fix(db): allow deleting meeting templates that past meetings reference
This commit is contained in:
parent
f04a7f9944
commit
4807a5283d
2 changed files with 232 additions and 0 deletions
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- 회의에 한 번이라도 쓰인 사용자 회의 문서 템플릿을 삭제할 수 있게 한다.
|
||||||
|
--
|
||||||
|
-- 문제: 20260821000025 가 meetings.template_id 를 ON DELETE RESTRICT 로 만들었고,
|
||||||
|
-- enforce_meeting_creation_identity_v1 은 RPC 로 생성된 회의의 template_id 변경을
|
||||||
|
-- 전부 거부했다. 그래서 커스텀 템플릿으로 회의를 한 번 만들면
|
||||||
|
-- delete_user_template_v1 / sync_delete_user_template_v1 이 23503(FK 위반)으로
|
||||||
|
-- 영구히 실패했고(데스크톱 아웃박스 삭제가 매 플러시마다 재시도), 회의에서
|
||||||
|
-- 템플릿 연결을 끊을 방법도 없었다.
|
||||||
|
--
|
||||||
|
-- 수정:
|
||||||
|
-- 1) FK 를 ON DELETE SET NULL 로 바꾼다 (meeting_documents.template_id 등 다른
|
||||||
|
-- user_templates 참조와 동일). 템플릿을 지우면 과거 회의에서 분리만 된다.
|
||||||
|
-- 2) RPC 생성 회의의 template_id 는 여전히 다른 템플릿으로 바꿀 수 없지만 NULL
|
||||||
|
-- 로의 분리는 허용한다. 원래 템플릿은 creation_request_hash 에 이미 기록되어
|
||||||
|
-- 있으므로 생성 무결성(idempotency 재시도 판정)은 그대로 유지된다. FK 의
|
||||||
|
-- SET NULL 참조 동작도 같은 BEFORE UPDATE OF template_id 트리거를 거치므로
|
||||||
|
-- 이 완화가 없으면 1) 만으로는 삭제가 여전히 실패한다.
|
||||||
|
|
||||||
|
ALTER TABLE public.meetings
|
||||||
|
DROP CONSTRAINT IF EXISTS meetings_template_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE public.meetings
|
||||||
|
ADD CONSTRAINT meetings_template_id_fkey
|
||||||
|
FOREIGN KEY (template_id)
|
||||||
|
REFERENCES public.user_templates(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE public.meetings
|
||||||
|
VALIDATE CONSTRAINT meetings_template_id_fkey;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_meeting_creation_identity_v1()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = pg_catalog, public, auth
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
actor_id uuid := auth.uid();
|
||||||
|
rpc_actor text := current_setting('d3ro.meeting_creation_actor', true);
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
IF NEW.creation_idempotency_key IS NOT NULL THEN
|
||||||
|
IF actor_id IS NULL OR rpc_actor IS DISTINCT FROM actor_id::text THEN
|
||||||
|
RAISE EXCEPTION 'meeting_creation_rpc_required' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
ELSIF NEW.template_id IS NOT NULL
|
||||||
|
OR NEW.language IS NOT NULL
|
||||||
|
OR NEW.attendees <> '[]'::jsonb THEN
|
||||||
|
RAISE EXCEPTION 'meeting_creation_metadata_requires_rpc' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.creation_idempotency_key IS DISTINCT FROM OLD.creation_idempotency_key
|
||||||
|
OR NEW.creation_request_hash IS DISTINCT FROM OLD.creation_request_hash THEN
|
||||||
|
RAISE EXCEPTION 'meeting_creation_identity_immutable' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
-- 다른 템플릿으로의 재지정은 금지, NULL 로의 분리(템플릿 삭제 포함)는 허용.
|
||||||
|
IF OLD.creation_idempotency_key IS NOT NULL
|
||||||
|
AND NEW.template_id IS DISTINCT FROM OLD.template_id
|
||||||
|
AND NEW.template_id IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'meeting_creation_template_immutable' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.enforce_meeting_creation_identity_v1()
|
||||||
|
FROM PUBLIC, anon, authenticated;
|
||||||
162
server/supabase/tests/meeting-template-delete.integration.sql
Normal file
162
server/supabase/tests/meeting-template-delete.integration.sql
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- Regression for 20260929000003_meeting_template_delete.sql.
|
||||||
|
-- A custom meeting-document template that was used to create a meeting
|
||||||
|
-- (mobile_create_meeting_workspace_v2) must still be deletable through
|
||||||
|
-- delete_user_template_v1 and sync_delete_user_template_v1. The meeting stays
|
||||||
|
-- and is detached (template_id = NULL) with its creation identity intact.
|
||||||
|
-- Re-pointing an RPC-created meeting at a different template stays forbidden.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF condition IS NOT TRUE THEN
|
||||||
|
RAISE EXCEPTION 'assertion_failed: %', message;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION pg_temp.act_as(uid uuid)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE sql
|
||||||
|
AS $$
|
||||||
|
SELECT set_config(
|
||||||
|
'request.jwt.claims',
|
||||||
|
json_build_object('sub', uid, 'role', 'authenticated')::text,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
id, aud, role, email, encrypted_password, email_confirmed_at,
|
||||||
|
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'32000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
|
||||||
|
'meeting-template-delete@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||||
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TEMP TABLE fixture_ids (
|
||||||
|
label text PRIMARY KEY,
|
||||||
|
id uuid NOT NULL
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
GRANT ALL ON fixture_ids TO authenticated;
|
||||||
|
|
||||||
|
SELECT pg_temp.act_as('32000000-0000-4000-8000-000000000001');
|
||||||
|
SET LOCAL ROLE authenticated;
|
||||||
|
|
||||||
|
-- Three custom meeting-document templates: one deleted via the mobile RPC,
|
||||||
|
-- one via the desktop sync RPC, one used as a re-point target.
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'weekly', (public.create_user_template_v1(
|
||||||
|
'meeting_document', 'Weekly sync', NULL, '[]'::jsonb, NULL, 'Summarize the weekly sync.'
|
||||||
|
)).id;
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'retro', (public.create_user_template_v1(
|
||||||
|
'meeting_document', 'Retro', NULL, '[]'::jsonb, NULL, 'Summarize the retrospective.'
|
||||||
|
)).id;
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'other', (public.create_user_template_v1(
|
||||||
|
'meeting_document', 'Other', NULL, '[]'::jsonb, NULL, 'Summarize.'
|
||||||
|
)).id;
|
||||||
|
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'weekly_meeting', (public.mobile_create_meeting_workspace_v2(
|
||||||
|
'Weekly sync 09-28', '["Alice"]'::jsonb, 'en',
|
||||||
|
(SELECT id FROM fixture_ids WHERE label = 'weekly'),
|
||||||
|
'32000000-0000-4000-8000-0000000000a1'
|
||||||
|
)).id;
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'retro_meeting', (public.mobile_create_meeting_workspace_v2(
|
||||||
|
'Retro 09-28', '[]'::jsonb, 'ko',
|
||||||
|
(SELECT id FROM fixture_ids WHERE label = 'retro'),
|
||||||
|
'32000000-0000-4000-8000-0000000000a2'
|
||||||
|
)).id;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE original_hashes ON COMMIT DROP AS
|
||||||
|
SELECT id, creation_idempotency_key, creation_request_hash
|
||||||
|
FROM public.meetings
|
||||||
|
WHERE id IN (SELECT id FROM fixture_ids WHERE label LIKE '%_meeting');
|
||||||
|
|
||||||
|
-- Re-pointing an RPC-created meeting at a different template stays forbidden.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.meetings
|
||||||
|
SET template_id = (SELECT id FROM fixture_ids WHERE label = 'other')
|
||||||
|
WHERE id = (SELECT id FROM fixture_ids WHERE label = 'weekly_meeting');
|
||||||
|
RAISE EXCEPTION 'assertion_failed: template re-point was allowed';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN insufficient_privilege THEN
|
||||||
|
PERFORM pg_temp.assert_true(
|
||||||
|
SQLERRM = 'meeting_creation_template_immutable',
|
||||||
|
format('re-point denied by the identity trigger, got %s', SQLERRM)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 1) Mobile delete: delete_user_template_v1 no longer fails with 23503.
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
public.delete_user_template_v1(
|
||||||
|
(SELECT id FROM fixture_ids WHERE label = 'weekly'),
|
||||||
|
(SELECT revision FROM public.user_templates
|
||||||
|
WHERE id = (SELECT id FROM fixture_ids WHERE label = 'weekly'))
|
||||||
|
),
|
||||||
|
'template used by a meeting can be deleted'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2) Desktop sync delete: sync_delete_user_template_v1 drains the outbox.
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
public.sync_delete_user_template_v1((SELECT id FROM fixture_ids WHERE label = 'retro')),
|
||||||
|
'sync delete of a template used by a meeting succeeds'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.user_templates
|
||||||
|
WHERE id IN (SELECT id FROM fixture_ids WHERE label IN ('weekly', 'retro'))
|
||||||
|
),
|
||||||
|
'deleted templates are gone on the server'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT count(*) FROM public.meetings m
|
||||||
|
JOIN original_hashes o ON o.id = m.id
|
||||||
|
WHERE m.template_id IS NULL
|
||||||
|
AND m.creation_idempotency_key = o.creation_idempotency_key
|
||||||
|
AND m.creation_request_hash = o.creation_request_hash) = 2,
|
||||||
|
'meetings survive, are detached, and keep their creation identity'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Manual detach to NULL is allowed on an RPC-created meeting.
|
||||||
|
INSERT INTO fixture_ids(label, id)
|
||||||
|
SELECT 'other_meeting', (public.mobile_create_meeting_workspace_v2(
|
||||||
|
'Other 09-28', '[]'::jsonb, 'en',
|
||||||
|
(SELECT id FROM fixture_ids WHERE label = 'other'),
|
||||||
|
'32000000-0000-4000-8000-0000000000a3'
|
||||||
|
)).id;
|
||||||
|
|
||||||
|
UPDATE public.meetings
|
||||||
|
SET template_id = NULL
|
||||||
|
WHERE id = (SELECT id FROM fixture_ids WHERE label = 'other_meeting');
|
||||||
|
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT template_id IS NULL FROM public.meetings
|
||||||
|
WHERE id = (SELECT id FROM fixture_ids WHERE label = 'other_meeting')),
|
||||||
|
'RPC-created meeting can be detached from its template'
|
||||||
|
);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
|
||||||
|
-- Schema contract: the FK detaches instead of restricting.
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT confdeltype = 'n' FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.meetings'::regclass
|
||||||
|
AND conname = 'meetings_template_id_fkey'),
|
||||||
|
'meetings.template_id FK is ON DELETE SET NULL'
|
||||||
|
);
|
||||||
|
|
||||||
|
ROLLBACK;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue