1025 lines
45 KiB
PL/PgSQL
1025 lines
45 KiB
PL/PgSQL
-- Portable archive v1: one owner for the frozen row shape.
|
|
--
|
|
-- restore_account_portability (0021) refused every archive that still had a
|
|
-- live meeting, memo or document on the server. Its "existing IDs must match
|
|
-- the archive" check compared whole current rows, to_jsonb(existing), against
|
|
-- the v1 archive rows. Columns added after the archive froze (meetings
|
|
-- language/attendees/template_id/creation_* in 0025, meeting_documents
|
|
-- template_id/generation_idempotency_key in 0024, meeting_memos.updated_at in
|
|
-- 0034) are always present in to_jsonb, so the comparison never matched and
|
|
-- the RPC raised portability_restore_conflict_existing_revision, even when an
|
|
-- archive was restored right after it was exported. 0034 had already taught
|
|
-- export_account_portability to strip those keys, but restore was never
|
|
-- redefined.
|
|
--
|
|
-- The v1 key sets lived in three places (restore validators, export
|
|
-- projection, restore comparison) and drifted apart. From here on:
|
|
-- * portability_v1_keys(dataset) is the single owner of each dataset's
|
|
-- frozen v1 key set (copied verbatim from the 0021 restore validators);
|
|
-- * portability_v1_project(dataset, row) keeps only those keys
|
|
-- (allow-list), so a column added to a synced table later cannot leak into
|
|
-- the archive or break the restore comparison;
|
|
-- * export, the restore validators and the restore comparison all go
|
|
-- through them.
|
|
--
|
|
-- Export bytes do not change: the allow-list is exactly the column set export
|
|
-- emitted after 0034, and jsonb serialises keys in its own canonical order.
|
|
-- The restore bodies below are 0021 verbatim except for the validator key
|
|
-- arrays and the existing-row comparison.
|
|
|
|
BEGIN;
|
|
|
|
CREATE OR REPLACE FUNCTION public.portability_v1_keys(p_dataset text)
|
|
RETURNS text[]
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
dataset_keys text[];
|
|
BEGIN
|
|
dataset_keys := CASE p_dataset
|
|
WHEN 'dictionary' THEN ARRAY[
|
|
'id', 'user_id', 'word', 'pronunciation', 'category', 'usage_count',
|
|
'last_used_at', 'created_at', 'updated_at'
|
|
]
|
|
WHEN 'history' THEN 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'
|
|
]
|
|
WHEN 'meetings' THEN 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'
|
|
]
|
|
WHEN 'transcripts' THEN ARRAY[
|
|
'id', 'meeting_id', 'segment_index', 'timestamp_ms', 'duration_ms', 'text',
|
|
'speaker', 'edited', 'created_at', 'updated_at'
|
|
]
|
|
WHEN 'meeting_memos' THEN ARRAY[
|
|
'id', 'meeting_id', 'user_id', 'content', 'timestamp_ms', 'created_at'
|
|
]
|
|
WHEN 'meeting_documents' THEN ARRAY[
|
|
'id', 'meeting_id', 'user_id', 'template_type', 'title', 'content',
|
|
'prompt_used', 'llm_model', 'llm_latency_ms', 'created_at', 'updated_at'
|
|
]
|
|
WHEN 'custom_instructions' THEN ARRAY[
|
|
'id', 'user_id', 'builtin_key', 'name', 'description', 'prompt', 'icon',
|
|
'sort_order', 'revision', 'created_at', 'updated_at'
|
|
]
|
|
END;
|
|
IF dataset_keys IS NULL THEN
|
|
RAISE EXCEPTION 'unknown_portability_dataset:%', p_dataset USING ERRCODE = '22023';
|
|
END IF;
|
|
RETURN dataset_keys;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.portability_v1_project(p_dataset text, p_row jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE sql
|
|
IMMUTABLE
|
|
STRICT
|
|
SET search_path = ''
|
|
AS $$
|
|
SELECT coalesce(pg_catalog.jsonb_object_agg(entry.key, entry.value), '{}'::jsonb)
|
|
FROM (SELECT public.portability_v1_keys(p_dataset) AS keys) AS v1
|
|
CROSS JOIN LATERAL pg_catalog.jsonb_each(p_row) AS entry
|
|
WHERE entry.key = ANY (v1.keys)
|
|
$$;
|
|
|
|
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(public.portability_v1_project('dictionary', 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(public.portability_v1_project('history', to_jsonb(history)) 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(public.portability_v1_project('meetings', to_jsonb(meeting)) 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(public.portability_v1_project('transcripts', 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(public.portability_v1_project('meeting_memos', 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(public.portability_v1_project('meeting_documents', 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(public.portability_v1_project('custom_instructions', 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, public.portability_v1_keys('dictionary'), '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, public.portability_v1_keys('history'), '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, public.portability_v1_keys('meetings'), '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, public.portability_v1_keys('transcripts'), '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, public.portability_v1_keys('meeting_memos'), '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, public.portability_v1_keys('meeting_documents'), '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, public.portability_v1_keys('custom_instructions'), '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,
|
|
-- the same v1 projection export_account_portability emits.
|
|
IF EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(datasets->'dictionary') AS incoming
|
|
JOIN public.dictionary AS existing ON existing.id = (incoming->>'id')::uuid
|
|
WHERE public.portability_v1_project('dictionary', 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 public.portability_v1_project('history', to_jsonb(existing)) 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 public.portability_v1_project('meetings', to_jsonb(existing)) 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 public.portability_v1_project('transcripts', 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 public.portability_v1_project('meeting_memos', 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 public.portability_v1_project('meeting_documents', 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 public.portability_v1_project('custom_instructions', 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_v1_keys(text) FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON FUNCTION public.portability_v1_project(text, jsonb) 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;
|