267 lines
9 KiB
PL/PgSQL
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;
|