BEGIN; -- Dictionary identity is case-insensitive in every client. Consolidate any -- legacy case-only duplicates before enforcing that contract atomically. CREATE TEMP TABLE dictionary_duplicate_merge ON COMMIT DROP AS SELECT id, first_value(id) OVER identity_order AS keeper_id, row_number() OVER identity_order AS duplicate_rank FROM public.dictionary WINDOW identity_order AS ( PARTITION BY user_id, lower(btrim(word)), category ORDER BY updated_at DESC, created_at DESC, id ); WITH aggregates AS ( SELECT merge.keeper_id, sum(dictionary.usage_count)::integer AS usage_count, max(dictionary.last_used_at) AS last_used_at, (array_agg(dictionary.pronunciation ORDER BY dictionary.updated_at DESC) FILTER (WHERE dictionary.pronunciation IS NOT NULL AND btrim(dictionary.pronunciation) <> ''))[1] AS pronunciation FROM dictionary_duplicate_merge AS merge JOIN public.dictionary AS dictionary ON dictionary.id = merge.id GROUP BY merge.keeper_id ) UPDATE public.dictionary AS dictionary SET word = btrim(dictionary.word), pronunciation = coalesce(aggregates.pronunciation, dictionary.pronunciation), usage_count = aggregates.usage_count, last_used_at = aggregates.last_used_at, updated_at = now() FROM aggregates WHERE dictionary.id = aggregates.keeper_id; DELETE FROM public.dictionary AS dictionary USING dictionary_duplicate_merge AS merge WHERE dictionary.id = merge.id AND merge.duplicate_rank > 1; CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_user_word_category_normalized ON public.dictionary(user_id, lower(btrim(word)), category); CREATE OR REPLACE FUNCTION public.normalize_dictionary_word() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN NEW.word := btrim(NEW.word); IF NEW.word = '' OR char_length(NEW.word) > 120 THEN RAISE EXCEPTION 'invalid_dictionary_word' USING ERRCODE = '22023'; END IF; IF NEW.pronunciation IS NOT NULL THEN NEW.pronunciation := nullif(btrim(NEW.pronunciation), ''); IF char_length(NEW.pronunciation) > 200 THEN RAISE EXCEPTION 'invalid_dictionary_pronunciation' USING ERRCODE = '22023'; END IF; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS normalize_dictionary_word_before_write ON public.dictionary; CREATE TRIGGER normalize_dictionary_word_before_write BEFORE INSERT OR UPDATE OF word, pronunciation ON public.dictionary FOR EACH ROW EXECUTE FUNCTION public.normalize_dictionary_word(); CREATE TABLE public.custom_instructions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, builtin_key text, name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 80), description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 240), prompt text NOT NULL CHECK (char_length(btrim(prompt)) BETWEEN 1 AND 4000), icon text NOT NULL DEFAULT 'sparkles' CHECK (char_length(icon) BETWEEN 1 AND 32), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (builtin_key IS NULL OR builtin_key IN ( 'translate_en', 'summarize', 'formal', 'explain_code' )) ); CREATE UNIQUE INDEX idx_custom_instructions_builtin ON public.custom_instructions(user_id, builtin_key) WHERE builtin_key IS NOT NULL; CREATE UNIQUE INDEX idx_custom_instructions_name_normalized ON public.custom_instructions(user_id, lower(btrim(name))); CREATE INDEX idx_custom_instructions_order ON public.custom_instructions(user_id, sort_order, created_at, id); CREATE OR REPLACE FUNCTION public.bump_custom_instruction_revision() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN NEW.name := btrim(NEW.name); NEW.description := btrim(NEW.description); NEW.prompt := btrim(NEW.prompt); NEW.revision := OLD.revision + 1; NEW.updated_at := now(); RETURN NEW; END; $$; CREATE TRIGGER bump_custom_instruction_revision_before_update BEFORE UPDATE ON public.custom_instructions FOR EACH ROW EXECUTE FUNCTION public.bump_custom_instruction_revision(); ALTER TABLE public.custom_instructions ENABLE ROW LEVEL SECURITY; CREATE POLICY "custom_instructions_read_own" ON public.custom_instructions FOR SELECT USING (user_id = auth.uid()); CREATE POLICY "custom_instructions_insert_custom" ON public.custom_instructions FOR INSERT WITH CHECK (user_id = auth.uid() AND builtin_key IS NULL); CREATE POLICY "custom_instructions_update_custom" ON public.custom_instructions FOR UPDATE USING (user_id = auth.uid() AND builtin_key IS NULL) WITH CHECK (user_id = auth.uid() AND builtin_key IS NULL); CREATE POLICY "custom_instructions_delete_custom" ON public.custom_instructions FOR DELETE USING (user_id = auth.uid() AND builtin_key IS NULL); REVOKE ALL ON public.custom_instructions FROM anon; GRANT SELECT, INSERT, UPDATE, DELETE ON public.custom_instructions TO authenticated; ALTER TABLE public.user_settings ADD COLUMN active_instruction_id uuid REFERENCES public.custom_instructions(id) ON DELETE SET NULL; CREATE OR REPLACE FUNCTION public.validate_active_instruction_owner() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN IF NEW.active_instruction_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM public.custom_instructions AS instruction WHERE instruction.id = NEW.active_instruction_id AND instruction.user_id = NEW.user_id ) THEN RAISE EXCEPTION 'active_instruction_not_owned' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; CREATE TRIGGER validate_active_instruction_owner_before_write BEFORE INSERT OR UPDATE OF active_instruction_id ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.validate_active_instruction_owner(); CREATE OR REPLACE FUNCTION public.bootstrap_custom_instructions() RETURNS SETOF public.custom_instructions LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; INSERT INTO public.custom_instructions ( user_id, builtin_key, name, description, prompt, icon, sort_order ) VALUES ( current_user_id, 'translate_en', 'Translate to English', 'Translate the input into natural English.', 'Translate the following text into natural English. Return only the translation.', 'translate', 10 ), ( current_user_id, 'summarize', 'Key summary', 'Summarize the key points in no more than three lines.', 'Summarize the following text in no more than three concise lines.', 'list', 20 ), ( current_user_id, 'formal', 'Professional rewrite', 'Rewrite in a formal business style while preserving meaning.', 'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.', 'briefcase', 30 ), ( current_user_id, 'explain_code', 'Explain code', 'Explain responsibilities and caveats in Korean.', 'Explain the following code in Korean, including its responsibilities and important caveats.', 'code', 40 ) ON CONFLICT (user_id, builtin_key) WHERE builtin_key IS NOT NULL DO NOTHING; INSERT INTO public.user_settings (user_id) VALUES (current_user_id) ON CONFLICT (user_id) DO NOTHING; RETURN QUERY SELECT instruction.* FROM public.custom_instructions AS instruction WHERE instruction.user_id = current_user_id ORDER BY instruction.sort_order, instruction.created_at, instruction.id; END; $$; CREATE OR REPLACE FUNCTION public.set_active_custom_instruction( instruction_id uuid ) RETURNS public.user_settings LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); settings public.user_settings; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF instruction_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM public.custom_instructions AS instruction WHERE instruction.id = instruction_id AND instruction.user_id = current_user_id ) THEN RAISE EXCEPTION 'instruction_not_found' USING ERRCODE = 'P0002'; END IF; INSERT INTO public.user_settings (user_id, active_instruction_id) VALUES (current_user_id, instruction_id) ON CONFLICT (user_id) DO UPDATE SET active_instruction_id = EXCLUDED.active_instruction_id, revision = public.user_settings.revision + 1 RETURNING * INTO settings; RETURN settings; END; $$; REVOKE ALL ON FUNCTION public.bootstrap_custom_instructions() FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.bootstrap_custom_instructions() TO authenticated; REVOKE ALL ON FUNCTION public.set_active_custom_instruction(uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.set_active_custom_instruction(uuid) TO authenticated; ALTER PUBLICATION supabase_realtime ADD TABLE public.custom_instructions; COMMIT;