d3ro-voice/server/supabase/migrations/20260821000005_synced_commands_dictionary.sql
2026-08-29 18:33:45 +09:00

267 lines
9 KiB
PL/PgSQL

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;