1070 lines
46 KiB
PL/PgSQL
1070 lines
46 KiB
PL/PgSQL
BEGIN;
|
|
|
|
CREATE TABLE public.data_portability_imports (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
checksum text NOT NULL CHECK (checksum ~ '^[0-9a-f]{64}$'),
|
|
schema_version integer NOT NULL CHECK (schema_version = 1),
|
|
source text NOT NULL CHECK (source IN ('cloud', 'mobile', 'desktop-legacy', 'web-legacy')),
|
|
imported_rows integer NOT NULL CHECK (imported_rows >= 0),
|
|
skipped_rows integer NOT NULL CHECK (skipped_rows >= 0),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (user_id, checksum)
|
|
);
|
|
|
|
CREATE INDEX idx_data_portability_imports_user_created
|
|
ON public.data_portability_imports(user_id, created_at DESC);
|
|
|
|
ALTER TABLE public.data_portability_imports ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "data_portability_imports_read_own"
|
|
ON public.data_portability_imports
|
|
FOR SELECT
|
|
USING (user_id = auth.uid());
|
|
|
|
REVOKE ALL ON public.data_portability_imports FROM anon, authenticated;
|
|
GRANT SELECT ON public.data_portability_imports TO authenticated;
|
|
|
|
CREATE OR REPLACE FUNCTION public.portability_assert_exact_keys(
|
|
value jsonb,
|
|
allowed_keys text[],
|
|
field_label text
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
actual_count integer;
|
|
invalid_key text;
|
|
BEGIN
|
|
IF jsonb_typeof(value) IS DISTINCT FROM 'object' THEN
|
|
RAISE EXCEPTION 'invalid_portability_object:%', field_label USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT count(*)::integer
|
|
INTO actual_count
|
|
FROM jsonb_object_keys(value);
|
|
|
|
IF actual_count <> cardinality(allowed_keys) THEN
|
|
RAISE EXCEPTION 'invalid_portability_fields:%', field_label USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT key
|
|
INTO invalid_key
|
|
FROM jsonb_object_keys(value) AS key
|
|
WHERE NOT (key = ANY (allowed_keys))
|
|
LIMIT 1;
|
|
|
|
IF invalid_key IS NOT NULL THEN
|
|
RAISE EXCEPTION 'unsupported_portability_field:%.%', field_label, invalid_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.portability_assert_field_types(
|
|
value jsonb,
|
|
required_text_keys text[],
|
|
nullable_text_keys text[],
|
|
required_number_keys text[],
|
|
nullable_number_keys text[],
|
|
required_boolean_keys text[],
|
|
nullable_boolean_keys text[],
|
|
nullable_object_keys text[],
|
|
field_label text
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
field_key text;
|
|
field_type text;
|
|
BEGIN
|
|
FOREACH field_key IN ARRAY required_text_keys LOOP
|
|
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'string' THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY nullable_text_keys LOOP
|
|
field_type := jsonb_typeof(value->field_key);
|
|
IF field_type IS NULL OR field_type <> ALL (ARRAY['string', 'null']) THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY required_number_keys LOOP
|
|
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'number' THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY nullable_number_keys LOOP
|
|
field_type := jsonb_typeof(value->field_key);
|
|
IF field_type IS NULL OR field_type <> ALL (ARRAY['number', 'null']) THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY required_boolean_keys LOOP
|
|
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'boolean' THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY nullable_boolean_keys LOOP
|
|
field_type := jsonb_typeof(value->field_key);
|
|
IF field_type IS NULL OR field_type <> ALL (ARRAY['boolean', 'null']) THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
FOREACH field_key IN ARRAY nullable_object_keys LOOP
|
|
field_type := jsonb_typeof(value->field_key);
|
|
IF field_type IS NULL OR field_type <> ALL (ARRAY['object', 'null']) THEN
|
|
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
|
|
END IF;
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.export_account_portability()
|
|
RETURNS TABLE (
|
|
canonical_payload text,
|
|
checksum text,
|
|
exported_at timestamptz,
|
|
row_count integer
|
|
)
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
payload jsonb;
|
|
payload_text text;
|
|
dataset_rows integer;
|
|
exported_timestamp timestamptz := clock_timestamp();
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT
|
|
(SELECT count(*) FROM public.dictionary WHERE user_id = current_user_id)
|
|
+ (SELECT count(*) FROM public.history WHERE user_id = current_user_id)
|
|
+ (SELECT count(*) FROM public.meetings WHERE user_id = current_user_id AND team_id IS NULL)
|
|
+ (
|
|
SELECT count(*)
|
|
FROM public.transcripts AS transcript
|
|
JOIN public.meetings AS meeting ON meeting.id = transcript.meeting_id
|
|
WHERE meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
)
|
|
+ (
|
|
SELECT count(*)
|
|
FROM public.meeting_memos AS memo
|
|
JOIN public.meetings AS meeting ON meeting.id = memo.meeting_id
|
|
WHERE memo.user_id = current_user_id
|
|
AND meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
)
|
|
+ (
|
|
SELECT count(*)
|
|
FROM public.meeting_documents AS document
|
|
JOIN public.meetings AS meeting ON meeting.id = document.meeting_id
|
|
WHERE document.user_id = current_user_id
|
|
AND meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
)
|
|
+ (
|
|
SELECT count(*)
|
|
FROM public.custom_instructions
|
|
WHERE user_id = current_user_id
|
|
AND builtin_key IS NULL
|
|
)
|
|
INTO dataset_rows;
|
|
|
|
IF dataset_rows > 10000 THEN
|
|
RAISE EXCEPTION 'export_row_limit_exceeded' USING ERRCODE = '54000';
|
|
END IF;
|
|
|
|
payload := jsonb_build_object(
|
|
'format', 'd3ro-account-portability',
|
|
'schema_version', 1,
|
|
'exported_at', exported_timestamp,
|
|
'owner_id', current_user_id,
|
|
'source', 'cloud',
|
|
'account', jsonb_build_object(
|
|
'profile', coalesce((
|
|
SELECT jsonb_build_object(
|
|
'id', profile.id,
|
|
'name', profile.name,
|
|
'avatar_url', profile.avatar_url,
|
|
'locale', profile.locale,
|
|
'tier', profile.tier,
|
|
'created_at', profile.created_at,
|
|
'updated_at', profile.updated_at
|
|
)
|
|
FROM public.profiles AS profile
|
|
WHERE profile.id = current_user_id
|
|
), 'null'::jsonb),
|
|
'settings', coalesce((
|
|
SELECT jsonb_build_object(
|
|
'theme_mode', settings.theme_mode,
|
|
'locale', settings.locale,
|
|
'haptic_enabled', settings.haptic_enabled,
|
|
'auto_polish_enabled', settings.auto_polish_enabled,
|
|
'preferred_stt_model', settings.preferred_stt_model,
|
|
'preferred_llm_model', settings.preferred_llm_model,
|
|
'onboarding_version', settings.onboarding_version,
|
|
'tutorial_completed_at', settings.tutorial_completed_at,
|
|
'revision', settings.revision,
|
|
'updated_at', settings.updated_at
|
|
)
|
|
FROM public.user_settings AS settings
|
|
WHERE settings.user_id = current_user_id
|
|
), 'null'::jsonb),
|
|
'subscription', coalesce((
|
|
SELECT jsonb_build_object(
|
|
'tier', subscription.tier,
|
|
'provider', subscription.provider,
|
|
'status', subscription.status,
|
|
'current_period_start', subscription.current_period_start,
|
|
'current_period_end', subscription.current_period_end,
|
|
'cancel_at', subscription.cancel_at,
|
|
'auto_renewing', subscription.auto_renewing,
|
|
'updated_at', subscription.updated_at
|
|
)
|
|
FROM public.subscriptions AS subscription
|
|
WHERE subscription.user_id = current_user_id
|
|
), 'null'::jsonb)
|
|
),
|
|
'datasets', jsonb_build_object(
|
|
'dictionary', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(dictionary) ORDER BY dictionary.created_at, dictionary.id)
|
|
FROM public.dictionary AS dictionary
|
|
WHERE dictionary.user_id = current_user_id
|
|
), '[]'::jsonb),
|
|
'history', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(history) - 'audio_storage_key' ORDER BY history.created_at, history.id)
|
|
FROM public.history AS history
|
|
WHERE history.user_id = current_user_id
|
|
), '[]'::jsonb),
|
|
'meetings', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(meeting) - 'audio_storage_key' ORDER BY meeting.created_at, meeting.id)
|
|
FROM public.meetings AS meeting
|
|
WHERE meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
), '[]'::jsonb),
|
|
'transcripts', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(transcript) ORDER BY transcript.meeting_id, transcript.segment_index, transcript.id)
|
|
FROM public.transcripts AS transcript
|
|
JOIN public.meetings AS meeting ON meeting.id = transcript.meeting_id
|
|
WHERE meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
), '[]'::jsonb),
|
|
'meeting_memos', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(memo) ORDER BY memo.meeting_id, memo.timestamp_ms, memo.id)
|
|
FROM public.meeting_memos AS memo
|
|
JOIN public.meetings AS meeting ON meeting.id = memo.meeting_id
|
|
WHERE memo.user_id = current_user_id
|
|
AND meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
), '[]'::jsonb),
|
|
'meeting_documents', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(document) ORDER BY document.meeting_id, document.created_at, document.id)
|
|
FROM public.meeting_documents AS document
|
|
JOIN public.meetings AS meeting ON meeting.id = document.meeting_id
|
|
WHERE document.user_id = current_user_id
|
|
AND meeting.user_id = current_user_id
|
|
AND meeting.team_id IS NULL
|
|
), '[]'::jsonb),
|
|
'custom_instructions', coalesce((
|
|
SELECT jsonb_agg(to_jsonb(instruction) ORDER BY instruction.sort_order, instruction.created_at, instruction.id)
|
|
FROM public.custom_instructions AS instruction
|
|
WHERE instruction.user_id = current_user_id
|
|
AND instruction.builtin_key IS NULL
|
|
), '[]'::jsonb)
|
|
),
|
|
'exclusions', jsonb_build_array(
|
|
'raw_audio',
|
|
'storage_objects',
|
|
'payment_credentials',
|
|
'push_tokens'
|
|
)
|
|
);
|
|
|
|
payload_text := payload::text;
|
|
IF octet_length(payload_text) > 5242880 THEN
|
|
RAISE EXCEPTION 'export_payload_too_large' USING ERRCODE = '54000';
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT
|
|
payload_text,
|
|
encode(extensions.digest(convert_to(payload_text, 'UTF8'), 'sha256'), 'hex'),
|
|
exported_timestamp,
|
|
dataset_rows;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.restore_account_portability(
|
|
canonical_payload text,
|
|
supplied_checksum text
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
payload jsonb;
|
|
datasets jsonb;
|
|
account_snapshot jsonb;
|
|
payload_checksum text;
|
|
payload_source text;
|
|
payload_row_count integer;
|
|
imported_count integer := 0;
|
|
skipped_count integer := 0;
|
|
affected integer;
|
|
existing_import public.data_portability_imports%ROWTYPE;
|
|
imported_at timestamptz;
|
|
item jsonb;
|
|
item_id uuid;
|
|
item_owner uuid;
|
|
parent_id uuid;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF canonical_payload IS NULL OR octet_length(canonical_payload) NOT BETWEEN 1 AND 5242880 THEN
|
|
RAISE EXCEPTION 'invalid_or_oversized_portability_payload' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF supplied_checksum IS NULL OR supplied_checksum !~ '^[0-9a-f]{64}$' THEN
|
|
RAISE EXCEPTION 'invalid_portability_checksum' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
payload_checksum := encode(
|
|
extensions.digest(convert_to(canonical_payload, 'UTF8'), 'sha256'),
|
|
'hex'
|
|
);
|
|
IF payload_checksum <> supplied_checksum THEN
|
|
RAISE EXCEPTION 'portability_checksum_mismatch' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
BEGIN
|
|
payload := canonical_payload::jsonb;
|
|
EXCEPTION WHEN invalid_text_representation THEN
|
|
RAISE EXCEPTION 'invalid_portability_json' USING ERRCODE = '22023';
|
|
END;
|
|
|
|
PERFORM public.portability_assert_exact_keys(
|
|
payload,
|
|
ARRAY[
|
|
'format', 'schema_version', 'exported_at', 'owner_id', 'source', 'account',
|
|
'datasets', 'exclusions'
|
|
],
|
|
'payload'
|
|
);
|
|
IF jsonb_typeof(payload->'format') IS DISTINCT FROM 'string'
|
|
OR jsonb_typeof(payload->'schema_version') IS DISTINCT FROM 'number'
|
|
OR jsonb_typeof(payload->'exported_at') IS DISTINCT FROM 'string'
|
|
OR jsonb_typeof(payload->'owner_id') IS DISTINCT FROM 'string'
|
|
OR jsonb_typeof(payload->'source') IS DISTINCT FROM 'string'
|
|
OR jsonb_typeof(payload->'account') IS DISTINCT FROM 'object'
|
|
OR jsonb_typeof(payload->'datasets') IS DISTINCT FROM 'object'
|
|
OR jsonb_typeof(payload->'exclusions') IS DISTINCT FROM 'array' THEN
|
|
RAISE EXCEPTION 'invalid_portability_root_types' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF payload->>'format' IS DISTINCT FROM 'd3ro-account-portability'
|
|
OR payload->>'schema_version' IS DISTINCT FROM '1' THEN
|
|
RAISE EXCEPTION 'unsupported_portability_version' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF (payload->>'owner_id')::uuid <> current_user_id THEN
|
|
RAISE EXCEPTION 'portability_owner_mismatch' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF jsonb_typeof(payload->'exported_at') IS DISTINCT FROM 'string' THEN
|
|
RAISE EXCEPTION 'invalid_portability_exported_at' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (payload->>'exported_at')::timestamptz;
|
|
payload_source := payload->>'source';
|
|
IF payload_source IS NULL OR payload_source <> ALL (
|
|
ARRAY['cloud', 'mobile', 'desktop-legacy', 'web-legacy']
|
|
) THEN
|
|
RAISE EXCEPTION 'invalid_portability_source' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF payload->'exclusions' IS DISTINCT FROM jsonb_build_array(
|
|
'raw_audio', 'storage_objects', 'payment_credentials', 'push_tokens'
|
|
) THEN
|
|
RAISE EXCEPTION 'invalid_portability_exclusions' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
account_snapshot := payload->'account';
|
|
PERFORM public.portability_assert_exact_keys(
|
|
account_snapshot,
|
|
ARRAY['profile', 'settings', 'subscription'],
|
|
'account'
|
|
);
|
|
IF account_snapshot->'profile' <> 'null'::jsonb THEN
|
|
PERFORM public.portability_assert_exact_keys(
|
|
account_snapshot->'profile',
|
|
ARRAY['id', 'name', 'avatar_url', 'locale', 'tier', 'created_at', 'updated_at'],
|
|
'account.profile'
|
|
);
|
|
PERFORM public.portability_assert_field_types(
|
|
account_snapshot->'profile',
|
|
ARRAY['id', 'locale', 'tier', 'created_at', 'updated_at'],
|
|
ARRAY['name', 'avatar_url'],
|
|
ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[],
|
|
ARRAY[]::text[],
|
|
'account.profile'
|
|
);
|
|
IF (account_snapshot->'profile'->>'id')::uuid <> current_user_id THEN
|
|
RAISE EXCEPTION 'portability_profile_owner_mismatch' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF coalesce(length(account_snapshot->'profile'->>'name'), 0) > 80
|
|
OR coalesce(length(account_snapshot->'profile'->>'avatar_url'), 0) > 2048
|
|
OR length(account_snapshot->'profile'->>'locale') NOT BETWEEN 1 AND 16
|
|
OR length(account_snapshot->'profile'->>'tier') NOT BETWEEN 1 AND 32 THEN
|
|
RAISE EXCEPTION 'invalid_portability_profile' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM
|
|
(account_snapshot->'profile'->>'created_at')::timestamptz,
|
|
(account_snapshot->'profile'->>'updated_at')::timestamptz;
|
|
END IF;
|
|
IF account_snapshot->'settings' <> 'null'::jsonb THEN
|
|
PERFORM public.portability_assert_exact_keys(
|
|
account_snapshot->'settings',
|
|
ARRAY[
|
|
'theme_mode', 'locale', 'haptic_enabled', 'auto_polish_enabled',
|
|
'preferred_stt_model', 'preferred_llm_model', 'onboarding_version',
|
|
'tutorial_completed_at', 'revision', 'updated_at'
|
|
],
|
|
'account.settings'
|
|
);
|
|
IF account_snapshot->'settings'->>'theme_mode' <> ALL (ARRAY['system', 'light', 'dark'])
|
|
OR length(account_snapshot->'settings'->>'locale') NOT BETWEEN 1 AND 16
|
|
OR (account_snapshot->'settings'->>'onboarding_version')::integer < 0
|
|
OR (account_snapshot->'settings'->>'revision')::bigint < 1 THEN
|
|
RAISE EXCEPTION 'invalid_portability_settings' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (account_snapshot->'settings'->>'updated_at')::timestamptz;
|
|
IF account_snapshot->'settings'->'tutorial_completed_at' <> 'null'::jsonb THEN
|
|
PERFORM (account_snapshot->'settings'->>'tutorial_completed_at')::timestamptz;
|
|
END IF;
|
|
PERFORM public.portability_assert_field_types(
|
|
account_snapshot->'settings',
|
|
ARRAY['theme_mode', 'locale', 'updated_at'],
|
|
ARRAY['preferred_stt_model', 'preferred_llm_model', 'tutorial_completed_at'],
|
|
ARRAY['onboarding_version', 'revision'], ARRAY[]::text[],
|
|
ARRAY['haptic_enabled', 'auto_polish_enabled'], ARRAY[]::text[],
|
|
ARRAY[]::text[],
|
|
'account.settings'
|
|
);
|
|
END IF;
|
|
IF account_snapshot->'subscription' <> 'null'::jsonb THEN
|
|
PERFORM public.portability_assert_exact_keys(
|
|
account_snapshot->'subscription',
|
|
ARRAY[
|
|
'tier', 'provider', 'status', 'current_period_start', 'current_period_end',
|
|
'cancel_at', 'auto_renewing', 'updated_at'
|
|
],
|
|
'account.subscription'
|
|
);
|
|
IF length(account_snapshot->'subscription'->>'tier') NOT BETWEEN 1 AND 32
|
|
OR length(account_snapshot->'subscription'->>'provider') NOT BETWEEN 1 AND 32
|
|
OR coalesce(length(account_snapshot->'subscription'->>'status'), 0) > 64 THEN
|
|
RAISE EXCEPTION 'invalid_portability_subscription' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (account_snapshot->'subscription'->>'updated_at')::timestamptz;
|
|
IF account_snapshot->'subscription'->'current_period_start' <> 'null'::jsonb THEN
|
|
PERFORM (account_snapshot->'subscription'->>'current_period_start')::timestamptz;
|
|
END IF;
|
|
IF account_snapshot->'subscription'->'current_period_end' <> 'null'::jsonb THEN
|
|
PERFORM (account_snapshot->'subscription'->>'current_period_end')::timestamptz;
|
|
END IF;
|
|
IF account_snapshot->'subscription'->'cancel_at' <> 'null'::jsonb THEN
|
|
PERFORM (account_snapshot->'subscription'->>'cancel_at')::timestamptz;
|
|
END IF;
|
|
PERFORM public.portability_assert_field_types(
|
|
account_snapshot->'subscription',
|
|
ARRAY['tier', 'provider', 'updated_at'],
|
|
ARRAY['status', 'current_period_start', 'current_period_end', 'cancel_at'],
|
|
ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY['auto_renewing'],
|
|
ARRAY[]::text[],
|
|
'account.subscription'
|
|
);
|
|
END IF;
|
|
|
|
datasets := payload->'datasets';
|
|
PERFORM public.portability_assert_exact_keys(
|
|
datasets,
|
|
ARRAY[
|
|
'dictionary', 'history', 'meetings', 'transcripts', 'meeting_memos',
|
|
'meeting_documents', 'custom_instructions'
|
|
],
|
|
'datasets'
|
|
);
|
|
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM unnest(ARRAY[
|
|
'dictionary', 'history', 'meetings', 'transcripts', 'meeting_memos',
|
|
'meeting_documents', 'custom_instructions'
|
|
]) AS dataset_name
|
|
WHERE jsonb_typeof(datasets->dataset_name) IS DISTINCT FROM 'array'
|
|
) THEN
|
|
RAISE EXCEPTION 'invalid_portability_dataset_type' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF jsonb_array_length(datasets->'dictionary') > 5000
|
|
OR jsonb_array_length(datasets->'history') > 5000
|
|
OR jsonb_array_length(datasets->'meetings') > 1000
|
|
OR jsonb_array_length(datasets->'transcripts') > 10000
|
|
OR jsonb_array_length(datasets->'meeting_memos') > 5000
|
|
OR jsonb_array_length(datasets->'meeting_documents') > 2000
|
|
OR jsonb_array_length(datasets->'custom_instructions') > 1000 THEN
|
|
RAISE EXCEPTION 'portability_dataset_row_limit_exceeded' USING ERRCODE = '54000';
|
|
END IF;
|
|
|
|
payload_row_count :=
|
|
jsonb_array_length(datasets->'dictionary')
|
|
+ jsonb_array_length(datasets->'history')
|
|
+ jsonb_array_length(datasets->'meetings')
|
|
+ jsonb_array_length(datasets->'transcripts')
|
|
+ jsonb_array_length(datasets->'meeting_memos')
|
|
+ jsonb_array_length(datasets->'meeting_documents')
|
|
+ jsonb_array_length(datasets->'custom_instructions');
|
|
IF payload_row_count > 10000 THEN
|
|
RAISE EXCEPTION 'portability_total_row_limit_exceeded' USING ERRCODE = '54000';
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'dictionary')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'history')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'meetings')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'transcripts')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'meeting_memos')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'meeting_documents')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'id' FROM jsonb_array_elements(datasets->'custom_instructions')
|
|
GROUP BY value->>'id' HAVING count(*) > 1
|
|
) THEN
|
|
RAISE EXCEPTION 'duplicate_portability_row_id' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT lower(btrim(value->>'word')), value->>'category'
|
|
FROM jsonb_array_elements(datasets->'dictionary')
|
|
GROUP BY lower(btrim(value->>'word')), value->>'category'
|
|
HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT value->>'meeting_id', value->>'segment_index'
|
|
FROM jsonb_array_elements(datasets->'transcripts')
|
|
GROUP BY value->>'meeting_id', value->>'segment_index'
|
|
HAVING count(*) > 1
|
|
) OR EXISTS (
|
|
SELECT lower(btrim(value->>'name'))
|
|
FROM jsonb_array_elements(datasets->'custom_instructions')
|
|
GROUP BY lower(btrim(value->>'name'))
|
|
HAVING count(*) > 1
|
|
) THEN
|
|
RAISE EXCEPTION 'duplicate_portability_natural_key' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'dictionary') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'user_id', 'word', 'pronunciation', 'category', 'usage_count',
|
|
'last_used_at', 'created_at', 'updated_at'
|
|
], 'datasets.dictionary[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'user_id', 'word', 'category', 'created_at', 'updated_at'],
|
|
ARRAY['pronunciation', 'last_used_at'],
|
|
ARRAY['usage_count'], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[],
|
|
ARRAY[]::text[],
|
|
'datasets.dictionary[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id THEN
|
|
RAISE EXCEPTION 'cross_user_dictionary_row' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF length(btrim(item->>'word')) NOT BETWEEN 1 AND 120
|
|
OR coalesce(length(item->>'pronunciation'), 0) > 200
|
|
OR item->>'category' <> ALL (ARRAY['user', 'auto', 'technical'])
|
|
OR (item->>'usage_count')::integer < 0 THEN
|
|
RAISE EXCEPTION 'invalid_dictionary_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF item->'last_used_at' <> 'null'::jsonb THEN PERFORM (item->>'last_used_at')::timestamptz; END IF;
|
|
IF EXISTS (SELECT 1 FROM public.dictionary WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_dictionary_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'history') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'user_id', 'title', 'original_text', 'polished_text', 'focused_app',
|
|
'focused_app_name', 'focused_app_window_title', 'mode', 'status', 'error_code',
|
|
'duration', 'detected_language', 'mic_device', 'word_count', 'stt_model',
|
|
'llm_model', 'stt_latency_ms', 'llm_latency_ms', 'app_version', 'summary_text',
|
|
'is_favorite', 'revision', 'created_at', 'updated_at'
|
|
], 'datasets.history[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'user_id', 'original_text', 'mode', 'status', 'app_version', 'created_at', 'updated_at'],
|
|
ARRAY[
|
|
'title', 'polished_text', 'focused_app', 'focused_app_name',
|
|
'focused_app_window_title', 'error_code', 'detected_language', 'mic_device',
|
|
'stt_model', 'llm_model', 'summary_text'
|
|
],
|
|
ARRAY['duration', 'word_count', 'revision'],
|
|
ARRAY['stt_latency_ms', 'llm_latency_ms'],
|
|
ARRAY['is_favorite'], ARRAY[]::text[], ARRAY[]::text[],
|
|
'datasets.history[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_history_row' USING ERRCODE = '42501'; END IF;
|
|
IF item->>'mode' <> ALL (ARRAY['dictation', 'translate', 'command', 'caption', 'file-transcription'])
|
|
OR item->>'status' <> ALL (ARRAY['completed', 'cancelled', 'error'])
|
|
OR (item->>'duration')::double precision < 0
|
|
OR (item->>'word_count')::integer < 0
|
|
OR (item->>'revision')::bigint < 1
|
|
OR length(item->>'app_version') NOT BETWEEN 1 AND 64 THEN
|
|
RAISE EXCEPTION 'invalid_history_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF EXISTS (SELECT 1 FROM public.history WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_history_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meetings') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'user_id', 'team_id', 'title', 'status', 'started_at', 'ended_at',
|
|
'duration_ms', 'raw_transcript', 'edited_transcript', 'minutes_markdown',
|
|
'minutes_json', 'stt_model', 'llm_model', 'stt_latency_ms', 'llm_latency_ms',
|
|
'error_message', 'created_at', 'updated_at'
|
|
], 'datasets.meetings[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'user_id', 'status', 'started_at', 'created_at', 'updated_at'],
|
|
ARRAY[
|
|
'team_id', 'title', 'ended_at', 'raw_transcript', 'edited_transcript',
|
|
'minutes_markdown', 'stt_model', 'llm_model', 'error_message'
|
|
],
|
|
ARRAY[]::text[],
|
|
ARRAY['duration_ms', 'stt_latency_ms', 'llm_latency_ms'],
|
|
ARRAY[]::text[], ARRAY[]::text[], ARRAY['minutes_json'],
|
|
'datasets.meetings[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id OR item->'team_id' <> 'null'::jsonb THEN
|
|
RAISE EXCEPTION 'cross_user_or_team_meeting_row' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF item->>'status' <> ALL (ARRAY['recording', 'processing', 'completed', 'error']) THEN
|
|
RAISE EXCEPTION 'invalid_meeting_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'started_at')::timestamptz, (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF item->'ended_at' <> 'null'::jsonb THEN PERFORM (item->>'ended_at')::timestamptz; END IF;
|
|
IF EXISTS (SELECT 1 FROM public.meetings WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_meeting_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'transcripts') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'meeting_id', 'segment_index', 'timestamp_ms', 'duration_ms', 'text',
|
|
'speaker', 'edited', 'created_at', 'updated_at'
|
|
], 'datasets.transcripts[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'meeting_id', 'text', 'created_at', 'updated_at'],
|
|
ARRAY['speaker'],
|
|
ARRAY['segment_index', 'timestamp_ms'], ARRAY['duration_ms'],
|
|
ARRAY['edited'], ARRAY[]::text[], ARRAY[]::text[],
|
|
'datasets.transcripts[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
parent_id := (item->>'meeting_id')::uuid;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent
|
|
WHERE (parent->>'id')::uuid = parent_id
|
|
AND (parent->>'user_id')::uuid = current_user_id
|
|
) THEN RAISE EXCEPTION 'orphan_transcript_row' USING ERRCODE = '23503'; END IF;
|
|
IF (item->>'segment_index')::integer < 0 OR (item->>'timestamp_ms')::bigint < 0 THEN
|
|
RAISE EXCEPTION 'invalid_transcript_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF EXISTS (
|
|
SELECT 1 FROM public.transcripts AS existing
|
|
JOIN public.meetings AS meeting ON meeting.id = existing.meeting_id
|
|
WHERE existing.id = item_id AND meeting.user_id <> current_user_id
|
|
) THEN RAISE EXCEPTION 'cross_user_transcript_id' USING ERRCODE = '42501'; END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meeting_memos') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'meeting_id', 'user_id', 'content', 'timestamp_ms', 'created_at'
|
|
], 'datasets.meeting_memos[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'meeting_id', 'user_id', 'content', 'created_at'], ARRAY[]::text[],
|
|
ARRAY['timestamp_ms'], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[],
|
|
ARRAY[]::text[],
|
|
'datasets.meeting_memos[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
parent_id := (item->>'meeting_id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_memo_row' USING ERRCODE = '42501'; END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent
|
|
WHERE (parent->>'id')::uuid = parent_id
|
|
AND (parent->>'user_id')::uuid = current_user_id
|
|
) THEN RAISE EXCEPTION 'orphan_memo_row' USING ERRCODE = '23503'; END IF;
|
|
IF length(item->>'content') NOT BETWEEN 1 AND 4000 OR (item->>'timestamp_ms')::bigint < 0 THEN
|
|
RAISE EXCEPTION 'invalid_memo_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz;
|
|
IF EXISTS (SELECT 1 FROM public.meeting_memos WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_memo_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meeting_documents') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'meeting_id', 'user_id', 'template_type', 'title', 'content',
|
|
'prompt_used', 'llm_model', 'llm_latency_ms', 'created_at', 'updated_at'
|
|
], 'datasets.meeting_documents[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY['id', 'meeting_id', 'user_id', 'template_type', 'title', 'content', 'created_at', 'updated_at'],
|
|
ARRAY['prompt_used', 'llm_model'],
|
|
ARRAY[]::text[], ARRAY['llm_latency_ms'], ARRAY[]::text[], ARRAY[]::text[],
|
|
ARRAY[]::text[],
|
|
'datasets.meeting_documents[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
parent_id := (item->>'meeting_id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_document_row' USING ERRCODE = '42501'; END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent
|
|
WHERE (parent->>'id')::uuid = parent_id
|
|
AND (parent->>'user_id')::uuid = current_user_id
|
|
) THEN RAISE EXCEPTION 'orphan_document_row' USING ERRCODE = '23503'; END IF;
|
|
IF item->>'template_type' <> ALL (ARRAY['minutes', 'report', 'idea-note', 'custom', 'mindmap'])
|
|
OR length(item->>'title') NOT BETWEEN 1 AND 300 THEN
|
|
RAISE EXCEPTION 'invalid_document_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF EXISTS (SELECT 1 FROM public.meeting_documents WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_document_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
FOR item IN SELECT value FROM jsonb_array_elements(datasets->'custom_instructions') LOOP
|
|
PERFORM public.portability_assert_exact_keys(item, ARRAY[
|
|
'id', 'user_id', 'builtin_key', 'name', 'description', 'prompt', 'icon',
|
|
'sort_order', 'revision', 'created_at', 'updated_at'
|
|
], 'datasets.custom_instructions[]');
|
|
PERFORM public.portability_assert_field_types(
|
|
item,
|
|
ARRAY[
|
|
'id', 'user_id', 'name', 'description', 'prompt', 'icon', 'created_at',
|
|
'updated_at'
|
|
],
|
|
ARRAY['builtin_key'], ARRAY['sort_order', 'revision'], ARRAY[]::text[],
|
|
ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[],
|
|
'datasets.custom_instructions[]'
|
|
);
|
|
item_id := (item->>'id')::uuid;
|
|
item_owner := (item->>'user_id')::uuid;
|
|
IF item_owner <> current_user_id OR item->'builtin_key' <> 'null'::jsonb THEN
|
|
RAISE EXCEPTION 'cross_user_or_builtin_instruction_row' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF length(btrim(item->>'name')) NOT BETWEEN 1 AND 80
|
|
OR length(item->>'description') > 240
|
|
OR length(btrim(item->>'prompt')) NOT BETWEEN 1 AND 4000
|
|
OR length(item->>'icon') NOT BETWEEN 1 AND 32
|
|
OR (item->>'sort_order')::integer < 0
|
|
OR (item->>'revision')::bigint < 1 THEN
|
|
RAISE EXCEPTION 'invalid_instruction_import_row' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz;
|
|
IF EXISTS (SELECT 1 FROM public.custom_instructions WHERE id = item_id AND user_id <> current_user_id) THEN
|
|
RAISE EXCEPTION 'cross_user_instruction_id' USING ERRCODE = '42501';
|
|
END IF;
|
|
END LOOP;
|
|
|
|
-- Serialize imports for one account. This also closes the ledger check/insert race.
|
|
IF NOT pg_catalog.pg_try_advisory_xact_lock(
|
|
pg_catalog.hashtextextended('d3ro-portability:' || current_user_id::text, 0)
|
|
) THEN
|
|
RAISE EXCEPTION 'portability_import_busy' USING ERRCODE = 'P0001';
|
|
END IF;
|
|
|
|
SELECT * INTO existing_import
|
|
FROM public.data_portability_imports
|
|
WHERE user_id = current_user_id
|
|
AND checksum = payload_checksum;
|
|
IF FOUND THEN
|
|
RETURN jsonb_build_object(
|
|
'status', 'duplicate',
|
|
'checksum', existing_import.checksum,
|
|
'imported_rows', 0,
|
|
'skipped_rows', existing_import.imported_rows + existing_import.skipped_rows,
|
|
'imported_at', existing_import.created_at
|
|
);
|
|
END IF;
|
|
|
|
-- Existing IDs must be byte-for-byte equivalent to the portable projection.
|
|
IF EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'dictionary') AS incoming
|
|
JOIN public.dictionary AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'history') AS incoming
|
|
JOIN public.history AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) - 'audio_storage_key' IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS incoming
|
|
JOIN public.meetings AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) - 'audio_storage_key' IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'transcripts') AS incoming
|
|
JOIN public.transcripts AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meeting_memos') AS incoming
|
|
JOIN public.meeting_memos AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'meeting_documents') AS incoming
|
|
JOIN public.meeting_documents AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) IS DISTINCT FROM incoming
|
|
) OR EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'custom_instructions') AS incoming
|
|
JOIN public.custom_instructions AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE to_jsonb(existing) IS DISTINCT FROM incoming
|
|
) THEN
|
|
RAISE EXCEPTION 'portability_restore_conflict_existing_revision' USING ERRCODE = 'P0001';
|
|
END IF;
|
|
|
|
-- Natural-key duplicates are idempotent only when their user data agrees.
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(datasets->'dictionary') AS incoming
|
|
JOIN public.dictionary AS existing
|
|
ON existing.user_id = current_user_id
|
|
AND lower(btrim(existing.word)) = lower(btrim(incoming->>'word'))
|
|
AND existing.category = incoming->>'category'
|
|
AND existing.id <> (incoming->>'id')::uuid
|
|
WHERE existing.pronunciation IS DISTINCT FROM nullif(incoming->>'pronunciation', '')
|
|
OR existing.usage_count IS DISTINCT FROM (incoming->>'usage_count')::integer
|
|
OR existing.last_used_at IS DISTINCT FROM (incoming->>'last_used_at')::timestamptz
|
|
) OR EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(datasets->'transcripts') AS incoming
|
|
JOIN public.transcripts AS existing
|
|
ON existing.meeting_id = (incoming->>'meeting_id')::uuid
|
|
AND existing.segment_index = (incoming->>'segment_index')::integer
|
|
AND existing.id <> (incoming->>'id')::uuid
|
|
WHERE existing.timestamp_ms IS DISTINCT FROM (incoming->>'timestamp_ms')::bigint
|
|
OR existing.duration_ms IS DISTINCT FROM (incoming->>'duration_ms')::integer
|
|
OR existing.text IS DISTINCT FROM incoming->>'text'
|
|
OR existing.speaker IS DISTINCT FROM nullif(incoming->>'speaker', '')
|
|
OR existing.edited IS DISTINCT FROM (incoming->>'edited')::boolean
|
|
) OR EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(datasets->'custom_instructions') AS incoming
|
|
JOIN public.custom_instructions AS existing
|
|
ON existing.user_id = current_user_id
|
|
AND lower(btrim(existing.name)) = lower(btrim(incoming->>'name'))
|
|
AND existing.id <> (incoming->>'id')::uuid
|
|
WHERE existing.description IS DISTINCT FROM incoming->>'description'
|
|
OR existing.prompt IS DISTINCT FROM incoming->>'prompt'
|
|
OR existing.icon IS DISTINCT FROM incoming->>'icon'
|
|
OR existing.sort_order IS DISTINCT FROM (incoming->>'sort_order')::integer
|
|
OR existing.revision IS DISTINCT FROM (incoming->>'revision')::bigint
|
|
) THEN
|
|
RAISE EXCEPTION 'portability_restore_conflict_natural_key' USING ERRCODE = 'P0001';
|
|
END IF;
|
|
|
|
INSERT INTO public.dictionary (
|
|
id, user_id, word, pronunciation, category, usage_count, last_used_at,
|
|
created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.user_id, row.word, row.pronunciation, row.category,
|
|
row.usage_count, row.last_used_at, row.created_at, row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'dictionary') AS row(
|
|
id uuid, user_id uuid, word text, pronunciation text, category text,
|
|
usage_count integer, last_used_at timestamptz, created_at timestamptz,
|
|
updated_at timestamptz
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'dictionary') - affected;
|
|
|
|
INSERT INTO public.history (
|
|
id, user_id, title, original_text, polished_text, focused_app,
|
|
focused_app_name, focused_app_window_title, mode, status, error_code,
|
|
audio_storage_key, duration, detected_language, mic_device, word_count,
|
|
stt_model, llm_model, stt_latency_ms, llm_latency_ms, app_version,
|
|
summary_text, is_favorite, revision, created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.user_id, row.title, row.original_text, row.polished_text,
|
|
row.focused_app, row.focused_app_name, row.focused_app_window_title,
|
|
row.mode, row.status, row.error_code, NULL, row.duration,
|
|
row.detected_language, row.mic_device, row.word_count, row.stt_model,
|
|
row.llm_model, row.stt_latency_ms, row.llm_latency_ms, row.app_version,
|
|
row.summary_text, row.is_favorite, row.revision, row.created_at, row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'history') AS row(
|
|
id uuid, user_id uuid, title text, original_text text, polished_text text,
|
|
focused_app text, focused_app_name text, focused_app_window_title text,
|
|
mode text, status text, error_code text, duration double precision,
|
|
detected_language text, mic_device text, word_count integer, stt_model text,
|
|
llm_model text, stt_latency_ms integer, llm_latency_ms integer,
|
|
app_version text, summary_text text, is_favorite boolean, revision bigint,
|
|
created_at timestamptz, updated_at timestamptz
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'history') - affected;
|
|
|
|
INSERT INTO public.meetings (
|
|
id, user_id, team_id, title, status, started_at, ended_at, duration_ms,
|
|
raw_transcript, edited_transcript, minutes_markdown, minutes_json,
|
|
stt_model, llm_model, stt_latency_ms, llm_latency_ms, error_message,
|
|
audio_storage_key, created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.user_id, NULL, row.title, row.status, row.started_at,
|
|
row.ended_at, row.duration_ms, row.raw_transcript, row.edited_transcript,
|
|
row.minutes_markdown, row.minutes_json, row.stt_model, row.llm_model,
|
|
row.stt_latency_ms, row.llm_latency_ms, row.error_message, NULL,
|
|
row.created_at, row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'meetings') AS row(
|
|
id uuid, user_id uuid, team_id uuid, title text, status text,
|
|
started_at timestamptz, ended_at timestamptz, duration_ms bigint,
|
|
raw_transcript text, edited_transcript text, minutes_markdown text,
|
|
minutes_json jsonb, stt_model text, llm_model text, stt_latency_ms integer,
|
|
llm_latency_ms integer, error_message text, created_at timestamptz,
|
|
updated_at timestamptz
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'meetings') - affected;
|
|
|
|
INSERT INTO public.transcripts (
|
|
id, meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker,
|
|
edited, created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.meeting_id, row.segment_index, row.timestamp_ms,
|
|
row.duration_ms, row.text, row.speaker, row.edited, row.created_at,
|
|
row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'transcripts') AS row(
|
|
id uuid, meeting_id uuid, segment_index integer, timestamp_ms bigint,
|
|
duration_ms integer, text text, speaker text, edited boolean,
|
|
created_at timestamptz, updated_at timestamptz
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'transcripts') - affected;
|
|
|
|
INSERT INTO public.meeting_memos (
|
|
id, meeting_id, user_id, content, timestamp_ms, created_at
|
|
)
|
|
SELECT row.id, row.meeting_id, row.user_id, row.content, row.timestamp_ms, row.created_at
|
|
FROM jsonb_to_recordset(datasets->'meeting_memos') AS row(
|
|
id uuid, meeting_id uuid, user_id uuid, content text, timestamp_ms bigint,
|
|
created_at timestamptz
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'meeting_memos') - affected;
|
|
|
|
INSERT INTO public.meeting_documents (
|
|
id, meeting_id, user_id, template_type, title, content, prompt_used,
|
|
llm_model, llm_latency_ms, created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.meeting_id, row.user_id, row.template_type, row.title,
|
|
row.content, row.prompt_used, row.llm_model, row.llm_latency_ms,
|
|
row.created_at, row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'meeting_documents') AS row(
|
|
id uuid, meeting_id uuid, user_id uuid, template_type text, title text,
|
|
content text, prompt_used text, llm_model text, llm_latency_ms integer,
|
|
created_at timestamptz, updated_at timestamptz
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'meeting_documents') - affected;
|
|
|
|
INSERT INTO public.custom_instructions (
|
|
id, user_id, builtin_key, name, description, prompt, icon, sort_order,
|
|
revision, created_at, updated_at
|
|
)
|
|
SELECT
|
|
row.id, row.user_id, NULL, row.name, row.description, row.prompt,
|
|
row.icon, row.sort_order, row.revision, row.created_at, row.updated_at
|
|
FROM jsonb_to_recordset(datasets->'custom_instructions') AS row(
|
|
id uuid, user_id uuid, builtin_key text, name text, description text,
|
|
prompt text, icon text, sort_order integer, revision bigint,
|
|
created_at timestamptz, updated_at timestamptz
|
|
)
|
|
ON CONFLICT DO NOTHING;
|
|
GET DIAGNOSTICS affected = ROW_COUNT;
|
|
imported_count := imported_count + affected;
|
|
skipped_count := skipped_count + jsonb_array_length(datasets->'custom_instructions') - affected;
|
|
|
|
INSERT INTO public.data_portability_imports (
|
|
user_id, checksum, schema_version, source, imported_rows, skipped_rows
|
|
) VALUES (
|
|
current_user_id, payload_checksum, 1, payload_source, imported_count, skipped_count
|
|
)
|
|
RETURNING created_at INTO imported_at;
|
|
|
|
RETURN jsonb_build_object(
|
|
'status', 'imported',
|
|
'checksum', payload_checksum,
|
|
'imported_rows', imported_count,
|
|
'skipped_rows', skipped_count,
|
|
'imported_at', imported_at
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.portability_assert_exact_keys(jsonb, text[], text) FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON FUNCTION public.portability_assert_field_types(
|
|
jsonb, text[], text[], text[], text[], text[], text[], text[], text
|
|
) FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON FUNCTION public.export_account_portability() FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.export_account_portability() TO authenticated;
|
|
REVOKE ALL ON FUNCTION public.restore_account_portability(text, text) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.restore_account_portability(text, text) TO authenticated;
|
|
|
|
COMMIT;
|