From 532e8ae90cc2830043f8cb40d634e84a53ea0e84 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 26 Sep 2026 06:42:43 +0000 Subject: [PATCH 1/7] wip(db): current debate schema, legacy cleanup, pgTAP --- .../20260926090000_current_debates.sql | 255 ++++++++++++++++++ .../20260926090100_legacy_debate_cleanup.sql | 29 ++ supabase/tests/current_debates_test.sql | 206 ++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 supabase/migrations/20260926090000_current_debates.sql create mode 100644 supabase/migrations/20260926090100_legacy_debate_cleanup.sql create mode 100644 supabase/tests/current_debates_test.sql diff --git a/supabase/migrations/20260926090000_current_debates.sql b/supabase/migrations/20260926090000_current_debates.sql new file mode 100644 index 000000000..25a043cd7 --- /dev/null +++ b/supabase/migrations/20260926090000_current_debates.sql @@ -0,0 +1,255 @@ +-- Personal Current Debate: one motion that the signed-in user prepares across +-- every tool. Results and sources carry the debate they belong to; rows without +-- a debate are one-off work or legacy material awaiting confirmation. + +CREATE TABLE public.debates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + motion text NOT NULL CHECK (char_length(btrim(motion)) BETWEEN 1 AND 1200), + settings jsonb NOT NULL DEFAULT '{}'::jsonb + CHECK (jsonb_typeof(settings) = 'object' AND octet_length(settings::text) <= 4000), + instructions text NOT NULL DEFAULT '' CHECK (char_length(instructions) <= 2000), + last_activity_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + -- Child tables reference (user_id, id) so a row can only join its owner's debate. + CONSTRAINT debates_user_id_id_key UNIQUE (user_id, id) +); + +CREATE INDEX debates_user_activity_idx ON public.debates (user_id, last_activity_at DESC); + +ALTER TABLE public.debates ENABLE ROW LEVEL SECURITY; +CREATE POLICY debates_own ON public.debates + FOR ALL TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE TABLE public.current_debates ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + debate_id uuid NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT current_debates_debate_fkey FOREIGN KEY (user_id, debate_id) + REFERENCES public.debates (user_id, id) ON DELETE CASCADE +); + +CREATE INDEX current_debates_user_debate_idx ON public.current_debates (user_id, debate_id); + +ALTER TABLE public.current_debates ENABLE ROW LEVEL SECURITY; +CREATE POLICY current_debates_own ON public.current_debates + FOR ALL TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +-- Crossfire previously lived only in the browser; completed rounds now persist +-- beside the other tool results. +CREATE TABLE public.crossfires ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title text NOT NULL, + input jsonb NOT NULL, + output jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.crossfires ENABLE ROW LEVEL SECURITY; +CREATE POLICY crossfires_own ON public.crossfires + FOR ALL TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +DO $$ +DECLARE + v_table text; +BEGIN + FOREACH v_table IN ARRAY ARRAY[ + 'cases', 'attacks', 'brainstorms', 'factoids', 'chats', + 'flowcharts', 'sparring_sessions', 'crossfires', 'sources' + ] LOOP + EXECUTE format('ALTER TABLE public.%I ADD COLUMN debate_id uuid', v_table); + EXECUTE format( + 'ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (user_id, debate_id) REFERENCES public.debates (user_id, id) ON DELETE CASCADE', + v_table, v_table || '_debate_fkey' + ); + EXECUTE format( + 'CREATE INDEX %I ON public.%I (user_id, debate_id, created_at DESC)', + v_table || '_user_debate_idx', v_table + ); + IF v_table <> 'sources' THEN + EXECUTE format( + 'ALTER TABLE public.%I ADD COLUMN side text CHECK (side IN (''pro'', ''con'', ''both'', ''neutral''))', + v_table + ); + END IF; + END LOOP; +END; +$$; + +-- Every new source belongs to exactly one debate. Legacy rows may stay unsorted +-- until the user attaches them, but an attached source cannot be detached. +CREATE FUNCTION private.require_source_debate() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF NEW.debate_id IS NULL AND (TG_OP = 'INSERT' OR OLD.debate_id IS NOT NULL) THEN + RAISE EXCEPTION 'a source must belong to a debate' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; +REVOKE ALL ON FUNCTION private.require_source_debate() FROM PUBLIC, anon, authenticated; + +CREATE TRIGGER sources_require_debate + BEFORE INSERT OR UPDATE OF debate_id ON public.sources + FOR EACH ROW EXECUTE FUNCTION private.require_source_debate(); + +-- Meaningful work (a new result, source, or chat message in a debate) resets +-- the inactivity clock that drives the five-day check-in. +CREATE FUNCTION private.touch_debate_activity() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_debate_id uuid; +BEGIN + IF TG_TABLE_NAME = 'messages' THEN + SELECT debate_id INTO v_debate_id FROM public.chats WHERE id = NEW.chat_id; + ELSIF TG_OP = 'INSERT' OR NEW.debate_id IS DISTINCT FROM OLD.debate_id THEN + v_debate_id := NEW.debate_id; + END IF; + IF v_debate_id IS NOT NULL THEN + UPDATE public.debates SET last_activity_at = now() WHERE id = v_debate_id; + END IF; + RETURN NULL; +END; +$$; +REVOKE ALL ON FUNCTION private.touch_debate_activity() FROM PUBLIC, anon, authenticated; + +DO $$ +DECLARE + v_table text; +BEGIN + FOREACH v_table IN ARRAY ARRAY[ + 'cases', 'attacks', 'brainstorms', 'factoids', 'chats', + 'flowcharts', 'sparring_sessions', 'crossfires', 'sources' + ] LOOP + EXECUTE format( + 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OF debate_id ON public.%I FOR EACH ROW EXECUTE FUNCTION private.touch_debate_activity()', + v_table || '_touch_debate', v_table + ); + END LOOP; +END; +$$; + +CREATE TRIGGER messages_touch_debate + AFTER INSERT ON public.messages + FOR EACH ROW EXECUTE FUNCTION private.touch_debate_activity(); + +-- Attaches one-off or legacy work to one of the caller's debates. Sparring rows +-- are written only by the server, so the client cannot update them directly. +CREATE FUNCTION public.attach_to_debate(p_kind text, p_id uuid, p_debate_id uuid, p_side text DEFAULT NULL) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_rows integer; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '28000'; + END IF; + IF p_side IS NOT NULL AND p_side NOT IN ('pro', 'con', 'both', 'neutral') THEN + RAISE EXCEPTION 'invalid side' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.debates WHERE id = p_debate_id AND user_id = v_user_id) THEN + RAISE EXCEPTION 'debate not found' USING ERRCODE = 'P0002'; + END IF; + + CASE p_kind + WHEN 'case' THEN + UPDATE public.cases SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'attack' THEN + UPDATE public.attacks SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'brainstorm' THEN + UPDATE public.brainstorms SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'factoids' THEN + UPDATE public.factoids SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'chat' THEN + UPDATE public.chats SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'flowchart' THEN + UPDATE public.flowcharts SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'sparring' THEN + UPDATE public.sparring_sessions SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'crossfire' THEN + UPDATE public.crossfires SET debate_id = p_debate_id, side = COALESCE(p_side, side) + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + WHEN 'source' THEN + UPDATE public.sources SET debate_id = p_debate_id + WHERE id = p_id AND user_id = v_user_id AND debate_id IS NULL; + ELSE + RAISE EXCEPTION 'unknown item kind' USING ERRCODE = '22023'; + END CASE; + + GET DIAGNOSTICS v_rows = ROW_COUNT; + IF v_rows <> 1 THEN + RAISE EXCEPTION 'item not found or already in a debate' USING ERRCODE = 'P0002'; + END IF; +END; +$$; + +REVOKE ALL ON FUNCTION public.attach_to_debate(text, uuid, uuid, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.attach_to_debate(text, uuid, uuid, text) TO authenticated; + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.debates, public.current_debates, public.crossfires + TO authenticated, service_role; + +-- Account erase also removes debates and persisted Crossfire rounds. +CREATE OR REPLACE FUNCTION public.clear_current_user_data() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid := auth.uid(); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'authentication required'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended(v_user_id::text, 0)); + INSERT INTO public.user_data_clear_markers (user_id, cleared_at) + VALUES (v_user_id, clock_timestamp()) + ON CONFLICT (user_id) DO UPDATE SET cleared_at = EXCLUDED.cleared_at; + + DELETE FROM public.cases WHERE user_id = v_user_id; + DELETE FROM public.attacks WHERE user_id = v_user_id; + DELETE FROM public.brainstorms WHERE user_id = v_user_id; + DELETE FROM public.factoids WHERE user_id = v_user_id; + DELETE FROM public.chats WHERE user_id = v_user_id; + DELETE FROM public.flowcharts WHERE user_id = v_user_id; + DELETE FROM public.sparring_sessions WHERE user_id = v_user_id; + DELETE FROM public.crossfires WHERE user_id = v_user_id; + DELETE FROM public.evidence_cards WHERE user_id = v_user_id; + DELETE FROM public.sources WHERE user_id = v_user_id; + DELETE FROM public.source_folders WHERE user_id = v_user_id; + DELETE FROM public.api_keys WHERE user_id = v_user_id; + DELETE FROM public.current_debates WHERE user_id = v_user_id; + DELETE FROM public.debates WHERE user_id = v_user_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.clear_current_user_data() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.clear_current_user_data() TO authenticated; diff --git a/supabase/migrations/20260926090100_legacy_debate_cleanup.sql b/supabase/migrations/20260926090100_legacy_debate_cleanup.sql new file mode 100644 index 000000000..24bf2e7d8 --- /dev/null +++ b/supabase/migrations/20260926090100_legacy_debate_cleanup.sql @@ -0,0 +1,29 @@ +-- DESTRUCTIVE, ONE-TIME: remove legacy material that predates Current Debate +-- and is older than 30 days. Recent legacy material stays, without a debate, +-- in the Unsorted view until the user confirms where it belongs. +-- +-- The function only touches rows that are not in a debate, so rerunning it can +-- never remove debate work. Nothing schedules it; this migration calls it once. +-- Chat messages and source files are removed by their ON DELETE CASCADE keys. + +CREATE FUNCTION private.purge_legacy_debate_material(p_cutoff timestamptz) +RETURNS void +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + DELETE FROM public.cases WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.attacks WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.brainstorms WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.factoids WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.chats WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.flowcharts WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.sparring_sessions WHERE debate_id IS NULL AND created_at < p_cutoff; + DELETE FROM public.sources WHERE debate_id IS NULL AND created_at < p_cutoff; +END; +$$; + +REVOKE ALL ON FUNCTION private.purge_legacy_debate_material(timestamptz) + FROM PUBLIC, anon, authenticated, service_role; + +SELECT private.purge_legacy_debate_material(now() - interval '30 days'); diff --git a/supabase/tests/current_debates_test.sql b/supabase/tests/current_debates_test.sql new file mode 100644 index 000000000..31aba5047 --- /dev/null +++ b/supabase/tests/current_debates_test.sql @@ -0,0 +1,206 @@ +BEGIN; +SELECT plan(27); + +INSERT INTO auth.users (id) VALUES + ('d1111111-1111-4111-8111-111111111111'), + ('d2222222-2222-4222-8222-222222222222'); + +SELECT ok( + has_table_privilege('authenticated', 'public.debates', 'SELECT,INSERT,UPDATE,DELETE') + AND has_table_privilege('authenticated', 'public.current_debates', 'SELECT,INSERT,UPDATE,DELETE') + AND has_table_privilege('authenticated', 'public.crossfires', 'SELECT,INSERT,UPDATE,DELETE') + AND has_function_privilege('authenticated', 'public.attach_to_debate(text, uuid, uuid, text)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.attach_to_debate(text, uuid, uuid, text)', 'EXECUTE'), + 'signed-in clients can manage debates and attach work; anonymous callers cannot' +); +SELECT ok( + NOT has_function_privilege('authenticated', 'private.purge_legacy_debate_material(timestamptz)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'private.purge_legacy_debate_material(timestamptz)', 'EXECUTE'), + 'the one-time legacy purge is not callable through the Data API' +); + +-- Owner A: two debates, as a student preparing two different motions. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'd1111111-1111-4111-8111-111111111111', true); +INSERT INTO public.debates (id, user_id, motion, last_activity_at) VALUES + ('da000000-0000-4000-8000-000000000001', 'd1111111-1111-4111-8111-111111111111', 'This House would ban homework', now() - interval '6 days'), + ('da000000-0000-4000-8000-000000000002', 'd1111111-1111-4111-8111-111111111111', 'This House would abolish zoos', now() - interval '6 days'); + +SELECT lives_ok( + $$INSERT INTO public.current_debates (user_id, debate_id) + VALUES ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000001')$$, + 'a user can make their own debate current' +); +SELECT lives_ok( + $$INSERT INTO public.current_debates (user_id, debate_id) + VALUES ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000002') + ON CONFLICT (user_id) DO UPDATE SET debate_id = EXCLUDED.debate_id, updated_at = now()$$, + 'switching replaces the single current debate' +); +SELECT is( + (SELECT debate_id FROM public.current_debates), + 'da000000-0000-4000-8000-000000000002'::uuid, + 'exactly one current debate follows the user after switching' +); + +SELECT throws_ok( + $$INSERT INTO public.sources (user_id, title, content, type) + VALUES ('d1111111-1111-4111-8111-111111111111', 'Loose', 'No debate', 'text')$$, + '23514', + 'a source must belong to a debate', + 'new sources cannot be created outside a debate' +); + +INSERT INTO public.sources (user_id, debate_id, title, content, type) VALUES + ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000001', 'Homework study', 'Homework evidence', 'text'), + ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000002', 'Zoo study', 'Zoo evidence', 'text'); +SELECT is( + (SELECT array_agg(title) FROM public.sources WHERE debate_id = 'da000000-0000-4000-8000-000000000001'), + ARRAY['Homework study'], + 'each debate sees only its own sources' +); +SELECT throws_ok( + $$UPDATE public.sources SET debate_id = NULL WHERE title = 'Homework study'$$, + '23514', + 'a source must belong to a debate', + 'an attached source cannot be detached into a global library' +); +SELECT ok( + (SELECT last_activity_at > now() - interval '1 minute' FROM public.debates WHERE id = 'da000000-0000-4000-8000-000000000001'), + 'adding a source counts as meaningful work' +); + +INSERT INTO public.cases (user_id, debate_id, side, title, input, output) VALUES + ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000002', 'con', 'Zoo case', '{}', '{}'); +SELECT ok( + (SELECT last_activity_at > now() - interval '1 minute' FROM public.debates WHERE id = 'da000000-0000-4000-8000-000000000002'), + 'saving a result counts as meaningful work' +); +SELECT throws_ok( + $$INSERT INTO public.cases (user_id, debate_id, side, title, input, output) + VALUES ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000002', 'sideways', 'Bad', '{}', '{}')$$, + '23514', + NULL, + 'results record only A favor, En contra, both, or neutral' +); + +-- Owner B cannot see, reuse, or point at A's debates. +SELECT set_config('request.jwt.claim.sub', 'd2222222-2222-4222-8222-222222222222', true); +SELECT is((SELECT count(*) FROM public.debates), 0::bigint, 'debates are private to their owner'); +SELECT is((SELECT count(*) FROM public.sources), 0::bigint, 'sources in another user''s debate stay private'); +SELECT throws_ok( + $$INSERT INTO public.current_debates (user_id, debate_id) + VALUES ('d2222222-2222-4222-8222-222222222222', 'da000000-0000-4000-8000-000000000001')$$, + '23503', + NULL, + 'another user''s debate cannot become current' +); +SELECT throws_ok( + $$INSERT INTO public.sources (user_id, debate_id, title, content, type) + VALUES ('d2222222-2222-4222-8222-222222222222', 'da000000-0000-4000-8000-000000000001', 'Forged', 'x', 'text')$$, + '23503', + NULL, + 'a source cannot be filed into another user''s debate' +); +SELECT throws_ok( + $$INSERT INTO public.cases (user_id, debate_id, title, input, output) + VALUES ('d2222222-2222-4222-8222-222222222222', 'da000000-0000-4000-8000-000000000001', 'Forged', '{}', '{}')$$, + '23503', + NULL, + 'a result cannot be filed into another user''s debate' +); + +INSERT INTO public.cases (id, user_id, title, input, output) VALUES + ('dc000000-0000-4000-8000-000000000009', 'd2222222-2222-4222-8222-222222222222', 'B one-off', '{}', '{}'); +SELECT throws_ok( + $$SELECT public.attach_to_debate('case', 'dc000000-0000-4000-8000-000000000009', 'da000000-0000-4000-8000-000000000001')$$, + 'P0002', + 'debate not found', + 'one-off work cannot be attached to another user''s debate' +); + +-- Back to A: attach one-off and legacy work only after explicit confirmation. +SELECT set_config('request.jwt.claim.sub', 'd1111111-1111-4111-8111-111111111111', true); +INSERT INTO public.attacks (id, user_id, title, input, output) VALUES + ('dc000000-0000-4000-8000-000000000001', 'd1111111-1111-4111-8111-111111111111', 'One-off attack', '{}', '{}'); +SELECT lives_ok( + $$SELECT public.attach_to_debate('attack', 'dc000000-0000-4000-8000-000000000001', 'da000000-0000-4000-8000-000000000001', 'pro')$$, + 'one-off work can be attached to one of the owner''s debates' +); +SELECT is( + (SELECT debate_id::text || ':' || side FROM public.attacks WHERE id = 'dc000000-0000-4000-8000-000000000001'), + 'da000000-0000-4000-8000-000000000001:pro', + 'attachment records the debate and side' +); +SELECT throws_ok( + $$SELECT public.attach_to_debate('attack', 'dc000000-0000-4000-8000-000000000001', 'da000000-0000-4000-8000-000000000002')$$, + 'P0002', + 'item not found or already in a debate', + 'attached work cannot be silently moved into another debate' +); +SELECT throws_ok( + $$SELECT public.attach_to_debate('case', 'dc000000-0000-4000-8000-000000000009', 'da000000-0000-4000-8000-000000000001')$$, + 'P0002', + 'item not found or already in a debate', + 'another user''s one-off work cannot be pulled into a debate' +); + +RESET ROLE; +INSERT INTO public.sparring_sessions (id, user_id, motion, user_side, persona, status) VALUES + ('dc000000-0000-4000-8000-000000000002', 'd1111111-1111-4111-8111-111111111111', 'This House would ban homework', 'con', 'coach', 'complete'); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'd1111111-1111-4111-8111-111111111111', true); +SELECT lives_ok( + $$SELECT public.attach_to_debate('sparring', 'dc000000-0000-4000-8000-000000000002', 'da000000-0000-4000-8000-000000000001', 'con')$$, + 'server-written sparring rounds attach through the RPC' +); + +DELETE FROM public.debates WHERE id = 'da000000-0000-4000-8000-000000000002'; +SELECT ok( + NOT EXISTS (SELECT 1 FROM public.sources WHERE title = 'Zoo study') + AND NOT EXISTS (SELECT 1 FROM public.cases WHERE title = 'Zoo case') + AND NOT EXISTS (SELECT 1 FROM public.current_debates), + 'deleting a debate removes its sources, results, and current pointer' +); +SELECT is( + (SELECT count(*) FROM public.sources WHERE debate_id = 'da000000-0000-4000-8000-000000000001'), + 1::bigint, + 'deleting one debate leaves the other debate intact' +); +RESET ROLE; + +-- The one-time cleanup keeps recent legacy work and any debate work. +INSERT INTO public.cases (user_id, title, input, output, created_at) VALUES + ('d1111111-1111-4111-8111-111111111111', 'Legacy old', '{}', '{}', now() - interval '31 days'), + ('d1111111-1111-4111-8111-111111111111', 'Legacy recent', '{}', '{}', now() - interval '29 days'); +INSERT INTO public.cases (user_id, debate_id, title, input, output, created_at) VALUES + ('d1111111-1111-4111-8111-111111111111', 'da000000-0000-4000-8000-000000000001', 'Debate old', '{}', '{}', now() - interval '90 days'); +ALTER TABLE public.sources DISABLE TRIGGER sources_require_debate; +INSERT INTO public.sources (user_id, title, content, type, created_at) VALUES + ('d1111111-1111-4111-8111-111111111111', 'Legacy old source', 'x', 'text', now() - interval '31 days'), + ('d1111111-1111-4111-8111-111111111111', 'Legacy recent source', 'x', 'text', now() - interval '2 days'); +ALTER TABLE public.sources ENABLE TRIGGER sources_require_debate; +INSERT INTO public.chats (id, user_id, title, created_at) VALUES + ('dc000000-0000-4000-8000-000000000003', 'd1111111-1111-4111-8111-111111111111', 'Legacy old chat', now() - interval '45 days'); +INSERT INTO public.messages (chat_id, role, content) VALUES + ('dc000000-0000-4000-8000-000000000003', 'user', 'old message'); + +SELECT private.purge_legacy_debate_material(now() - interval '30 days'); + +SELECT is( + (SELECT array_agg(title ORDER BY title) FROM public.cases WHERE user_id = 'd1111111-1111-4111-8111-111111111111'), + ARRAY['Debate old', 'Legacy recent'], + 'the cutoff removes only unsorted cases older than 30 days' +); +SELECT is( + (SELECT array_agg(title ORDER BY title) FROM public.sources WHERE debate_id IS NULL), + ARRAY['Legacy recent source'], + 'recent unsorted sources remain for the user to confirm' +); +SELECT ok( + NOT EXISTS (SELECT 1 FROM public.messages WHERE chat_id = 'dc000000-0000-4000-8000-000000000003'), + 'old chats are removed with their messages' +); + +SELECT * FROM finish(); +ROLLBACK; From 0b32298aa8edfb25a4d7ecdea91d9bd8b847123e Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 26 Sep 2026 10:45:18 +0000 Subject: [PATCH 2/7] wip: current debate client, stores, and feature wiring --- src/App.tsx | 3 + .../features/AutoSavedSourcesUndo.tsx | 5 +- .../features/brainstorm/BrainstormForm.tsx | 9 +- .../features/debate/CurrentDebateMenu.tsx | 81 +++++ .../features/debate/CurrentDebateStrip.tsx | 89 +++++ .../features/debate/DebateDialogs.tsx | 226 ++++++++++++ src/components/features/debate/DebateForm.tsx | 122 +++++++ .../features/debate/DebateMotionGate.tsx | 115 ++++++ .../features/debate/UnsortedWork.tsx | 116 ++++++ .../features/debate/debate-items.ts | 110 ++++++ .../features/generate/GenerateForm.tsx | 9 +- .../features/sparring/SparringSetup.tsx | 13 +- src/components/layout/AppLayout.tsx | 122 +++---- src/lib/ai/client.ts | 38 +- src/lib/debate/motion.ts | 35 ++ src/lib/route-modules.ts | 1 + src/lib/stores/debate-store.ts | 266 ++++++++++++++ src/lib/stores/session-store.ts | 343 +++++++++++++----- src/lib/stores/sparring-store.ts | 26 +- src/lib/supabase/AuthProvider.tsx | 8 +- src/locales/en.json | 110 +++++- src/locales/es.json | 110 +++++- src/locales/hu.json | 110 +++++- src/pages/AskPage.tsx | 31 +- src/pages/AttackPage.tsx | 13 +- src/pages/BrainstormPage.tsx | 19 +- src/pages/CrossfirePage.tsx | 47 ++- src/pages/CurrentDebatePage.tsx | 210 +++++++++++ src/pages/FactoidsPage.tsx | 21 +- src/pages/FlowchartPage.tsx | 10 +- src/pages/GeneratePage.tsx | 25 +- src/pages/HistoryPage.tsx | 76 ++-- src/pages/HomePage.tsx | 47 +++ src/pages/LibraryPage.tsx | 24 +- src/pages/SettingsPage.tsx | 2 + src/pages/SparringPage.tsx | 21 +- src/test/ai/client-branches.test.ts | 20 +- src/test/ai/client.test.ts | 6 +- src/test/ai/crossfire-client.test.ts | 12 +- src/test/ai/request-dispatch.test.ts | 6 +- src/test/components/AppLayout.test.tsx | 80 ++-- .../components/AutoSavedSourcesUndo.test.tsx | 10 +- src/test/helpers/debate.ts | 36 ++ src/test/mocks/supabase.ts | 28 +- src/test/pages/ConversationPages.test.tsx | 2 + src/test/pages/CrossfirePage.test.tsx | 2 + src/test/pages/GeneratePage.test.tsx | 2 + src/test/pages/HistoryPage.test.tsx | 34 +- src/test/pages/LibraryPage.test.tsx | 27 ++ src/test/pages/SparringPage.test.tsx | 2 + src/test/stores/session-store.test.ts | 9 +- src/test/stores/sparring-store.test.ts | 4 +- 52 files changed, 2546 insertions(+), 347 deletions(-) create mode 100644 src/components/features/debate/CurrentDebateMenu.tsx create mode 100644 src/components/features/debate/CurrentDebateStrip.tsx create mode 100644 src/components/features/debate/DebateDialogs.tsx create mode 100644 src/components/features/debate/DebateForm.tsx create mode 100644 src/components/features/debate/DebateMotionGate.tsx create mode 100644 src/components/features/debate/UnsortedWork.tsx create mode 100644 src/components/features/debate/debate-items.ts create mode 100644 src/lib/debate/motion.ts create mode 100644 src/lib/stores/debate-store.ts create mode 100644 src/pages/CurrentDebatePage.tsx create mode 100644 src/test/helpers/debate.ts diff --git a/src/App.tsx b/src/App.tsx index 0370e716a..14bf0fae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ const LandingPage = lazy(routeLoaders["/"]); const TermsPage = lazy(routeLoaders["/terms"]); const PrivacyPage = lazy(routeLoaders["/privacy"]); const HomePage = lazy(routeLoaders["/app"]); +const CurrentDebatePage = lazy(routeLoaders["/app/debate"]); const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]); const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]); @@ -97,6 +98,7 @@ const ROUTE_FALLBACKS: Record = { "/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" }, "/login": { variant: "public", label: "Sign in", labelKey: "login.title" }, "/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" }, + "/app/debate": { variant: "settings/list", label: "Current debate", labelKey: "nav.currentDebate" }, "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" }, "/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" }, "/app/attack": { variant: "tool-form", label: "Attack", labelKey: "nav.attack" }, @@ -240,6 +242,7 @@ function AppRoutes() { } > } /> + } /> {COLLABORATION_URL && ( <> } /> diff --git a/src/components/features/AutoSavedSourcesUndo.tsx b/src/components/features/AutoSavedSourcesUndo.tsx index f35bcd760..2e8470d0c 100644 --- a/src/components/features/AutoSavedSourcesUndo.tsx +++ b/src/components/features/AutoSavedSourcesUndo.tsx @@ -4,13 +4,16 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useSessionStore } from '@/lib/stores/session-store'; +import { useDebateStore } from '@/lib/stores/debate-store'; export function AutoSavedSourcesUndo() { const { t } = useTranslation(); const sourceLibrary = useSessionStore((state) => state.sourceLibrary); const removeSource = useSessionStore((state) => state.removeSource); const [removing, setRemoving] = useState(false); - const autoSavedSources = sourceLibrary.filter((source) => source.folder === 'Auto-saved'); + const currentDebateId = useDebateStore((state) => state.currentDebateId); + // Undo only this debate's auto-saved research; other debates keep theirs. + const autoSavedSources = sourceLibrary.filter((source) => source.folder === 'Auto-saved' && source.debateId === currentDebateId); if (autoSavedSources.length === 0) return null; diff --git a/src/components/features/brainstorm/BrainstormForm.tsx b/src/components/features/brainstorm/BrainstormForm.tsx index 3422aa7de..d3faac497 100644 --- a/src/components/features/brainstorm/BrainstormForm.tsx +++ b/src/components/features/brainstorm/BrainstormForm.tsx @@ -17,6 +17,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { BrainstormInputSchema, type BrainstormInput } from '@/lib/ai/schemas/brainstorm'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { useDebateMotionPrefill } from '@/components/features/debate/CurrentDebateStrip'; import { useSessionStore } from '@/lib/stores/session-store'; import { useEffect } from 'react'; import { ModelSelector } from '@/components/ModelSelector'; @@ -24,13 +25,15 @@ import { ModelSelector } from '@/components/ModelSelector'; interface BrainstormFormProps { onSubmit: (data: BrainstormInput) => void; isLoading: boolean; + /** Fill the motion from the Current Debate. */ + debatePrefill?: boolean; } -export function BrainstormForm({ onSubmit, isLoading }: BrainstormFormProps) { +export function BrainstormForm({ onSubmit, isLoading, debatePrefill = true }: BrainstormFormProps) { const { t } = useTranslation(); const { brainstormForm, setBrainstormForm } = useSessionStore(); - const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ + const { register, handleSubmit, setValue, getValues, watch, formState: { errors } } = useForm({ resolver: zodResolver(BrainstormInputSchema), defaultValues: { motion: '', @@ -49,6 +52,8 @@ export function BrainstormForm({ onSubmit, isLoading }: BrainstormFormProps) { return () => subscription.unsubscribe(); }, [watch, setBrainstormForm]); + useDebateMotionPrefill(() => getValues('motion'), (motion) => setValue('motion', motion, { shouldValidate: true }), !debatePrefill); + const currentValues = watch(); return ( diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx new file mode 100644 index 000000000..f41ed2148 --- /dev/null +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { formatRelativeDate } from '@/lib/date-format'; +import { cn } from '@/lib/utils'; +import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; +import { useDebateCounts } from './CurrentDebateStrip'; + +/** + * Header control for the Current Debate. Desktop shows only the label; the + * mobile row also shows the motion on one truncated line. + */ +export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const openDialog = useDebateStore((state) => state.openDialog); + const setOneOff = useDebateStore((state) => state.setOneOff); + const counts = useDebateCounts(debate?.id); + const [open, setOpen] = useState(false); + + const triggerClass = cn( + 'motion-press nav-glow flex min-w-0 items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + variant === 'desktop' && 'xl:px-3 whitespace-nowrap', + variant === 'mobile' && 'w-full', + ); + + if (!debate) { + return ( + + ); + } + + const act = (action: () => void) => { + setOpen(false); + action(); + }; + + return ( + + + + + +
+

{debate.motion}

+

+ {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} +

+

+ {t('debate.counts', { sources: counts.sources, outputs: counts.outputs, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} +

+ {oneOff &&

{t('debate.oneOffActive', 'One-off mode is on')}

} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx new file mode 100644 index 000000000..645bc2884 --- /dev/null +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { shouldPrefillMotion } from '@/lib/debate/motion'; +import { useCurrentDebate, useDebateStore, type DebateSide } from '@/lib/stores/debate-store'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; + +// eslint-disable-next-line react-refresh/only-export-components +export function sideLabelKey(side: DebateSide): string { + return `debate.side.${side}`; +} + +/** Sources and saved outputs that belong to one debate. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateCounts(debateId: string | null | undefined) { + const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId).length : 0); + const outputs = useSessionStore((state) => debateId ? [ + state.savedCases, state.savedAttacks, state.savedBrainstorms, state.savedFactoids, + state.savedChats, state.savedFlowcharts, state.savedCrossfires, + ].reduce((count, list) => count + list.filter((item) => item.debateId === debateId).length, 0) : 0); + const sparring = useSparringStore((state) => debateId ? state.savedSessions.filter((session) => session.debateId === debateId).length : 0); + return { sources, outputs: outputs + sparring }; +} + +/** + * Fills a feature's motion from the Current Debate on open and after a switch, + * without overwriting text the user typed themselves. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateMotionPrefill(getMotion: () => string | undefined, setMotion: (motion: string) => void, disabled = false) { + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const getRef = useRef(getMotion); + const setRef = useRef(setMotion); + getRef.current = getMotion; + setRef.current = setMotion; + + useEffect(() => { + if (disabled || oneOff || !debate) return; + const known = useDebateStore.getState().debates.map((item) => item.motion); + if (shouldPrefillMotion(getRef.current(), debate.motion, known)) setRef.current(debate.motion); + }, [debate, disabled, oneOff]); +} + +/** Compact “Working in” line directly below each feature title. */ +export function CurrentDebateStrip({ side, hidden = false, className = 'mb-6' }: { side?: DebateSide; hidden?: boolean; className?: string }) { + const { t } = useTranslation(); + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const setOneOff = useDebateStore((state) => state.setOneOff); + const openDialog = useDebateStore((state) => state.openDialog); + if (hidden) return null; + + const base = `${className} flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border px-3 py-2 text-sm`; + + if (oneOff) { + return ( +
+ {t('debate.oneOffStrip', 'One-off mode: nothing here uses or saves to a debate.')} + {debate && } +
+ ); + } + + if (!debate) { + return ( +
+ {t('debate.noCurrentStrip', 'No current debate. Results stay one-off until you start one.')} + +
+ ); + } + + return ( +
+ + {t('debate.workingIn', 'Working in:')} + {debate.motion} + + {side && {t(sideLabelKey(side))}} +
+ + +
+
+ ); +} diff --git a/src/components/features/debate/DebateDialogs.tsx b/src/components/features/debate/DebateDialogs.tsx new file mode 100644 index 000000000..15ea8fec8 --- /dev/null +++ b/src/components/features/debate/DebateDialogs.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useToast } from '@/hooks/use-toast'; +import { formatRelativeDate } from '@/lib/date-format'; +import { cn } from '@/lib/utils'; +import { isDebateInactive, useCurrentDebate, useDebateStore, type Debate } from '@/lib/stores/debate-store'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { DebateForm, type DebateFormValues } from './DebateForm'; + +/** Switching or starting a debate from navigation clears open tool results so debates never mix. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateNavigationActions() { + const switchDebate = useDebateStore((state) => state.switchDebate); + const createDebate = useDebateStore((state) => state.createDebate); + const clearSession = useSessionStore((state) => state.clearSession); + return { + switchTo: async (id: string) => { + await switchDebate(id); + clearSession(); + }, + create: async (values: DebateFormValues) => { + const debate = await createDebate(values); + clearSession(); + return debate; + }, + }; +} + +export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: () => void; excludeCurrent?: boolean }) { + const { t } = useTranslation(); + const { toast } = useToast(); + const debates = useDebateStore((state) => state.debates); + const currentDebateId = useDebateStore((state) => state.currentDebateId); + const deleteDebate = useDebateStore((state) => state.deleteDebate); + const { switchTo } = useDebateNavigationActions(); + const [confirmingId, setConfirmingId] = useState(null); + const visible = excludeCurrent ? debates.filter((debate) => debate.id !== currentDebateId) : debates; + + if (visible.length === 0) { + return

{t('debate.noOtherDebates', 'You have no other debates yet.')}

; + } + + const pick = async (debate: Debate) => { + try { + await switchTo(debate.id); + onPicked(); + } catch { + toast({ title: t('debate.switchFailed', 'Could not switch debates'), variant: 'destructive' }); + } + }; + + const remove = async (debate: Debate) => { + try { + await deleteDebate(debate.id); + setConfirmingId(null); + toast({ title: t('debate.deleted', 'Debate deleted') }); + } catch { + toast({ title: t('debate.deleteFailed', 'Could not delete the debate'), variant: 'destructive' }); + } + }; + + return ( +
    + {visible.map((debate) => { + const isCurrent = debate.id === currentDebateId; + return ( +
  • +
    + + {isCurrent ? ( + + ) : ( + + )} +
    + {confirmingId === debate.id && ( +
    + {t('debate.deleteWarning', 'Deletes its sources and saved work.')} + + +
    + )} +
  • + ); + })} +
+ ); +} + +/** Global Switch and Start-new dialogs, opened from the header, strip, Home, or dashboard. */ +export function DebateDialogs() { + const { t } = useTranslation(); + const dialog = useDebateStore((state) => state.dialog); + const dialogMotion = useDebateStore((state) => state.dialogMotion); + const openDialog = useDebateStore((state) => state.openDialog); + const { create } = useDebateNavigationActions(); + const close = () => openDialog(null); + + return ( + { if (!open) close(); }}> + + {dialog === 'create' ? ( + <> + + {t('debate.startNew', 'Start new debate')} + {t('debate.startNewDesc', 'Your tools, sources, and results will follow this motion. Older debates stay available.')} + + { + await create(values); + close(); + }} + /> + + ) : ( + <> + + {t('debate.switch', 'Switch debate')} + {t('debate.switchDesc', 'Pick the motion you are preparing now.')} + + + + + )} + + + ); +} + +/** + * After five days without meaningful work the app is blocked until the user + * confirms the Current Debate, starts a new one, or switches. + */ +export function InactiveDebateGate() { + const { t } = useTranslation(); + const { toast } = useToast(); + const debate = useCurrentDebate(); + const hydrated = useDebateStore((state) => state.hydrated); + const continueDebate = useDebateStore((state) => state.continueDebate); + const { create } = useDebateNavigationActions(); + const [mode, setMode] = useState<'ask' | 'create' | 'switch'>('ask'); + + const open = Boolean(hydrated && debate && isDebateInactive(debate)); + if (!open || !debate) return null; + + const handleContinue = async () => { + try { + await continueDebate(); + } catch { + toast({ title: t('debate.continueFailed', 'Could not update the debate'), variant: 'destructive' }); + } + }; + + return ( + + event.preventDefault()}> + + {t('debate.gateTitle', 'Still preparing this debate?')} + +
+

{debate.motion}

+

{t('debate.gateDesc', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}. Choose how to continue.' })}

+
+
+
+ {mode === 'ask' && ( +
+ + + +
+ )} + {mode === 'create' && ( + setMode('ask')} + onSubmit={async (values) => { + await create(values); + setMode('ask'); + }} + /> + )} + {mode === 'switch' && ( +
+ setMode('ask')} /> + +
+ )} +
+
+ ); +} diff --git a/src/components/features/debate/DebateForm.tsx b/src/components/features/debate/DebateForm.tsx new file mode 100644 index 000000000..d21e628b6 --- /dev/null +++ b/src/components/features/debate/DebateForm.tsx @@ -0,0 +1,122 @@ +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { MAX_INSTRUCTIONS_LENGTH, MAX_MOTION_LENGTH, type DebateSettings } from '@/lib/stores/debate-store'; + +export interface DebateFormValues { + motion: string; + settings: DebateSettings; + instructions: string; +} + +const FORMATS = ['custom', 'bp', 'policy', 'classroom'] as const; +const ROLES = ['general', 'intro', 'r1', 'r2', 'conc'] as const; +const ROLE_LABEL_KEYS: Record<(typeof ROLES)[number], string> = { + general: 'debate.roleAny', + intro: 'generateForm.roleIntro', + r1: 'generateForm.roleR1', + r2: 'generateForm.roleR2', + conc: 'generateForm.roleConc', +}; + +interface DebateFormProps { + initial?: Partial; + submitLabel: string; + onSubmit: (values: DebateFormValues) => Promise; + onCancel?: () => void; + /** Motion is fixed when editing only settings. */ + showDetails?: boolean; + idPrefix?: string; +} + +export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetails = true, idPrefix = 'debate' }: DebateFormProps) { + const { t } = useTranslation(); + const [motion, setMotion] = useState(initial?.motion ?? ''); + const [format, setFormat] = useState(initial?.settings?.format ?? 'custom'); + const [role, setRole] = useState(initial?.settings?.role ?? 'general'); + const [instructions, setInstructions] = useState(initial?.instructions ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!motion.trim()) { + setError(t('debate.motionRequired', 'Enter the motion you are preparing.')); + return; + } + setSaving(true); + setError(''); + try { + await onSubmit({ motion: motion.trim(), settings: { format, role }, instructions: instructions.trim() }); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : t('debate.saveFailed', 'Could not save the debate.')); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +