From 4807a5283d3e0bd4e9c3f9a1766ffa5d35db6471 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:21 +0900 Subject: [PATCH] fix(db): allow deleting meeting templates that past meetings reference --- ...20260929000003_meeting_template_delete.sql | 70 ++++++++ .../meeting-template-delete.integration.sql | 162 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 server/supabase/migrations/20260929000003_meeting_template_delete.sql create mode 100644 server/supabase/tests/meeting-template-delete.integration.sql diff --git a/server/supabase/migrations/20260929000003_meeting_template_delete.sql b/server/supabase/migrations/20260929000003_meeting_template_delete.sql new file mode 100644 index 0000000..c5add7b --- /dev/null +++ b/server/supabase/migrations/20260929000003_meeting_template_delete.sql @@ -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; diff --git a/server/supabase/tests/meeting-template-delete.integration.sql b/server/supabase/tests/meeting-template-delete.integration.sql new file mode 100644 index 0000000..4376c80 --- /dev/null +++ b/server/supabase/tests/meeting-template-delete.integration.sql @@ -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;