feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,616 @@
-- ============================================================================
-- Mobile platform foundation
-- Device/settings sync, durable audio jobs, Play purchase ledger, ad rewards
-- ============================================================================
BEGIN;
UPDATE storage.buckets
SET allowed_mime_types = ARRAY[
'audio/wav',
'audio/x-wav',
'audio/webm',
'audio/mpeg',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'audio/ogg',
'audio/flac',
'video/mp4',
'video/quicktime',
'video/webm'
]
WHERE id = 'audio';
-- User settings ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.user_settings (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
theme_mode text NOT NULL DEFAULT 'system'
CHECK (theme_mode IN ('system', 'light', 'dark')),
locale text NOT NULL DEFAULT 'ko',
haptic_enabled boolean NOT NULL DEFAULT true,
auto_polish_enabled boolean NOT NULL DEFAULT true,
preferred_stt_model text,
preferred_llm_model text,
onboarding_version integer NOT NULL DEFAULT 0 CHECK (onboarding_version >= 0),
tutorial_completed_at timestamptz,
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "user_settings_read_own" ON public.user_settings
FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "user_settings_insert_own" ON public.user_settings
FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "user_settings_update_own" ON public.user_settings
FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
CREATE TRIGGER set_updated_at_user_settings
BEFORE UPDATE ON public.user_settings
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
-- Registered devices ----------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
installation_id uuid NOT NULL,
platform text NOT NULL CHECK (platform IN ('android', 'ios', 'web', 'windows', 'macos')),
device_name text NOT NULL,
app_version text NOT NULL,
os_version text,
push_token text,
last_seen_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, installation_id)
);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen
ON public.devices(user_id, last_seen_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_push_token
ON public.devices(push_token) WHERE push_token IS NOT NULL;
ALTER TABLE public.devices ENABLE ROW LEVEL SECURITY;
CREATE POLICY "devices_read_own" ON public.devices
FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "devices_insert_own" ON public.devices
FOR INSERT WITH CHECK (user_id = auth.uid() AND revoked_at IS NULL);
CREATE POLICY "devices_update_own" ON public.devices
FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
CREATE POLICY "devices_delete_own" ON public.devices
FOR DELETE USING (user_id = auth.uid());
CREATE TRIGGER set_updated_at_devices
BEFORE UPDATE ON public.devices
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
ALTER TABLE public.push_tokens
ADD COLUMN IF NOT EXISTS device_id uuid REFERENCES public.devices(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_push_tokens_device
ON public.push_tokens(device_id) WHERE device_id IS NOT NULL;
-- Durable audio metadata ------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.audio_files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
history_id uuid REFERENCES public.history(id) ON DELETE SET NULL,
meeting_id uuid REFERENCES public.meetings(id) ON DELETE SET NULL,
source text NOT NULL CHECK (source IN ('recording', 'file-picker', 'share-intent')),
original_name text,
storage_key text NOT NULL,
mime_type text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
duration_ms bigint CHECK (duration_ms IS NULL OR duration_ms >= 0),
sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
upload_status text NOT NULL DEFAULT 'pending'
CHECK (upload_status IN ('pending', 'uploading', 'uploaded', 'failed', 'deleted')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, sha256, storage_key),
CHECK (NOT (history_id IS NOT NULL AND meeting_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_audio_files_user_created
ON public.audio_files(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audio_files_history
ON public.audio_files(history_id) WHERE history_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audio_files_meeting
ON public.audio_files(meeting_id) WHERE meeting_id IS NOT NULL;
ALTER TABLE public.audio_files ENABLE ROW LEVEL SECURITY;
CREATE POLICY "audio_files_read_own" ON public.audio_files
FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "audio_files_insert_own" ON public.audio_files
FOR INSERT WITH CHECK (
user_id = auth.uid()
AND split_part(storage_key, '/', 1) = auth.uid()::text
);
CREATE POLICY "audio_files_update_own" ON public.audio_files
FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (
user_id = auth.uid()
AND split_part(storage_key, '/', 1) = auth.uid()::text
);
CREATE POLICY "audio_files_delete_own" ON public.audio_files
FOR DELETE USING (user_id = auth.uid());
CREATE TRIGGER set_updated_at_audio_files
BEFORE UPDATE ON public.audio_files
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
-- Server-owned processing jobs ------------------------------------------------
CREATE TABLE IF NOT EXISTS public.processing_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
audio_file_id uuid REFERENCES public.audio_files(id) ON DELETE SET NULL,
history_id uuid REFERENCES public.history(id) ON DELETE SET NULL,
meeting_id uuid REFERENCES public.meetings(id) ON DELETE SET NULL,
kind text NOT NULL CHECK (kind IN ('transcription', 'summary', 'minutes', 'diarization', 'export')),
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
idempotency_key text NOT NULL,
error_code text,
error_message text,
result jsonb,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idx_processing_jobs_user_created
ON public.processing_jobs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_processing_jobs_active
ON public.processing_jobs(status, created_at)
WHERE status IN ('queued', 'running');
ALTER TABLE public.processing_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "processing_jobs_read_own" ON public.processing_jobs
FOR SELECT USING (user_id = auth.uid());
-- Job mutations are service-role only. A user requests work through an Edge
-- Function, which validates ownership, quota, content type and size first.
CREATE TRIGGER set_updated_at_processing_jobs
BEFORE UPDATE ON public.processing_jobs
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
-- History state required by mobile filters and conflict-aware sync ------------
ALTER TABLE public.history
ADD COLUMN IF NOT EXISTS is_favorite boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0);
CREATE INDEX IF NOT EXISTS idx_history_favorite
ON public.history(user_id, created_at DESC) WHERE is_favorite;
-- Store purchase summary. Raw purchase tokens live in a separate RLS table. --
CREATE TABLE IF NOT EXISTS public.iap_purchases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
platform text NOT NULL CHECK (platform IN ('google_play', 'app_store')),
product_id text NOT NULL,
store_transaction_id text,
token_hash text NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
purchase_state text NOT NULL
CHECK (purchase_state IN ('pending', 'purchased', 'cancelled', 'expired', 'refunded', 'on_hold', 'paused')),
purchase_at timestamptz,
expires_at timestamptz,
auto_renewing boolean,
acknowledged_at timestamptz,
verified_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (platform, token_hash)
);
CREATE INDEX IF NOT EXISTS idx_iap_purchases_user_verified
ON public.iap_purchases(user_id, verified_at DESC);
ALTER TABLE public.iap_purchases ENABLE ROW LEVEL SECURITY;
CREATE POLICY "iap_purchases_read_own" ON public.iap_purchases
FOR SELECT USING (user_id = auth.uid());
CREATE TRIGGER set_updated_at_iap_purchases
BEFORE UPDATE ON public.iap_purchases
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
CREATE TABLE IF NOT EXISTS public.iap_purchase_receipts (
purchase_id uuid PRIMARY KEY REFERENCES public.iap_purchases(id) ON DELETE CASCADE,
purchase_token text NOT NULL UNIQUE,
verification jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.iap_purchase_receipts ENABLE ROW LEVEL SECURITY;
-- Deliberately no authenticated policies. service_role bypasses RLS.
CREATE TRIGGER set_updated_at_iap_purchase_receipts
BEFORE UPDATE ON public.iap_purchase_receipts
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
CREATE TABLE IF NOT EXISTS public.store_notification_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform text NOT NULL CHECK (platform IN ('google_play', 'app_store')),
message_id text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
processing_error text,
UNIQUE (platform, message_id)
);
ALTER TABLE public.store_notification_events ENABLE ROW LEVEL SECURITY;
-- Deliberately no authenticated policies. service_role bypasses RLS.
ALTER TABLE public.subscriptions
ADD COLUMN IF NOT EXISTS provider text NOT NULL DEFAULT 'none'
CHECK (provider IN ('none', 'stripe', 'payple', 'google_play', 'app_store', 'admin')),
ADD COLUMN IF NOT EXISTS store_product_id text,
ADD COLUMN IF NOT EXISTS store_purchase_id uuid REFERENCES public.iap_purchases(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS auto_renewing boolean;
UPDATE public.subscriptions
SET provider = payment_provider
WHERE provider = 'none'
AND payment_provider IN ('stripe', 'payple');
ALTER TABLE public.subscriptions
DROP CONSTRAINT IF EXISTS subscriptions_payment_provider_check;
ALTER TABLE public.subscriptions
ADD CONSTRAINT subscriptions_payment_provider_check
CHECK (payment_provider IN ('none', 'stripe', 'payple', 'google_play', 'app_store'));
-- A store receipt can affect entitlement only through this service-role RPC.
-- The Edge verifier calls the store first and passes the normalized result.
CREATE OR REPLACE FUNCTION public.apply_verified_store_purchase(
p_user_id uuid,
p_platform text,
p_product_id text,
p_store_transaction_id text,
p_token_hash text,
p_purchase_token text,
p_purchase_state text,
p_purchase_at timestamptz,
p_expires_at timestamptz,
p_auto_renewing boolean,
p_acknowledged boolean,
p_tier text,
p_entitled boolean,
p_verification jsonb
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_purchase_id uuid;
v_existing_user_id uuid;
v_status text;
BEGIN
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'unknown_user';
END IF;
IF p_platform NOT IN ('google_play', 'app_store') THEN
RAISE EXCEPTION 'invalid_platform';
END IF;
IF p_tier NOT IN ('pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_tier';
END IF;
IF p_purchase_state NOT IN ('pending', 'purchased', 'cancelled', 'expired', 'refunded', 'on_hold', 'paused') THEN
RAISE EXCEPTION 'invalid_purchase_state';
END IF;
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' OR length(trim(p_purchase_token)) < 8 THEN
RAISE EXCEPTION 'invalid_purchase_token';
END IF;
SELECT user_id
INTO v_existing_user_id
FROM public.iap_purchases
WHERE platform = p_platform
AND token_hash = p_token_hash
FOR UPDATE;
IF v_existing_user_id IS NOT NULL AND v_existing_user_id <> p_user_id THEN
RAISE EXCEPTION 'purchase_owned_by_other_user';
END IF;
IF p_entitled AND EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND tier <> 'free'
AND provider NOT IN ('none', p_platform)
AND status IN ('active', 'trialing', 'past_due')
AND (current_period_end IS NULL OR current_period_end > now())
) THEN
RAISE EXCEPTION 'active_subscription_other_provider';
END IF;
INSERT INTO public.iap_purchases (
user_id,
platform,
product_id,
store_transaction_id,
token_hash,
purchase_state,
purchase_at,
expires_at,
auto_renewing,
acknowledged_at,
verified_at
) VALUES (
p_user_id,
p_platform,
trim(p_product_id),
nullif(trim(p_store_transaction_id), ''),
p_token_hash,
p_purchase_state,
p_purchase_at,
p_expires_at,
p_auto_renewing,
CASE WHEN p_acknowledged THEN now() ELSE NULL END,
now()
)
ON CONFLICT (platform, token_hash) DO UPDATE
SET product_id = EXCLUDED.product_id,
store_transaction_id = EXCLUDED.store_transaction_id,
purchase_state = EXCLUDED.purchase_state,
purchase_at = EXCLUDED.purchase_at,
expires_at = EXCLUDED.expires_at,
auto_renewing = EXCLUDED.auto_renewing,
acknowledged_at = CASE
WHEN EXCLUDED.acknowledged_at IS NOT NULL THEN EXCLUDED.acknowledged_at
ELSE public.iap_purchases.acknowledged_at
END,
verified_at = now(),
updated_at = now()
RETURNING id INTO v_purchase_id;
INSERT INTO public.iap_purchase_receipts (purchase_id, purchase_token, verification)
VALUES (v_purchase_id, p_purchase_token, coalesce(p_verification, '{}'::jsonb))
ON CONFLICT (purchase_id) DO UPDATE
SET purchase_token = EXCLUDED.purchase_token,
verification = EXCLUDED.verification,
updated_at = now();
v_status := CASE p_purchase_state
WHEN 'purchased' THEN 'active'
WHEN 'cancelled' THEN 'canceled'
WHEN 'on_hold' THEN 'on_hold'
ELSE p_purchase_state
END;
IF p_entitled THEN
INSERT INTO public.subscriptions (
user_id,
tier,
status,
current_period_start,
current_period_end,
provider,
payment_provider,
store_product_id,
store_purchase_id,
auto_renewing
) VALUES (
p_user_id,
p_tier,
v_status,
p_purchase_at,
p_expires_at,
p_platform,
p_platform,
trim(p_product_id),
v_purchase_id,
p_auto_renewing
)
ON CONFLICT (user_id) DO UPDATE
SET tier = EXCLUDED.tier,
status = EXCLUDED.status,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
cancel_at = CASE WHEN EXCLUDED.auto_renewing THEN NULL ELSE EXCLUDED.current_period_end END,
provider = EXCLUDED.provider,
payment_provider = EXCLUDED.payment_provider,
store_product_id = EXCLUDED.store_product_id,
store_purchase_id = EXCLUDED.store_purchase_id,
auto_renewing = EXCLUDED.auto_renewing,
updated_at = now();
UPDATE public.profiles
SET tier = p_tier,
updated_at = now()
WHERE id = p_user_id;
ELSE
UPDATE public.subscriptions
SET tier = 'free',
status = v_status,
current_period_end = p_expires_at,
cancel_at = p_expires_at,
provider = 'none',
payment_provider = 'none',
store_product_id = NULL,
store_purchase_id = NULL,
auto_renewing = false,
updated_at = now()
WHERE user_id = p_user_id
AND store_purchase_id = v_purchase_id;
IF FOUND THEN
UPDATE public.profiles
SET tier = 'free',
updated_at = now()
WHERE id = p_user_id;
END IF;
END IF;
RETURN jsonb_build_object(
'purchase_id', v_purchase_id,
'tier', CASE WHEN p_entitled THEN p_tier ELSE 'free' END,
'entitled', p_entitled,
'status', v_status,
'acknowledged', p_acknowledged
);
END;
$$;
REVOKE ALL ON FUNCTION public.apply_verified_store_purchase(
uuid, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.apply_verified_store_purchase(
uuid, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) TO service_role;
-- Verified ad reward ledger ---------------------------------------------------
CREATE TABLE IF NOT EXISTS public.ad_reward_claims (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
network text NOT NULL,
placement text NOT NULL,
ad_unit_id text NOT NULL,
transaction_id text NOT NULL UNIQUE,
reward_tokens integer NOT NULL CHECK (reward_tokens > 0 AND reward_tokens <= 100),
verified_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ad_reward_claims_user_verified
ON public.ad_reward_claims(user_id, verified_at DESC);
ALTER TABLE public.ad_reward_claims ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ad_reward_claims_read_own" ON public.ad_reward_claims
FOR SELECT USING (user_id = auth.uid());
CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward(
p_user_id uuid,
p_network text,
p_placement text,
p_ad_unit_id text,
p_transaction_id text,
p_reward_tokens integer
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_claim_id uuid;
v_balance integer;
BEGIN
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'unknown_user';
END IF;
IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8 THEN
RAISE EXCEPTION 'invalid_transaction';
END IF;
IF p_reward_tokens <> 50 THEN
RAISE EXCEPTION 'invalid_reward_amount';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND tier = 'free'
AND coalesce(status, 'active') IN ('active', 'trialing')
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'ineligible_tier');
END IF;
IF EXISTS (
SELECT 1
FROM public.ad_reward_claims
WHERE transaction_id = trim(p_transaction_id)
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
IF EXISTS (
SELECT 1
FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at > now() - interval '15 seconds'
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'cooldown');
END IF;
INSERT INTO public.ad_reward_claims (
user_id, network, placement, ad_unit_id, transaction_id, reward_tokens
) VALUES (
p_user_id,
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
trim(p_transaction_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_claim_id;
IF v_claim_id IS NULL THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider)
VALUES (p_user_id, 'free', p_reward_tokens, 'none')
ON CONFLICT (user_id) DO UPDATE
SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits,
updated_at = now()
RETURNING overage_credits INTO v_balance;
RETURN jsonb_build_object(
'granted', true,
'claim_id', v_claim_id,
'tokens_added', p_reward_tokens,
'balance', v_balance
);
END;
$$;
REVOKE ALL ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) TO service_role;
-- Realtime state consumed by mobile and web ----------------------------------
ALTER PUBLICATION supabase_realtime ADD TABLE public.processing_jobs;
ALTER PUBLICATION supabase_realtime ADD TABLE public.user_settings;
COMMIT;

View file

@ -0,0 +1,121 @@
-- ============================================================================
-- Mobile monetization hardening
-- Atomically revoke Google Play linked purchase tokens before applying a new
-- verified purchase. This prevents upgrade/downgrade and resubscribe chains
-- from retaining two entitlements.
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.apply_verified_google_play_purchase(
p_user_id uuid,
p_platform text,
p_product_id text,
p_store_transaction_id text,
p_token_hash text,
p_linked_token_hash text,
p_purchase_token text,
p_purchase_state text,
p_purchase_at timestamptz,
p_expires_at timestamptz,
p_auto_renewing boolean,
p_acknowledged boolean,
p_tier text,
p_entitled boolean,
p_verification jsonb
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_linked_purchase_id uuid;
v_linked_user_id uuid;
v_linked_revoked boolean := false;
v_result jsonb;
BEGIN
IF p_platform <> 'google_play' THEN
RAISE EXCEPTION 'invalid_platform';
END IF;
IF p_linked_token_hash IS NOT NULL THEN
IF p_linked_token_hash !~ '^[0-9a-f]{64}$' OR p_linked_token_hash = p_token_hash THEN
RAISE EXCEPTION 'invalid_linked_purchase_token';
END IF;
SELECT id, user_id
INTO v_linked_purchase_id, v_linked_user_id
FROM public.iap_purchases
WHERE platform = 'google_play'
AND token_hash = p_linked_token_hash
FOR UPDATE;
IF v_linked_user_id IS NOT NULL AND v_linked_user_id <> p_user_id THEN
RAISE EXCEPTION 'linked_purchase_owned_by_other_user';
END IF;
IF v_linked_purchase_id IS NOT NULL THEN
UPDATE public.iap_purchases
SET purchase_state = 'expired',
expires_at = least(coalesce(expires_at, now()), now()),
auto_renewing = false,
verified_at = now(),
updated_at = now()
WHERE id = v_linked_purchase_id;
UPDATE public.subscriptions
SET tier = 'free',
status = 'expired',
current_period_end = now(),
cancel_at = now(),
provider = 'none',
payment_provider = 'none',
store_product_id = NULL,
store_purchase_id = NULL,
auto_renewing = false,
updated_at = now()
WHERE user_id = p_user_id
AND store_purchase_id = v_linked_purchase_id;
IF FOUND THEN
UPDATE public.profiles
SET tier = 'free',
updated_at = now()
WHERE id = p_user_id;
END IF;
v_linked_revoked := true;
END IF;
END IF;
v_result := public.apply_verified_store_purchase(
p_user_id,
p_platform,
p_product_id,
p_store_transaction_id,
p_token_hash,
p_purchase_token,
p_purchase_state,
p_purchase_at,
p_expires_at,
p_auto_renewing,
p_acknowledged,
p_tier,
p_entitled,
p_verification
);
RETURN v_result || jsonb_build_object('linked_purchase_revoked', v_linked_revoked);
END;
$$;
REVOKE ALL ON FUNCTION public.apply_verified_google_play_purchase(
uuid, text, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.apply_verified_google_play_purchase(
uuid, text, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) TO service_role;
COMMIT;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,206 @@
BEGIN;
-- Serialize rewards per user so distinct, concurrently verified callbacks
-- cannot both pass the cooldown/cap checks before either claim is visible.
CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward(
p_user_id uuid,
p_network text,
p_placement text,
p_ad_unit_id text,
p_transaction_id text,
p_reward_tokens integer
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_claim_id uuid;
v_balance integer;
BEGIN
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'unknown_user';
END IF;
IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8
OR length(trim(p_transaction_id)) > 128 THEN
RAISE EXCEPTION 'invalid_transaction';
END IF;
IF p_reward_tokens <> 50 THEN
RAISE EXCEPTION 'invalid_reward_amount';
END IF;
IF length(trim(coalesce(p_network, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_placement, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_ad_unit_id, ''))) NOT BETWEEN 1 AND 80 THEN
RAISE EXCEPTION 'invalid_reward_metadata';
END IF;
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:ad-reward:' || p_user_id::text, 0)
);
IF NOT EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND tier = 'free'
AND coalesce(status, 'active') IN ('active', 'trialing')
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'ineligible_tier');
END IF;
IF EXISTS (
SELECT 1 FROM public.ad_reward_claims
WHERE transaction_id = trim(p_transaction_id)
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
IF (
SELECT count(*)
FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at >= date_trunc('day', now())
) >= 20 THEN
RETURN jsonb_build_object('granted', false, 'reason', 'daily_cap');
END IF;
IF EXISTS (
SELECT 1 FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at > now() - interval '15 seconds'
) THEN
RETURN jsonb_build_object('granted', false, 'reason', 'cooldown');
END IF;
INSERT INTO public.ad_reward_claims (
user_id, network, placement, ad_unit_id, transaction_id, reward_tokens
) VALUES (
p_user_id,
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
trim(p_transaction_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_claim_id;
IF v_claim_id IS NULL THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider)
VALUES (p_user_id, 'free', p_reward_tokens, 'none')
ON CONFLICT (user_id) DO UPDATE
SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits,
updated_at = now()
RETURNING overage_credits INTO v_balance;
RETURN jsonb_build_object(
'granted', true,
'claim_id', v_claim_id,
'tokens_added', p_reward_tokens,
'balance', v_balance
);
END;
$$;
REVOKE ALL ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) TO service_role;
-- Exact dashboard aggregates shared by mobile and web. SECURITY INVOKER plus
-- the explicit auth.uid predicate keeps this within the caller's RLS boundary.
CREATE OR REPLACE FUNCTION public.mobile_dashboard_stats()
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path = public, pg_temp
AS $$
WITH activity AS (
SELECT id, title, original_text, duration, word_count, mode, created_at
FROM public.history
WHERE user_id = auth.uid()
AND status = 'completed'
),
activity_days AS (
SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS activity_day
FROM activity
),
streak_anchor AS (
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM activity_days
WHERE activity_day = (now() AT TIME ZONE 'UTC')::date
) THEN (now() AT TIME ZONE 'UTC')::date
WHEN EXISTS (
SELECT 1 FROM activity_days
WHERE activity_day = (now() AT TIME ZONE 'UTC')::date - 1
) THEN (now() AT TIME ZONE 'UTC')::date - 1
ELSE NULL::date
END AS anchor_day
),
ordered_days AS (
SELECT activity_day,
row_number() OVER (ORDER BY activity_day DESC) AS position,
anchor_day
FROM activity_days
CROSS JOIN streak_anchor
WHERE anchor_day IS NOT NULL
AND activity_day <= anchor_day
),
recent_rows AS (
SELECT id, title, original_text, duration, mode, created_at
FROM activity
ORDER BY created_at DESC, id DESC
LIMIT 5
)
SELECT jsonb_build_object(
'total_sessions', (SELECT count(*) FROM activity),
'total_recording_seconds', coalesce((SELECT sum(greatest(duration, 0)) FROM activity), 0),
'total_word_count', coalesce((SELECT sum(greatest(word_count, 0)) FROM activity), 0),
'today_sessions', (
SELECT count(*) FROM activity
WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date
),
'today_recording_seconds', coalesce((
SELECT sum(greatest(duration, 0)) FROM activity
WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date
), 0),
'today_word_count', coalesce((
SELECT sum(greatest(word_count, 0)) FROM activity
WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date
), 0),
'streak_days', (
SELECT count(*) FROM ordered_days
WHERE activity_day = anchor_day - ((position - 1)::integer)
),
'recent_history', coalesce((
SELECT jsonb_agg(jsonb_build_object(
'id', id,
'title', title,
'text', original_text,
'duration_seconds', duration,
'mode', mode,
'created_at', created_at
) ORDER BY created_at DESC, id DESC)
FROM recent_rows
), '[]'::jsonb),
'generated_at', now()
);
$$;
REVOKE ALL ON FUNCTION public.mobile_dashboard_stats() FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_dashboard_stats() TO authenticated;
COMMENT ON FUNCTION public.mobile_dashboard_stats() IS
'Exact authenticated history aggregates for mobile/web dashboards; UTC day boundary.';
COMMIT;

View file

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

View file

@ -0,0 +1,69 @@
BEGIN;
CREATE OR REPLACE FUNCTION public.reorder_custom_instruction(
instruction_id uuid,
direction text
)
RETURNS SETOF public.custom_instructions
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
ordered_ids uuid[];
current_index integer;
target_index integer;
swapped_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF direction NOT IN ('up', 'down') THEN
RAISE EXCEPTION 'invalid_direction' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73036));
SELECT array_agg(instruction.id ORDER BY instruction.sort_order, instruction.created_at, instruction.id)
INTO ordered_ids
FROM public.custom_instructions AS instruction
WHERE instruction.user_id = current_user_id
AND instruction.builtin_key IS NULL;
current_index := array_position(ordered_ids, instruction_id);
IF current_index IS NULL THEN
RAISE EXCEPTION 'instruction_not_found' USING ERRCODE = 'P0002';
END IF;
target_index := current_index + CASE direction WHEN 'up' THEN -1 ELSE 1 END;
IF target_index < 1 OR target_index > coalesce(array_length(ordered_ids, 1), 0) THEN
RETURN QUERY
SELECT instruction.*
FROM public.custom_instructions AS instruction
WHERE instruction.user_id = current_user_id
ORDER BY instruction.sort_order, instruction.created_at, instruction.id;
RETURN;
END IF;
swapped_id := ordered_ids[target_index];
ordered_ids[target_index] := ordered_ids[current_index];
ordered_ids[current_index] := swapped_id;
UPDATE public.custom_instructions AS instruction
SET sort_order = (ordering.ordinality * 10 + 1000)::integer
FROM unnest(ordered_ids) WITH ORDINALITY AS ordering(id, ordinality)
WHERE instruction.id = ordering.id
AND instruction.user_id = current_user_id
AND instruction.builtin_key IS NULL;
RETURN QUERY
SELECT instruction.*
FROM public.custom_instructions AS instruction
WHERE instruction.user_id = current_user_id
ORDER BY instruction.sort_order, instruction.created_at, instruction.id;
END;
$$;
REVOKE ALL ON FUNCTION public.reorder_custom_instruction(uuid, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.reorder_custom_instruction(uuid, text) TO authenticated;
COMMIT;

View file

@ -0,0 +1,63 @@
BEGIN;
CREATE OR REPLACE FUNCTION public.bootstrap_custom_instructions()
RETURNS SETOF public.custom_instructions
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73035));
INSERT INTO public.custom_instructions (
user_id, builtin_key, name, description, prompt, icon, sort_order
)
VALUES
(
current_user_id, 'translate_en', 'Translate to English',
'Translate the input into natural English.',
'Translate the following text into natural English. Return only the translation.',
'translate', 10
),
(
current_user_id, 'summarize', 'Key summary',
'Summarize the key points in no more than three lines.',
'Summarize the following text in no more than three concise lines.',
'list', 20
),
(
current_user_id, 'formal', 'Professional rewrite',
'Rewrite in a formal business style while preserving meaning.',
'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.',
'briefcase', 30
),
(
current_user_id, 'explain_code', 'Explain code',
'Explain responsibilities and caveats in Korean.',
'Explain the following code in Korean, including its responsibilities and important caveats.',
'code', 40
)
ON CONFLICT DO NOTHING;
INSERT INTO public.user_settings (user_id)
VALUES (current_user_id)
ON CONFLICT (user_id) DO NOTHING;
RETURN QUERY
SELECT instruction.*
FROM public.custom_instructions AS instruction
WHERE instruction.user_id = current_user_id
ORDER BY instruction.sort_order, instruction.created_at, instruction.id;
END;
$$;
REVOKE ALL ON FUNCTION public.bootstrap_custom_instructions() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.bootstrap_custom_instructions() TO authenticated;
COMMIT;

View file

@ -0,0 +1,611 @@
BEGIN;
-- Direct multi-row team mutations cannot preserve ownership invariants. All
-- writes below are routed through locked SECURITY DEFINER RPCs.
DROP POLICY IF EXISTS "teams_insert_own" ON public.teams;
DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members;
DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members;
DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members;
DROP POLICY IF EXISTS "team_invites_insert_admin" ON public.team_invites;
DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites;
DROP POLICY IF EXISTS "team_invites_delete" ON public.team_invites;
CREATE TEMP TABLE duplicate_active_team_invites ON COMMIT DROP AS
SELECT
id,
row_number() OVER (
PARTITION BY team_id, lower(btrim(email))
ORDER BY created_at DESC, id
) AS duplicate_rank
FROM public.team_invites
WHERE accepted_at IS NULL;
DELETE FROM public.team_invites AS invite
USING duplicate_active_team_invites AS duplicate
WHERE invite.id = duplicate.id AND duplicate.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_invites_active_email
ON public.team_invites(team_id, lower(btrim(email)))
WHERE accepted_at IS NULL;
CREATE OR REPLACE FUNCTION public.create_team(team_name text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
normalized_name text := btrim(team_name);
created_team public.teams;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF normalized_name = '' OR char_length(normalized_name) > 80 THEN
RAISE EXCEPTION 'invalid_team_name' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73041));
IF (
SELECT count(*)
FROM public.teams
WHERE owner_id = current_user_id
) >= 25 THEN
RAISE EXCEPTION 'team_limit_reached' USING ERRCODE = '54000';
END IF;
INSERT INTO public.teams (name, owner_id)
VALUES (normalized_name, current_user_id)
RETURNING * INTO created_team;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (created_team.id, current_user_id, 'owner');
RETURN jsonb_build_object(
'id', created_team.id,
'name', created_team.name,
'owner_id', created_team.owner_id,
'role', 'owner',
'member_count', 1,
'created_at', created_team.created_at,
'updated_at', created_team.updated_at
);
END;
$$;
CREATE OR REPLACE FUNCTION public.create_team_invite(
target_team_id uuid,
invited_email text,
invited_role text DEFAULT 'member'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_user_email text;
caller_team_role text;
normalized_email text := lower(btrim(invited_email));
existing_invite public.team_invites;
created_invite public.team_invites;
invite_token text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF target_team_id IS NULL
OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$'
OR char_length(normalized_email) > 254
OR invited_role NOT IN ('admin', 'member') THEN
RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023';
END IF;
SELECT lower(email) INTO current_user_email FROM auth.users WHERE id = current_user_id;
IF current_user_email = normalized_email THEN
RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023';
END IF;
SELECT role INTO caller_team_role
FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
IF caller_team_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
IF invited_role = 'admin' AND caller_team_role <> 'owner' THEN
RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text || ':' || normalized_email, 73042));
IF (
SELECT count(*)
FROM public.team_invites
WHERE invited_by = current_user_id
AND created_at >= now() - interval '24 hours'
) >= 50 THEN
RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000';
END IF;
IF EXISTS (
SELECT 1
FROM public.team_members AS member
JOIN auth.users AS account ON account.id = member.user_id
WHERE member.team_id = target_team_id AND lower(account.email) = normalized_email
) THEN
RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505';
END IF;
SELECT * INTO existing_invite
FROM public.team_invites
WHERE team_id = target_team_id
AND lower(email) = normalized_email
AND accepted_at IS NULL
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE;
IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN
IF existing_invite.role <> invited_role THEN
UPDATE public.team_invites
SET role = invited_role
WHERE id = existing_invite.id
RETURNING * INTO existing_invite;
END IF;
RETURN jsonb_build_object(
'id', existing_invite.id,
'team_id', existing_invite.team_id,
'email', existing_invite.email,
'token', existing_invite.token,
'role', existing_invite.role,
'expires_at', existing_invite.expires_at,
'duplicate', true
);
END IF;
IF existing_invite.id IS NOT NULL THEN
DELETE FROM public.team_invites WHERE id = existing_invite.id;
END IF;
invite_token := public.generate_invite_token();
INSERT INTO public.team_invites (
team_id, invited_by, email, role, token
)
VALUES (
target_team_id, current_user_id, normalized_email, invited_role, invite_token
)
RETURNING * INTO created_invite;
RETURN jsonb_build_object(
'id', created_invite.id,
'team_id', created_invite.team_id,
'email', created_invite.email,
'token', created_invite.token,
'role', created_invite.role,
'expires_at', created_invite.expires_at,
'duplicate', false
);
END;
$$;
CREATE OR REPLACE FUNCTION public.accept_team_invite(invite_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_email text;
invite public.team_invites;
final_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF invite_token IS NULL OR char_length(invite_token) NOT BETWEEN 20 AND 128 THEN
RAISE EXCEPTION 'invalid_invite_token' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(invite_token, 73043));
SELECT * INTO invite
FROM public.team_invites
WHERE token = invite_token
FOR UPDATE;
IF invite.id IS NULL THEN
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
END IF;
IF invite.accepted_at IS NOT NULL THEN
IF invite.accepted_by = current_user_id THEN
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id;
IF final_role IS NULL THEN
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', true,
'already_member', true
);
END IF;
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
IF invite.expires_at <= now() THEN
RAISE EXCEPTION 'invite_expired' USING ERRCODE = '22023';
END IF;
SELECT lower(email) INTO current_email FROM auth.users WHERE id = current_user_id;
IF current_email IS NULL OR current_email <> lower(invite.email) THEN
RAISE EXCEPTION 'invite_email_mismatch' USING ERRCODE = '42501';
END IF;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (invite.team_id, current_user_id, invite.role)
ON CONFLICT (team_id, user_id) DO NOTHING;
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id
FOR UPDATE;
IF final_role IS NULL THEN
RAISE EXCEPTION 'team_membership_failed';
END IF;
UPDATE public.team_invites
SET accepted_at = now(), accepted_by = current_user_id
WHERE id = invite.id AND accepted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'invite_acceptance_conflict';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', false,
'already_member', false
);
END;
$$;
CREATE OR REPLACE FUNCTION public.update_team_member_role(
target_team_id uuid,
member_user_id uuid,
new_role text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
caller_role text;
prior_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF new_role NOT IN ('admin', 'member') THEN
RAISE EXCEPTION 'invalid_team_role' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73044));
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
IF caller_role <> 'owner' THEN
RAISE EXCEPTION 'team_owner_required' USING ERRCODE = '42501';
END IF;
SELECT role INTO prior_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id
FOR UPDATE;
IF prior_role IS NULL THEN
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
END IF;
IF prior_role = 'owner' OR member_user_id = current_user_id THEN
RAISE EXCEPTION 'team_owner_immutable' USING ERRCODE = '42501';
END IF;
UPDATE public.team_members SET role = new_role
WHERE team_id = target_team_id AND user_id = member_user_id;
RETURN jsonb_build_object(
'team_id', target_team_id,
'user_id', member_user_id,
'role', new_role
);
END;
$$;
CREATE OR REPLACE FUNCTION public.remove_team_member(
target_team_id uuid,
member_user_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
caller_role text;
target_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045));
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
SELECT role INTO target_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id
FOR UPDATE;
IF target_role IS NULL THEN
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
END IF;
IF target_role = 'owner' THEN
RAISE EXCEPTION 'team_owner_cannot_leave' USING ERRCODE = '42501';
END IF;
IF member_user_id <> current_user_id AND (
caller_role NOT IN ('owner', 'admin')
OR (target_role = 'admin' AND caller_role <> 'owner')
) THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id;
RETURN jsonb_build_object(
'team_id', target_team_id,
'user_id', member_user_id,
'removed', true
);
END;
$$;
CREATE OR REPLACE FUNCTION public.list_team_members(target_team_id uuid)
RETURNS TABLE (
user_id uuid,
role text,
joined_at timestamptz,
display_name text,
avatar_url text,
is_current boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT
member.user_id,
member.role,
member.joined_at,
profile.name,
profile.avatar_url,
member.user_id = auth.uid()
FROM public.team_members AS member
LEFT JOIN public.profiles AS profile ON profile.id = member.user_id
WHERE member.team_id = target_team_id
AND EXISTS (
SELECT 1 FROM public.team_members AS caller
WHERE caller.team_id = target_team_id AND caller.user_id = auth.uid()
)
ORDER BY
CASE member.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,
member.joined_at,
member.user_id;
$$;
CREATE OR REPLACE FUNCTION public.list_team_invites(target_team_id uuid)
RETURNS TABLE (
id uuid,
email text,
role text,
expires_at timestamptz,
created_at timestamptz
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT invite.id, invite.email, invite.role, invite.expires_at, invite.created_at
FROM public.team_invites AS invite
WHERE invite.team_id = target_team_id
AND invite.accepted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.team_members AS caller
WHERE caller.team_id = target_team_id
AND caller.user_id = auth.uid()
AND caller.role IN ('owner', 'admin')
)
ORDER BY invite.created_at DESC, invite.id;
$$;
CREATE OR REPLACE FUNCTION public.cancel_team_invite(invite_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
invite public.team_invites;
caller_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
SELECT * INTO invite FROM public.team_invites WHERE id = invite_id FOR UPDATE;
IF invite.id IS NULL OR invite.accepted_at IS NOT NULL THEN
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
END IF;
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id;
IF invite.invited_by <> current_user_id AND caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.team_invites WHERE id = invite.id;
RETURN jsonb_build_object('id', invite.id, 'cancelled', true);
END;
$$;
REVOKE ALL ON FUNCTION public.create_team(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_members(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_invites(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.create_team(text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.accept_team_invite(text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.update_team_member_role(uuid, uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.remove_team_member(uuid, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.list_team_members(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.list_team_invites(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.cancel_team_invite(uuid) TO authenticated;
-- Replace the legacy Expo-only registration shape with provider-aware,
-- device-owned registrations. Old tokens cannot be proven to belong to a bare
-- RN installation, so they are invalidated and clients must register again.
DELETE FROM public.push_tokens;
UPDATE public.devices SET push_token = NULL WHERE push_token IS NOT NULL;
DROP INDEX IF EXISTS public.idx_devices_push_token;
DROP POLICY IF EXISTS "push_tokens_own" ON public.push_tokens;
ALTER TABLE public.push_tokens
ADD COLUMN IF NOT EXISTS provider text,
ADD COLUMN IF NOT EXISTS last_registered_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.push_tokens
ALTER COLUMN device_id SET NOT NULL,
ALTER COLUMN provider SET NOT NULL,
ADD CONSTRAINT push_tokens_provider_check
CHECK (provider IN ('fcm', 'apns', 'webpush'));
CREATE POLICY "push_tokens_no_direct_access" ON public.push_tokens
FOR SELECT USING (false);
DROP POLICY IF EXISTS "devices_insert_own" ON public.devices;
CREATE POLICY "devices_insert_own" ON public.devices
FOR INSERT WITH CHECK (
user_id = auth.uid() AND revoked_at IS NULL AND push_token IS NULL
);
CREATE OR REPLACE FUNCTION public.reject_direct_device_push_token()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
IF NEW.push_token IS DISTINCT FROM OLD.push_token THEN
RAISE EXCEPTION 'device_push_token_is_server_managed' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER reject_direct_device_push_token_update
BEFORE UPDATE OF push_token ON public.devices
FOR EACH ROW EXECUTE FUNCTION public.reject_direct_device_push_token();
CREATE OR REPLACE FUNCTION public.register_push_registration(
push_device_id uuid,
push_provider text,
registration_id text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
owned_device public.devices;
existing_owner uuid;
registered public.push_tokens;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF push_provider NOT IN ('fcm', 'apns', 'webpush')
OR registration_id IS NULL
OR char_length(registration_id) NOT BETWEEN 20 AND 4096
OR registration_id ~ '[[:space:]]' THEN
RAISE EXCEPTION 'invalid_push_registration' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(push_device_id::text, 73046));
PERFORM pg_advisory_xact_lock(hashtextextended(registration_id, 73047));
SELECT * INTO owned_device
FROM public.devices
WHERE id = push_device_id AND user_id = current_user_id
FOR UPDATE;
IF owned_device.id IS NULL OR owned_device.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'active_device_not_found' USING ERRCODE = 'P0002';
END IF;
IF (owned_device.platform = 'android' AND push_provider <> 'fcm')
OR (owned_device.platform = 'ios' AND push_provider <> 'apns')
OR (owned_device.platform = 'web' AND push_provider <> 'webpush') THEN
RAISE EXCEPTION 'push_provider_platform_mismatch' USING ERRCODE = '22023';
END IF;
SELECT user_id INTO existing_owner
FROM public.push_tokens
WHERE token = registration_id
FOR UPDATE;
IF existing_owner IS NOT NULL AND existing_owner <> current_user_id THEN
RAISE EXCEPTION 'push_registration_owned_by_other_user' USING ERRCODE = '23505';
END IF;
DELETE FROM public.push_tokens
WHERE device_id = push_device_id OR token = registration_id;
INSERT INTO public.push_tokens (
user_id, token, platform, device_name, device_id, provider, last_registered_at
)
VALUES (
current_user_id,
registration_id,
owned_device.platform,
owned_device.device_name,
owned_device.id,
push_provider,
now()
)
RETURNING * INTO registered;
RETURN jsonb_build_object(
'device_id', registered.device_id,
'provider', registered.provider,
'registered_at', registered.last_registered_at
);
END;
$$;
CREATE OR REPLACE FUNCTION public.unregister_push_registration(push_device_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
removed_count integer;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.push_tokens
WHERE device_id = push_device_id AND user_id = current_user_id;
GET DIAGNOSTICS removed_count = ROW_COUNT;
RETURN jsonb_build_object(
'device_id', push_device_id,
'removed', removed_count > 0
);
END;
$$;
REVOKE ALL ON FUNCTION public.register_push_registration(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.unregister_push_registration(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.register_push_registration(uuid, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.unregister_push_registration(uuid) TO authenticated;
COMMIT;

View file

@ -0,0 +1,176 @@
BEGIN;
-- These helpers are used by RLS policies with auth.uid(). Historically an
-- authenticated caller could invoke them with somebody else's UUID and learn
-- that account's team ids. Preserve the policy signature while binding every
-- authenticated invocation to the caller.
CREATE OR REPLACE FUNCTION public.user_team_ids(uid uuid)
RETURNS SETOF uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT member.team_id
FROM public.team_members AS member
WHERE uid = auth.uid()
AND member.user_id = auth.uid();
$$;
CREATE OR REPLACE FUNCTION public.user_admin_team_ids(uid uuid)
RETURNS SETOF uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT member.team_id
FROM public.team_members AS member
WHERE uid = auth.uid()
AND member.user_id = auth.uid()
AND member.role IN ('owner', 'admin');
$$;
REVOKE ALL ON FUNCTION public.user_team_ids(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.user_admin_team_ids(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.user_team_ids(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.user_admin_team_ids(uuid) TO authenticated;
-- Push delivery is initiated by an authenticated user but performed with the
-- service role. This service-only ledger makes deduplication and limits atomic
-- across Edge Function instances.
CREATE TABLE public.push_dispatch_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
caller_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
event_type text NOT NULL CHECK (event_type IN (
'transcription.completed',
'team.invite.created',
'billing.status.changed'
)),
resource_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_push_dispatch_attempts_caller_created
ON public.push_dispatch_attempts(caller_id, created_at DESC);
CREATE INDEX idx_push_dispatch_attempts_event_resource_created
ON public.push_dispatch_attempts(caller_id, event_type, resource_id, created_at DESC);
ALTER TABLE public.push_dispatch_attempts ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.reserve_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt_id uuid;
created_attempt_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed',
'team.invite.created',
'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
-- A caller-scoped lock serializes both aggregate limits and per-event
-- deduplication, including concurrent requests handled by different workers.
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73048));
SELECT attempt.id INTO existing_attempt_id
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.caller_id = current_user_id
AND attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND attempt.created_at > now() - interval '30 seconds'
ORDER BY attempt.created_at DESC
LIMIT 1;
IF existing_attempt_id IS NOT NULL THEN
RETURN jsonb_build_object(
'reserved', false,
'duplicate', true,
'attempt_id', existing_attempt_id
);
END IF;
IF (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '1 minute'
) >= 10 OR (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '24 hours'
) >= 100 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_attempts (caller_id, event_type, resource_id)
VALUES (current_user_id, normalized_event_type, push_resource_id)
RETURNING id INTO created_attempt_id;
RETURN jsonb_build_object(
'reserved', true,
'duplicate', false,
'attempt_id', created_attempt_id
);
END;
$$;
-- Invite recipient resolution must cross the auth schema, which PostgREST
-- clients cannot join directly. Only the inviter of an active invite may ask.
CREATE OR REPLACE FUNCTION public.resolve_team_invite_recipient(invite_id uuid)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
recipient_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF invite_id IS NULL THEN
RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023';
END IF;
SELECT account.id INTO recipient_id
FROM public.team_invites AS invite
JOIN auth.users AS account ON lower(account.email) = lower(invite.email)
WHERE invite.id = invite_id
AND invite.invited_by = current_user_id
AND invite.accepted_at IS NULL
AND invite.expires_at > now()
ORDER BY account.created_at, account.id
LIMIT 1;
RETURN recipient_id;
END;
$$;
REVOKE ALL ON TABLE public.push_dispatch_attempts FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.push_dispatch_attempts TO service_role;
REVOKE ALL ON FUNCTION public.reserve_push_dispatch(text, uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.resolve_team_invite_recipient(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.reserve_push_dispatch(text, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.resolve_team_invite_recipient(uuid) TO authenticated;
COMMIT;

View file

@ -0,0 +1,163 @@
BEGIN;
-- Supabase's default function privileges explicitly grant anon/authenticated,
-- so revoking PUBLIC alone is insufficient. Keep the bearer-token generator
-- service-only and give it an immutable search path.
CREATE OR REPLACE FUNCTION public.generate_invite_token()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
generated_token text;
BEGIN
generated_token := pg_catalog.encode(extensions.gen_random_bytes(24), 'base64');
RETURN pg_catalog.replace(
pg_catalog.replace(
pg_catalog.replace(generated_token, '+', '-'),
'/', '_'
),
'=', ''
);
END;
$$;
-- `current_role` is a PostgreSQL special expression. The earlier variable
-- name therefore evaluated to the database role instead of the team role and
-- rejected valid owners. Use an unambiguous variable name.
CREATE OR REPLACE FUNCTION public.create_team_invite(
target_team_id uuid,
invited_email text,
invited_role text DEFAULT 'member'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_user_email text;
caller_team_role text;
normalized_email text := lower(btrim(invited_email));
existing_invite public.team_invites;
created_invite public.team_invites;
invite_token text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF target_team_id IS NULL
OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$'
OR char_length(normalized_email) > 254
OR invited_role NOT IN ('admin', 'member') THEN
RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023';
END IF;
SELECT lower(account.email) INTO current_user_email
FROM auth.users AS account
WHERE account.id = current_user_id;
IF current_user_email = normalized_email THEN
RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023';
END IF;
SELECT member.role INTO caller_team_role
FROM public.team_members AS member
WHERE member.team_id = target_team_id AND member.user_id = current_user_id;
IF caller_team_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
IF invited_role = 'admin' AND caller_team_role <> 'owner' THEN
RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(
hashtextextended(target_team_id::text || ':' || normalized_email, 73042)
);
IF (
SELECT count(*)
FROM public.team_invites AS recent_invite
WHERE recent_invite.invited_by = current_user_id
AND recent_invite.created_at >= now() - interval '24 hours'
) >= 50 THEN
RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000';
END IF;
IF EXISTS (
SELECT 1
FROM public.team_members AS member
JOIN auth.users AS account ON account.id = member.user_id
WHERE member.team_id = target_team_id
AND lower(account.email) = normalized_email
) THEN
RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505';
END IF;
SELECT active_invite.* INTO existing_invite
FROM public.team_invites AS active_invite
WHERE active_invite.team_id = target_team_id
AND lower(active_invite.email) = normalized_email
AND active_invite.accepted_at IS NULL
ORDER BY active_invite.created_at DESC
LIMIT 1
FOR UPDATE;
IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN
IF existing_invite.role <> invited_role THEN
UPDATE public.team_invites
SET role = invited_role
WHERE id = existing_invite.id
RETURNING * INTO existing_invite;
END IF;
RETURN jsonb_build_object(
'id', existing_invite.id,
'team_id', existing_invite.team_id,
'email', existing_invite.email,
'token', existing_invite.token,
'role', existing_invite.role,
'expires_at', existing_invite.expires_at,
'duplicate', true
);
END IF;
IF existing_invite.id IS NOT NULL THEN
DELETE FROM public.team_invites WHERE id = existing_invite.id;
END IF;
invite_token := public.generate_invite_token();
INSERT INTO public.team_invites (
team_id, invited_by, email, role, token
) VALUES (
target_team_id, current_user_id, normalized_email, invited_role, invite_token
)
RETURNING * INTO created_invite;
RETURN jsonb_build_object(
'id', created_invite.id,
'team_id', created_invite.team_id,
'email', created_invite.email,
'token', created_invite.token,
'role', created_invite.role,
'expires_at', created_invite.expires_at,
'duplicate', false
);
END;
$$;
REVOKE ALL ON FUNCTION public.generate_invite_token() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_invite_token() TO service_role;
REVOKE ALL ON FUNCTION public.create_team(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_members(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_invites(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.register_push_registration(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.unregister_push_registration(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.user_team_ids(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.user_admin_team_ids(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.reserve_push_dispatch(text, uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.resolve_team_invite_recipient(uuid) FROM PUBLIC, anon;
COMMIT;

View file

@ -0,0 +1,87 @@
BEGIN;
CREATE OR REPLACE FUNCTION public.accept_team_invite(invite_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_email text;
invite public.team_invites;
final_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF invite_token IS NULL OR char_length(invite_token) NOT BETWEEN 20 AND 128 THEN
RAISE EXCEPTION 'invalid_invite_token' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(invite_token, 73043));
SELECT * INTO invite
FROM public.team_invites
WHERE token = invite_token
FOR UPDATE;
IF invite.id IS NULL THEN
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
END IF;
IF invite.accepted_at IS NOT NULL THEN
IF invite.accepted_by = current_user_id THEN
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id;
IF final_role IS NULL THEN
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', true,
'already_member', true
);
END IF;
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
IF invite.expires_at <= now() THEN
RAISE EXCEPTION 'invite_expired' USING ERRCODE = '22023';
END IF;
SELECT lower(email) INTO current_email FROM auth.users WHERE id = current_user_id;
IF current_email IS NULL OR current_email <> lower(invite.email) THEN
RAISE EXCEPTION 'invite_email_mismatch' USING ERRCODE = '42501';
END IF;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (invite.team_id, current_user_id, invite.role)
ON CONFLICT (team_id, user_id) DO NOTHING;
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id
FOR UPDATE;
IF final_role IS NULL THEN
RAISE EXCEPTION 'team_membership_failed';
END IF;
UPDATE public.team_invites
SET accepted_at = now(), accepted_by = current_user_id
WHERE id = invite.id AND accepted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'invite_acceptance_conflict';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', false,
'already_member', false
);
END;
$$;
REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.accept_team_invite(text) TO authenticated;
COMMIT;

View file

@ -0,0 +1,83 @@
BEGIN;
ALTER TABLE public.push_dispatch_attempts
ADD COLUMN actor_kind text NOT NULL DEFAULT 'user'
CHECK (actor_kind IN ('user', 'system'));
ALTER TABLE public.push_dispatch_attempts
ALTER COLUMN caller_id DROP NOT NULL,
ADD CONSTRAINT push_dispatch_attempt_actor_check CHECK (
(actor_kind = 'user' AND caller_id IS NOT NULL)
OR (actor_kind = 'system' AND caller_id IS NULL)
);
CREATE INDEX idx_push_dispatch_attempts_system_event
ON public.push_dispatch_attempts(event_type, resource_id, created_at DESC)
WHERE actor_kind = 'system';
CREATE OR REPLACE FUNCTION public.reserve_system_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt_id uuid;
created_attempt_id uuid;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed',
'team.invite.created',
'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(
hashtextextended(normalized_event_type || ':' || push_resource_id::text, 73049)
);
SELECT attempt.id INTO existing_attempt_id
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND attempt.created_at > now() - interval '30 seconds'
ORDER BY attempt.created_at DESC
LIMIT 1;
IF existing_attempt_id IS NOT NULL THEN
RETURN jsonb_build_object(
'reserved', false,
'duplicate', true,
'attempt_id', existing_attempt_id
);
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id
) VALUES (
NULL, 'system', normalized_event_type, push_resource_id
)
RETURNING id INTO created_attempt_id;
RETURN jsonb_build_object(
'reserved', true,
'duplicate', false,
'attempt_id', created_attempt_id
);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_system_push_dispatch(text, uuid)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_system_push_dispatch(text, uuid)
TO service_role;
COMMIT;

View file

@ -0,0 +1,186 @@
BEGIN;
-- Transcription completion and a specific invitation are immutable events.
-- Keep the earliest reservation if a pre-release database already contains
-- duplicates, then enforce exactly-once dispatch across user/system actors.
WITH ranked AS (
SELECT id, row_number() OVER (
PARTITION BY event_type, resource_id
ORDER BY created_at, id
) AS duplicate_rank
FROM public.push_dispatch_attempts
WHERE event_type IN ('transcription.completed', 'team.invite.created')
)
DELETE FROM public.push_dispatch_attempts AS attempt
USING ranked
WHERE attempt.id = ranked.id AND ranked.duplicate_rank > 1;
CREATE UNIQUE INDEX idx_push_dispatch_immutable_event_once
ON public.push_dispatch_attempts(event_type, resource_id)
WHERE event_type IN ('transcription.completed', 'team.invite.created');
CREATE OR REPLACE FUNCTION public.reserve_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt_id uuid;
created_attempt_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed',
'team.invite.created',
'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73048));
PERFORM pg_advisory_xact_lock(
hashtextextended(normalized_event_type || ':' || push_resource_id::text, 73051)
);
SELECT attempt.id INTO existing_attempt_id
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND (
normalized_event_type IN ('transcription.completed', 'team.invite.created')
OR attempt.created_at > now() - interval '30 seconds'
)
ORDER BY attempt.created_at DESC
LIMIT 1;
IF existing_attempt_id IS NOT NULL THEN
RETURN jsonb_build_object(
'reserved', false,
'duplicate', true,
'attempt_id', existing_attempt_id
);
END IF;
IF (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'user'
AND attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '1 minute'
) >= 10 OR (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'user'
AND attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '24 hours'
) >= 100 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id
) VALUES (
current_user_id, 'user', normalized_event_type, push_resource_id
)
RETURNING id INTO created_attempt_id;
RETURN jsonb_build_object(
'reserved', true,
'duplicate', false,
'attempt_id', created_attempt_id
);
END;
$$;
CREATE OR REPLACE FUNCTION public.reserve_system_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt_id uuid;
created_attempt_id uuid;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed',
'team.invite.created',
'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('system-push-global', 73050));
PERFORM pg_advisory_xact_lock(
hashtextextended(normalized_event_type || ':' || push_resource_id::text, 73051)
);
SELECT attempt.id INTO existing_attempt_id
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND (
normalized_event_type IN ('transcription.completed', 'team.invite.created')
OR attempt.created_at > now() - interval '30 seconds'
)
ORDER BY attempt.created_at DESC
LIMIT 1;
IF existing_attempt_id IS NOT NULL THEN
RETURN jsonb_build_object(
'reserved', false,
'duplicate', true,
'attempt_id', existing_attempt_id
);
END IF;
IF (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND attempt.created_at > now() - interval '1 minute'
) >= 500 OR (
SELECT count(*)
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND attempt.created_at > now() - interval '24 hours'
) >= 10000 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id
) VALUES (
NULL, 'system', normalized_event_type, push_resource_id
)
RETURNING id INTO created_attempt_id;
RETURN jsonb_build_object(
'reserved', true,
'duplicate', false,
'attempt_id', created_attempt_id
);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_push_dispatch(text, uuid)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.reserve_push_dispatch(text, uuid)
TO authenticated;
REVOKE ALL ON FUNCTION public.reserve_system_push_dispatch(text, uuid)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_system_push_dispatch(text, uuid)
TO service_role;
COMMIT;

View file

@ -0,0 +1,153 @@
BEGIN;
DROP POLICY IF EXISTS "devices_delete_own" ON public.devices;
CREATE OR REPLACE FUNCTION public.protect_device_security_fields()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
IF current_user NOT IN ('postgres', 'service_role', 'supabase_admin') AND (
NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.installation_id IS DISTINCT FROM OLD.installation_id
OR NEW.platform IS DISTINCT FROM OLD.platform
OR NEW.revoked_at IS DISTINCT FROM OLD.revoked_at
OR NEW.push_token IS DISTINCT FROM OLD.push_token
) THEN
RAISE EXCEPTION 'device_security_fields_are_server_managed' USING ERRCODE = '42501';
END IF;
IF OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NULL THEN
RAISE EXCEPTION 'device_revocation_is_permanent' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS reject_direct_device_push_token_update ON public.devices;
DROP FUNCTION IF EXISTS public.reject_direct_device_push_token();
CREATE TRIGGER protect_device_security_fields_update
BEFORE UPDATE ON public.devices
FOR EACH ROW EXECUTE FUNCTION public.protect_device_security_fields();
CREATE OR REPLACE FUNCTION public.revoke_device(target_device_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
target_device public.devices;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF target_device_id IS NULL THEN
RAISE EXCEPTION 'invalid_device' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_device_id::text, 73052));
SELECT * INTO target_device
FROM public.devices
WHERE id = target_device_id AND user_id = current_user_id
FOR UPDATE;
IF target_device.id IS NULL THEN
RAISE EXCEPTION 'device_not_found' USING ERRCODE = 'P0002';
END IF;
IF target_device.revoked_at IS NULL THEN
UPDATE public.devices
SET revoked_at = now(), push_token = NULL
WHERE id = target_device.id
RETURNING * INTO target_device;
END IF;
DELETE FROM public.push_tokens
WHERE device_id = target_device.id AND user_id = current_user_id;
RETURN to_jsonb(target_device);
END;
$$;
CREATE OR REPLACE FUNCTION public.unregister_current_device(
current_installation_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
target_device_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF current_installation_id IS NULL THEN
RAISE EXCEPTION 'invalid_device' USING ERRCODE = '22023';
END IF;
SELECT id INTO target_device_id
FROM public.devices
WHERE user_id = current_user_id
AND installation_id = current_installation_id
AND revoked_at IS NULL
FOR UPDATE;
IF target_device_id IS NULL THEN
RETURN jsonb_build_object('removed', false);
END IF;
DELETE FROM public.push_tokens
WHERE device_id = target_device_id AND user_id = current_user_id;
DELETE FROM public.devices
WHERE id = target_device_id AND user_id = current_user_id AND revoked_at IS NULL;
RETURN jsonb_build_object('removed', true, 'device_id', target_device_id);
END;
$$;
CREATE OR REPLACE FUNCTION public.purge_revoked_device(
target_device_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
jwt_issued_at timestamptz;
removed_count integer;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
BEGIN
jwt_issued_at := to_timestamp((auth.jwt()->>'iat')::double precision);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'recent_authentication_required' USING ERRCODE = '42501';
END;
IF jwt_issued_at IS NULL OR jwt_issued_at < now() - interval '10 minutes' THEN
RAISE EXCEPTION 'recent_authentication_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.devices
WHERE id = target_device_id
AND user_id = current_user_id
AND revoked_at IS NOT NULL;
GET DIAGNOSTICS removed_count = ROW_COUNT;
IF removed_count = 0 THEN
RAISE EXCEPTION 'revoked_device_not_found' USING ERRCODE = 'P0002';
END IF;
RETURN jsonb_build_object('removed', true, 'device_id', target_device_id);
END;
$$;
REVOKE ALL ON FUNCTION public.revoke_device(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.unregister_current_device(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.purge_revoked_device(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.revoke_device(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.unregister_current_device(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.purge_revoked_device(uuid) TO authenticated;
COMMIT;

View file

@ -0,0 +1,792 @@
BEGIN;
-- A dispatch reservation is a durable lease, not proof that FCM accepted the
-- message. Existing pre-outbox rows are treated as completed so a rollout
-- never replays historical notifications unexpectedly.
ALTER TABLE public.push_dispatch_attempts
ADD COLUMN status text NOT NULL DEFAULT 'pending',
ADD COLUMN attempt_count integer NOT NULL DEFAULT 0,
ADD COLUMN dispatch_lease_token uuid,
ADD COLUMN lease_expires_at timestamptz,
ADD COLUMN next_retry_at timestamptz,
ADD COLUMN completed_at timestamptz,
ADD COLUMN last_error_code text,
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
UPDATE public.push_dispatch_attempts
SET status = 'succeeded',
completed_at = created_at,
updated_at = now()
WHERE status = 'pending';
ALTER TABLE public.push_dispatch_attempts
ADD CONSTRAINT push_dispatch_status_check CHECK (
status IN ('pending', 'processing', 'succeeded', 'partial', 'failed')
),
ADD CONSTRAINT push_dispatch_attempt_count_check CHECK (attempt_count >= 0),
ADD CONSTRAINT push_dispatch_error_code_check CHECK (
last_error_code IS NULL OR last_error_code ~ '^[a-z0-9_]{1,64}$'
),
ADD CONSTRAINT push_dispatch_lease_shape_check CHECK (
(dispatch_lease_token IS NULL AND lease_expires_at IS NULL)
OR (dispatch_lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
);
CREATE INDEX idx_push_dispatch_due
ON public.push_dispatch_attempts(next_retry_at, created_at)
WHERE status IN ('pending', 'processing');
CREATE TABLE public.push_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
attempt_id uuid NOT NULL REFERENCES public.push_dispatch_attempts(id) ON DELETE CASCADE,
push_token_id uuid NOT NULL,
target_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider text NOT NULL CHECK (provider IN ('fcm', 'apns', 'webpush')),
token_fingerprint text NOT NULL CHECK (token_fingerprint ~ '^[0-9a-f]{64}$'),
status text NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'leased', 'delivered', 'stale', 'retryable', 'permanent_failed')
),
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
delivery_lease_token uuid,
lease_expires_at timestamptz,
next_retry_at timestamptz,
last_error_code text CHECK (
last_error_code IS NULL OR last_error_code ~ '^[a-z0-9_]{1,64}$'
),
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (attempt_id, push_token_id),
CHECK (
(status = 'leased' AND delivery_lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
OR (status <> 'leased' AND delivery_lease_token IS NULL AND lease_expires_at IS NULL)
)
);
CREATE INDEX idx_push_deliveries_attempt_status
ON public.push_deliveries(attempt_id, status, next_retry_at);
ALTER TABLE public.push_deliveries ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.push_deliveries FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.push_deliveries TO service_role;
CREATE OR REPLACE FUNCTION public.push_dispatch_target_user(
target_event_type text,
target_resource_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
target_user_id uuid;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
CASE target_event_type
WHEN 'transcription.completed' THEN
SELECT history.user_id INTO target_user_id
FROM public.history AS history
WHERE history.id = target_resource_id AND history.status = 'completed';
WHEN 'team.invite.created' THEN
SELECT account.id INTO target_user_id
FROM public.team_invites AS invite
JOIN auth.users AS account ON lower(account.email) = lower(invite.email)
WHERE invite.id = target_resource_id
AND invite.accepted_at IS NULL
AND invite.expires_at > now()
ORDER BY account.created_at, account.id
LIMIT 1;
WHEN 'billing.status.changed' THEN
SELECT subscription.user_id INTO target_user_id
FROM public.subscriptions AS subscription
WHERE subscription.id = target_resource_id;
ELSE
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END CASE;
RETURN target_user_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.reserve_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt public.push_dispatch_attempts;
created_attempt public.push_dispatch_attempts;
new_lease_token uuid := gen_random_uuid();
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed', 'team.invite.created', 'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
-- RPC callers cannot poison another user's immutable event reservation.
IF normalized_event_type = 'transcription.completed' AND NOT EXISTS (
SELECT 1 FROM public.history
WHERE id = push_resource_id AND user_id = current_user_id AND status = 'completed'
) THEN
RAISE EXCEPTION 'push_resource_unavailable' USING ERRCODE = 'P0002';
ELSIF normalized_event_type = 'team.invite.created' AND NOT EXISTS (
SELECT 1 FROM public.team_invites
WHERE id = push_resource_id AND invited_by = current_user_id
AND accepted_at IS NULL AND expires_at > now()
) THEN
RAISE EXCEPTION 'push_resource_unavailable' USING ERRCODE = 'P0002';
ELSIF normalized_event_type = 'billing.status.changed' AND NOT EXISTS (
SELECT 1 FROM public.subscriptions
WHERE id = push_resource_id AND user_id = current_user_id
) THEN
RAISE EXCEPTION 'push_resource_unavailable' USING ERRCODE = 'P0002';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73048));
PERFORM pg_advisory_xact_lock(
hashtextextended(normalized_event_type || ':' || push_resource_id::text, 73051)
);
SELECT attempt.* INTO existing_attempt
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND (
normalized_event_type IN ('transcription.completed', 'team.invite.created')
OR attempt.status IN ('pending', 'processing')
OR attempt.created_at > now() - interval '30 seconds'
)
ORDER BY
CASE WHEN attempt.status IN ('pending', 'processing') THEN 0 ELSE 1 END,
attempt.created_at DESC
LIMIT 1
FOR UPDATE;
IF existing_attempt.id IS NOT NULL THEN
IF existing_attempt.status IN ('succeeded', 'partial', 'failed') THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', existing_attempt.status
);
END IF;
IF existing_attempt.lease_expires_at IS NOT NULL
AND existing_attempt.lease_expires_at > now() THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', 'processing'
);
END IF;
IF existing_attempt.next_retry_at IS NOT NULL
AND existing_attempt.next_retry_at > now() THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', 'pending',
'next_retry_at', existing_attempt.next_retry_at
);
END IF;
UPDATE public.push_dispatch_attempts
SET status = 'processing',
attempt_count = attempt_count + 1,
dispatch_lease_token = new_lease_token,
lease_expires_at = now() + interval '2 minutes',
next_retry_at = NULL,
completed_at = NULL,
last_error_code = NULL,
updated_at = now()
WHERE id = existing_attempt.id
RETURNING * INTO existing_attempt;
RETURN jsonb_build_object(
'reserved', true, 'duplicate', false,
'attempt_id', existing_attempt.id, 'status', existing_attempt.status,
'dispatch_lease_token', new_lease_token
);
END IF;
IF (
SELECT count(*) FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'user' AND attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '1 minute'
) >= 10 OR (
SELECT count(*) FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'user' AND attempt.caller_id = current_user_id
AND attempt.created_at > now() - interval '24 hours'
) >= 100 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id, status, attempt_count,
dispatch_lease_token, lease_expires_at
) VALUES (
current_user_id, 'user', normalized_event_type, push_resource_id,
'processing', 1, new_lease_token, now() + interval '2 minutes'
) RETURNING * INTO created_attempt;
RETURN jsonb_build_object(
'reserved', true, 'duplicate', false,
'attempt_id', created_attempt.id, 'status', created_attempt.status,
'dispatch_lease_token', new_lease_token
);
END;
$$;
CREATE OR REPLACE FUNCTION public.reserve_system_push_dispatch(
push_event_type text,
push_resource_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
normalized_event_type text := lower(btrim(push_event_type));
existing_attempt public.push_dispatch_attempts;
created_attempt public.push_dispatch_attempts;
new_lease_token uuid := gen_random_uuid();
target_user_id uuid;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF push_resource_id IS NULL OR normalized_event_type NOT IN (
'transcription.completed', 'team.invite.created', 'billing.status.changed'
) THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
target_user_id := public.push_dispatch_target_user(normalized_event_type, push_resource_id);
IF target_user_id IS NULL THEN
RAISE EXCEPTION 'push_resource_unavailable' USING ERRCODE = 'P0002';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('system-push-global', 73050));
PERFORM pg_advisory_xact_lock(
hashtextextended(normalized_event_type || ':' || push_resource_id::text, 73051)
);
SELECT attempt.* INTO existing_attempt
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.event_type = normalized_event_type
AND attempt.resource_id = push_resource_id
AND (
normalized_event_type IN ('transcription.completed', 'team.invite.created')
OR attempt.status IN ('pending', 'processing')
OR attempt.created_at > now() - interval '30 seconds'
)
ORDER BY
CASE WHEN attempt.status IN ('pending', 'processing') THEN 0 ELSE 1 END,
attempt.created_at DESC
LIMIT 1
FOR UPDATE;
IF existing_attempt.id IS NOT NULL THEN
IF existing_attempt.status IN ('succeeded', 'partial', 'failed') THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', existing_attempt.status
);
END IF;
IF existing_attempt.lease_expires_at IS NOT NULL
AND existing_attempt.lease_expires_at > now() THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', 'processing'
);
END IF;
IF existing_attempt.next_retry_at IS NOT NULL
AND existing_attempt.next_retry_at > now() THEN
RETURN jsonb_build_object(
'reserved', false, 'duplicate', true,
'attempt_id', existing_attempt.id, 'status', 'pending',
'next_retry_at', existing_attempt.next_retry_at
);
END IF;
UPDATE public.push_dispatch_attempts
SET status = 'processing',
attempt_count = attempt_count + 1,
dispatch_lease_token = new_lease_token,
lease_expires_at = now() + interval '2 minutes',
next_retry_at = NULL,
completed_at = NULL,
last_error_code = NULL,
updated_at = now()
WHERE id = existing_attempt.id
RETURNING * INTO existing_attempt;
RETURN jsonb_build_object(
'reserved', true, 'duplicate', false,
'attempt_id', existing_attempt.id, 'status', existing_attempt.status,
'dispatch_lease_token', new_lease_token
);
END IF;
IF (
SELECT count(*) FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND attempt.created_at > now() - interval '1 minute'
) >= 500 OR (
SELECT count(*) FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND attempt.created_at > now() - interval '24 hours'
) >= 10000 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id, status, attempt_count,
dispatch_lease_token, lease_expires_at
) VALUES (
NULL, 'system', normalized_event_type, push_resource_id,
'processing', 1, new_lease_token, now() + interval '2 minutes'
) RETURNING * INTO created_attempt;
RETURN jsonb_build_object(
'reserved', true, 'duplicate', false,
'attempt_id', created_attempt.id, 'status', created_attempt.status,
'dispatch_lease_token', new_lease_token
);
END;
$$;
CREATE OR REPLACE FUNCTION public.lease_push_deliveries(
target_attempt_id uuid,
target_dispatch_lease_token uuid
)
RETURNS TABLE (
delivery_id uuid,
delivery_lease_token uuid,
push_token_id uuid,
provider text,
registration_id text,
last_registered_at timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
dispatch public.push_dispatch_attempts;
resolved_target_user_id uuid;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
SELECT * INTO dispatch FROM public.push_dispatch_attempts
WHERE id = target_attempt_id FOR UPDATE;
IF dispatch.id IS NULL THEN
RAISE EXCEPTION 'push_dispatch_not_found' USING ERRCODE = 'P0002';
END IF;
IF dispatch.status IN ('succeeded', 'partial', 'failed') THEN
RETURN;
END IF;
IF target_dispatch_lease_token IS NULL
OR dispatch.dispatch_lease_token IS DISTINCT FROM target_dispatch_lease_token
OR dispatch.lease_expires_at IS NULL
OR dispatch.lease_expires_at <= now() THEN
RAISE EXCEPTION 'push_dispatch_lease_conflict' USING ERRCODE = '40001';
END IF;
resolved_target_user_id := public.push_dispatch_target_user(dispatch.event_type, dispatch.resource_id);
IF resolved_target_user_id IS NULL THEN
UPDATE public.push_dispatch_attempts
SET status = 'succeeded', completed_at = now(), last_error_code = 'no_targets',
dispatch_lease_token = NULL, lease_expires_at = NULL,
next_retry_at = NULL, updated_at = now()
WHERE id = dispatch.id;
RETURN;
END IF;
-- Refresh the delivery snapshot without duplicating raw registration ids.
INSERT INTO public.push_deliveries (
attempt_id, push_token_id, target_user_id, provider, token_fingerprint
)
SELECT
dispatch.id,
token.id,
resolved_target_user_id,
token.provider,
encode(extensions.digest(token.token, 'sha256'), 'hex')
FROM public.push_tokens AS token
JOIN public.devices AS device
ON device.id = token.device_id
AND device.user_id = token.user_id
AND device.revoked_at IS NULL
WHERE token.user_id = resolved_target_user_id
AND token.last_registered_at >= now() - interval '35 days'
ON CONFLICT ON CONSTRAINT push_deliveries_attempt_id_push_token_id_key DO NOTHING;
-- Missing, revoked, or old registrations are terminal neutral outcomes.
UPDATE public.push_deliveries AS delivery
SET status = 'stale', completed_at = now(), last_error_code = 'registration_stale',
delivery_lease_token = NULL, lease_expires_at = NULL,
next_retry_at = NULL, updated_at = now()
WHERE delivery.attempt_id = dispatch.id
AND delivery.status IN ('pending', 'leased', 'retryable')
AND NOT EXISTS (
SELECT 1
FROM public.push_tokens AS token
JOIN public.devices AS device
ON device.id = token.device_id
AND device.user_id = token.user_id
AND device.revoked_at IS NULL
WHERE token.id = delivery.push_token_id
AND token.user_id = resolved_target_user_id
AND token.last_registered_at >= now() - interval '35 days'
AND encode(extensions.digest(token.token, 'sha256'), 'hex') = delivery.token_fingerprint
);
DELETE FROM public.push_tokens AS token
WHERE token.user_id = resolved_target_user_id
AND token.last_registered_at < now() - interval '35 days';
UPDATE public.push_deliveries
SET status = CASE WHEN attempt_count >= 5 THEN 'permanent_failed' ELSE 'retryable' END,
next_retry_at = CASE
WHEN attempt_count >= 5 THEN NULL
ELSE now() + make_interval(secs => least(3600, (30 * power(2, greatest(0, attempt_count - 1)))::integer))
END,
completed_at = CASE WHEN attempt_count >= 5 THEN now() ELSE NULL END,
last_error_code = CASE WHEN attempt_count >= 5 THEN 'delivery_attempts_exhausted' ELSE 'delivery_lease_expired' END,
delivery_lease_token = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE attempt_id = dispatch.id AND status = 'leased' AND lease_expires_at <= now();
UPDATE public.push_deliveries
SET status = 'permanent_failed', completed_at = now(), next_retry_at = NULL,
last_error_code = 'delivery_attempts_exhausted', updated_at = now()
WHERE attempt_id = dispatch.id AND status = 'retryable' AND attempt_count >= 5;
IF NOT EXISTS (
SELECT 1 FROM public.push_deliveries WHERE attempt_id = dispatch.id
) THEN
UPDATE public.push_dispatch_attempts
SET status = 'succeeded', completed_at = now(), last_error_code = 'no_targets',
dispatch_lease_token = NULL, lease_expires_at = NULL,
next_retry_at = NULL, updated_at = now()
WHERE id = dispatch.id;
RETURN;
END IF;
RETURN QUERY
WITH candidates AS (
SELECT delivery.id
FROM public.push_deliveries AS delivery
WHERE delivery.attempt_id = dispatch.id
AND (
delivery.status = 'pending'
OR (delivery.status = 'retryable' AND coalesce(delivery.next_retry_at, now()) <= now())
)
AND delivery.attempt_count < 5
ORDER BY delivery.created_at, delivery.id
FOR UPDATE SKIP LOCKED
LIMIT 100
), leased AS (
UPDATE public.push_deliveries AS delivery
SET status = 'leased',
attempt_count = delivery.attempt_count + 1,
delivery_lease_token = gen_random_uuid(),
lease_expires_at = now() + interval '60 seconds',
next_retry_at = NULL,
completed_at = NULL,
last_error_code = NULL,
updated_at = now()
FROM candidates
WHERE delivery.id = candidates.id
RETURNING delivery.*
)
SELECT
leased.id,
leased.delivery_lease_token,
leased.push_token_id,
leased.provider,
token.token,
token.last_registered_at
FROM leased
JOIN public.push_tokens AS token
ON token.id = leased.push_token_id
AND token.user_id = leased.target_user_id
AND encode(extensions.digest(token.token, 'sha256'), 'hex') = leased.token_fingerprint
ORDER BY leased.created_at, leased.id;
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_push_delivery(
target_delivery_id uuid,
target_delivery_lease_token uuid,
delivery_outcome text,
delivery_error_code text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
delivery public.push_deliveries;
normalized_outcome text := lower(btrim(delivery_outcome));
normalized_error text := nullif(lower(btrim(delivery_error_code)), '');
next_status text;
retry_at timestamptz;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF normalized_outcome NOT IN (
'delivered', 'stale', 'retryable_failure', 'permanent_failure'
) OR (normalized_error IS NOT NULL AND normalized_error !~ '^[a-z0-9_]{1,64}$') THEN
RAISE EXCEPTION 'invalid_delivery_outcome' USING ERRCODE = '22023';
END IF;
IF normalized_outcome IN ('retryable_failure', 'permanent_failure')
AND normalized_error IS NULL THEN
RAISE EXCEPTION 'delivery_error_code_required' USING ERRCODE = '22023';
END IF;
SELECT * INTO delivery FROM public.push_deliveries
WHERE id = target_delivery_id FOR UPDATE;
IF delivery.id IS NULL THEN
RAISE EXCEPTION 'push_delivery_not_found' USING ERRCODE = 'P0002';
END IF;
IF delivery.status <> 'leased'
OR target_delivery_lease_token IS NULL
OR delivery.delivery_lease_token IS DISTINCT FROM target_delivery_lease_token
OR delivery.lease_expires_at IS NULL
OR delivery.lease_expires_at <= now() THEN
RAISE EXCEPTION 'push_delivery_lease_conflict' USING ERRCODE = '40001';
END IF;
IF normalized_outcome = 'delivered' THEN
next_status := 'delivered';
ELSIF normalized_outcome = 'stale' THEN
next_status := 'stale';
DELETE FROM public.push_tokens AS token
WHERE token.id = delivery.push_token_id
AND encode(extensions.digest(token.token, 'sha256'), 'hex') = delivery.token_fingerprint;
ELSIF normalized_outcome = 'retryable_failure' AND delivery.attempt_count < 5 THEN
next_status := 'retryable';
retry_at := now() + make_interval(
secs => least(3600, (30 * power(2, greatest(0, delivery.attempt_count - 1)))::integer)
);
ELSE
next_status := 'permanent_failed';
END IF;
UPDATE public.push_deliveries
SET status = next_status,
next_retry_at = retry_at,
last_error_code = CASE
WHEN next_status = 'delivered' THEN NULL
WHEN next_status = 'stale' THEN coalesce(normalized_error, 'registration_stale')
ELSE normalized_error
END,
completed_at = CASE
WHEN next_status IN ('delivered', 'stale', 'permanent_failed') THEN now()
ELSE NULL
END,
delivery_lease_token = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE id = delivery.id;
RETURN jsonb_build_object(
'delivery_id', delivery.id,
'status', next_status,
'next_retry_at', retry_at
);
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_push_dispatch(
target_attempt_id uuid,
target_dispatch_lease_token uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
dispatch public.push_dispatch_attempts;
delivered_count integer;
stale_count integer;
retryable_count integer;
permanent_count integer;
leased_count integer;
pending_count integer;
next_retry timestamptz;
final_status text;
is_complete boolean;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
SELECT * INTO dispatch FROM public.push_dispatch_attempts
WHERE id = target_attempt_id FOR UPDATE;
IF dispatch.id IS NULL THEN
RAISE EXCEPTION 'push_dispatch_not_found' USING ERRCODE = 'P0002';
END IF;
IF dispatch.status IN ('succeeded', 'partial', 'failed') THEN
RETURN jsonb_build_object(
'attempt_id', dispatch.id, 'status', dispatch.status, 'complete', true
);
END IF;
IF target_dispatch_lease_token IS NULL
OR dispatch.dispatch_lease_token IS DISTINCT FROM target_dispatch_lease_token
OR dispatch.lease_expires_at IS NULL
OR dispatch.lease_expires_at <= now() THEN
RAISE EXCEPTION 'push_dispatch_lease_conflict' USING ERRCODE = '40001';
END IF;
UPDATE public.push_deliveries
SET status = CASE WHEN attempt_count >= 5 THEN 'permanent_failed' ELSE 'retryable' END,
next_retry_at = CASE
WHEN attempt_count >= 5 THEN NULL
ELSE now() + make_interval(secs => least(3600, (30 * power(2, greatest(0, attempt_count - 1)))::integer))
END,
completed_at = CASE WHEN attempt_count >= 5 THEN now() ELSE NULL END,
last_error_code = CASE WHEN attempt_count >= 5 THEN 'delivery_attempts_exhausted' ELSE 'delivery_lease_expired' END,
delivery_lease_token = NULL, lease_expires_at = NULL, updated_at = now()
WHERE attempt_id = dispatch.id AND status = 'leased' AND lease_expires_at <= now();
SELECT
count(*) FILTER (WHERE status = 'delivered'),
count(*) FILTER (WHERE status = 'stale'),
count(*) FILTER (WHERE status = 'retryable'),
count(*) FILTER (WHERE status = 'permanent_failed'),
count(*) FILTER (WHERE status = 'leased'),
count(*) FILTER (WHERE status = 'pending'),
min(CASE
WHEN status = 'pending' THEN now()
WHEN status = 'retryable' THEN coalesce(next_retry_at, now())
WHEN status = 'leased' THEN lease_expires_at
END)
INTO delivered_count, stale_count, retryable_count, permanent_count,
leased_count, pending_count, next_retry
FROM public.push_deliveries
WHERE attempt_id = dispatch.id;
IF retryable_count + leased_count + pending_count > 0 THEN
final_status := CASE WHEN leased_count > 0 THEN 'processing' ELSE 'pending' END;
is_complete := false;
UPDATE public.push_dispatch_attempts
SET status = final_status,
dispatch_lease_token = NULL,
lease_expires_at = NULL,
next_retry_at = coalesce(next_retry, now()),
completed_at = NULL,
last_error_code = CASE WHEN retryable_count > 0 THEN 'delivery_retry_pending' ELSE NULL END,
updated_at = now()
WHERE id = dispatch.id;
ELSE
is_complete := true;
final_status := CASE
WHEN delivered_count > 0 AND permanent_count = 0 THEN 'succeeded'
WHEN delivered_count > 0 AND permanent_count > 0 THEN 'partial'
WHEN delivered_count = 0 AND permanent_count > 0 THEN 'failed'
ELSE 'succeeded'
END;
UPDATE public.push_dispatch_attempts
SET status = final_status,
dispatch_lease_token = NULL,
lease_expires_at = NULL,
next_retry_at = NULL,
completed_at = now(),
last_error_code = CASE
WHEN final_status = 'partial' THEN 'delivery_partial_failure'
WHEN final_status = 'failed' THEN 'delivery_failed'
WHEN delivered_count = 0 THEN 'no_targets'
ELSE NULL
END,
updated_at = now()
WHERE id = dispatch.id;
END IF;
RETURN jsonb_build_object(
'attempt_id', dispatch.id,
'status', final_status,
'complete', is_complete,
'delivered', delivered_count,
'stale', stale_count,
'retryable_failed', retryable_count,
'permanent_failed', permanent_count,
'next_retry_at', next_retry
);
END;
$$;
CREATE OR REPLACE FUNCTION public.claim_due_push_dispatches(batch_limit integer DEFAULT 50)
RETURNS TABLE (
attempt_id uuid,
event_type text,
resource_id uuid,
dispatch_lease_token uuid
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF batch_limit IS NULL OR batch_limit NOT BETWEEN 1 AND 100 THEN
RAISE EXCEPTION 'invalid_batch_limit' USING ERRCODE = '22023';
END IF;
RETURN QUERY
WITH due AS (
SELECT attempt.id
FROM public.push_dispatch_attempts AS attempt
WHERE (
attempt.status = 'pending' AND coalesce(attempt.next_retry_at, attempt.created_at) <= now()
) OR (
attempt.status = 'processing' AND attempt.lease_expires_at <= now()
)
ORDER BY coalesce(attempt.next_retry_at, attempt.created_at), attempt.id
FOR UPDATE SKIP LOCKED
LIMIT batch_limit
), claimed AS (
UPDATE public.push_dispatch_attempts AS attempt
SET status = 'processing',
attempt_count = attempt.attempt_count + 1,
dispatch_lease_token = gen_random_uuid(),
lease_expires_at = now() + interval '2 minutes',
next_retry_at = NULL,
updated_at = now()
FROM due
WHERE attempt.id = due.id
RETURNING attempt.*
)
SELECT claimed.id, claimed.event_type, claimed.resource_id, claimed.dispatch_lease_token
FROM claimed
ORDER BY claimed.created_at, claimed.id;
END;
$$;
REVOKE ALL ON FUNCTION public.push_dispatch_target_user(text, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.lease_push_deliveries(uuid, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_push_delivery(uuid, uuid, text, text)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_push_dispatch(uuid, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.claim_due_push_dispatches(integer)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.push_dispatch_target_user(text, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.lease_push_deliveries(uuid, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_push_delivery(uuid, uuid, text, text) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_push_dispatch(uuid, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.claim_due_push_dispatches(integer) TO service_role;
COMMIT;

View file

@ -0,0 +1,166 @@
BEGIN;
-- Business state and notification enqueue must commit together. Edge calls
-- made after a commit can be lost if the worker crashes between the two.
CREATE OR REPLACE FUNCTION public.enqueue_immutable_push_event(
queued_event_type text,
queued_resource_id uuid
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF queued_event_type NOT IN ('transcription.completed', 'team.invite.created')
OR queued_resource_id IS NULL THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id, status,
attempt_count, next_retry_at
) VALUES (
NULL, 'system', queued_event_type, queued_resource_id, 'pending',
0, now()
)
ON CONFLICT DO NOTHING;
END;
$$;
CREATE OR REPLACE FUNCTION public.enqueue_billing_push_event(
queued_subscription_id uuid
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF queued_subscription_id IS NULL THEN
RAISE EXCEPTION 'invalid_push_event' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(
hashtextextended('billing.status.changed:' || queued_subscription_id::text, 73052)
);
IF EXISTS (
SELECT 1 FROM public.push_dispatch_attempts
WHERE event_type = 'billing.status.changed'
AND resource_id = queued_subscription_id
AND created_at > now() - interval '30 seconds'
) THEN
RETURN;
END IF;
INSERT INTO public.push_dispatch_attempts (
caller_id, actor_kind, event_type, resource_id, status,
attempt_count, next_retry_at
) VALUES (
NULL, 'system', 'billing.status.changed', queued_subscription_id,
'pending', 0, now()
);
END;
$$;
CREATE OR REPLACE FUNCTION public.enqueue_processing_job_push()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.kind = 'transcription'
AND NEW.status = 'succeeded'
AND NEW.history_id IS NOT NULL
AND OLD.status IS DISTINCT FROM 'succeeded'
AND EXISTS (
SELECT 1 FROM public.history
WHERE id = NEW.history_id AND user_id = NEW.user_id AND status = 'completed'
) THEN
PERFORM public.enqueue_immutable_push_event(
'transcription.completed', NEW.history_id
);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.enqueue_history_after_worker_push()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.status = 'completed'
AND OLD.status IS DISTINCT FROM 'completed'
AND EXISTS (
SELECT 1 FROM public.processing_jobs
WHERE history_id = NEW.id
AND user_id = NEW.user_id
AND kind = 'transcription'
AND status = 'succeeded'
) THEN
PERFORM public.enqueue_immutable_push_event(
'transcription.completed', NEW.id
);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.enqueue_team_invite_push()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
PERFORM public.enqueue_immutable_push_event('team.invite.created', NEW.id);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.enqueue_subscription_push()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF ROW(
OLD.tier, OLD.status, OLD.provider, OLD.current_period_end,
OLD.cancel_at, OLD.auto_renewing
) IS DISTINCT FROM ROW(
NEW.tier, NEW.status, NEW.provider, NEW.current_period_end,
NEW.cancel_at, NEW.auto_renewing
) THEN
PERFORM public.enqueue_billing_push_event(NEW.id);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enqueue_processing_job_push_after_success
AFTER UPDATE OF status ON public.processing_jobs
FOR EACH ROW EXECUTE FUNCTION public.enqueue_processing_job_push();
CREATE TRIGGER enqueue_history_push_after_worker_success
AFTER UPDATE OF status ON public.history
FOR EACH ROW EXECUTE FUNCTION public.enqueue_history_after_worker_push();
CREATE TRIGGER enqueue_team_invite_push_after_insert
AFTER INSERT ON public.team_invites
FOR EACH ROW EXECUTE FUNCTION public.enqueue_team_invite_push();
CREATE TRIGGER enqueue_subscription_push_after_change
AFTER UPDATE OF tier, status, provider, current_period_end, cancel_at, auto_renewing
ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.enqueue_subscription_push();
REVOKE ALL ON FUNCTION public.enqueue_immutable_push_event(text, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.enqueue_billing_push_event(uuid)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.enqueue_immutable_push_event(text, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.enqueue_billing_push_event(uuid) TO service_role;
COMMIT;

View file

@ -0,0 +1,181 @@
BEGIN;
-- Transactional producers enqueue pending rows even during provider bursts so
-- business state never rolls back merely because notification capacity is
-- exhausted. Rate limiting therefore belongs to the system dispatch lease,
-- immediately before a worker can read registrations or contact FCM.
CREATE TABLE public.push_dispatch_claim_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
attempt_id uuid NOT NULL,
dispatch_lease_token uuid NOT NULL UNIQUE,
claimed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_push_dispatch_claim_events_claimed_at
ON public.push_dispatch_claim_events(claimed_at);
ALTER TABLE public.push_dispatch_claim_events ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.push_dispatch_claim_events
FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.push_dispatch_claim_events TO service_role;
CREATE OR REPLACE FUNCTION public.enforce_system_push_claim_rate()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.actor_kind <> 'system'
OR NEW.status <> 'processing'
OR NEW.dispatch_lease_token IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.dispatch_lease_token IS NOT DISTINCT FROM NEW.dispatch_lease_token THEN
RETURN NEW;
END IF;
-- The same lock is acquired by reserve_system_push_dispatch and the due
-- worker. Reusing it keeps direct dispatch and drain claims in one budget.
PERFORM pg_advisory_xact_lock(hashtextextended('system-push-global', 73050));
DELETE FROM public.push_dispatch_claim_events
WHERE claimed_at < now() - interval '2 days';
IF (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '1 minute'
) >= 500 OR (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '24 hours'
) >= 10000 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_claim_events (
attempt_id, dispatch_lease_token
) VALUES (
NEW.id, NEW.dispatch_lease_token
);
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_system_push_claim_rate_on_insert
BEFORE INSERT ON public.push_dispatch_attempts
FOR EACH ROW EXECUTE FUNCTION public.enforce_system_push_claim_rate();
CREATE TRIGGER enforce_system_push_claim_rate_on_update
BEFORE UPDATE OF status, dispatch_lease_token ON public.push_dispatch_attempts
FOR EACH ROW EXECUTE FUNCTION public.enforce_system_push_claim_rate();
CREATE OR REPLACE FUNCTION public.claim_due_push_dispatches(batch_limit integer DEFAULT 50)
RETURNS TABLE (
attempt_id uuid,
event_type text,
resource_id uuid,
dispatch_lease_token uuid
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
minute_slots integer;
daily_slots integer;
system_slots integer;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF batch_limit IS NULL OR batch_limit NOT BETWEEN 1 AND 100 THEN
RAISE EXCEPTION 'invalid_batch_limit' USING ERRCODE = '22023';
END IF;
-- Keep capacity calculation, row claims and claim-ledger inserts in the same
-- transaction. At capacity, system rows are not selected or mutated, so
-- their pending status and next_retry_at remain intact for a later drain.
PERFORM pg_advisory_xact_lock(hashtextextended('system-push-global', 73050));
DELETE FROM public.push_dispatch_claim_events
WHERE claimed_at < now() - interval '2 days';
SELECT greatest(0, 500 - count(*))::integer
INTO minute_slots
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '1 minute';
SELECT greatest(0, 10000 - count(*))::integer
INTO daily_slots
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '24 hours';
system_slots := least(batch_limit, minute_slots, daily_slots);
RETURN QUERY
WITH user_due AS (
SELECT
attempt.id,
coalesce(attempt.next_retry_at, attempt.created_at) AS due_at
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'user'
AND (
(attempt.status = 'pending'
AND coalesce(attempt.next_retry_at, attempt.created_at) <= now())
OR (attempt.status = 'processing' AND attempt.lease_expires_at <= now())
)
ORDER BY due_at, attempt.id
FOR UPDATE SKIP LOCKED
LIMIT batch_limit
), system_due AS (
SELECT
attempt.id,
coalesce(attempt.next_retry_at, attempt.created_at) AS due_at
FROM public.push_dispatch_attempts AS attempt
WHERE attempt.actor_kind = 'system'
AND (
(attempt.status = 'pending'
AND coalesce(attempt.next_retry_at, attempt.created_at) <= now())
OR (attempt.status = 'processing' AND attempt.lease_expires_at <= now())
)
ORDER BY due_at, attempt.id
FOR UPDATE SKIP LOCKED
LIMIT system_slots
), due AS (
SELECT candidate.id
FROM (
SELECT * FROM user_due
UNION ALL
SELECT * FROM system_due
) AS candidate
ORDER BY candidate.due_at, candidate.id
LIMIT batch_limit
), claimed AS (
UPDATE public.push_dispatch_attempts AS attempt
SET status = 'processing',
attempt_count = attempt.attempt_count + 1,
dispatch_lease_token = gen_random_uuid(),
lease_expires_at = now() + interval '2 minutes',
next_retry_at = NULL,
updated_at = now()
FROM due
WHERE attempt.id = due.id
RETURNING attempt.*
)
SELECT claimed.id, claimed.event_type, claimed.resource_id,
claimed.dispatch_lease_token
FROM claimed
ORDER BY claimed.created_at, claimed.id;
END;
$$;
REVOKE ALL ON FUNCTION public.enforce_system_push_claim_rate()
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.claim_due_push_dispatches(integer)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.claim_due_push_dispatches(integer)
TO service_role;
COMMIT;

View file

@ -0,0 +1,58 @@
BEGIN;
-- A user-authorized immediate dispatch can encounter a transactionally
-- enqueued system attempt. That path already owns the event/attempt row before
-- this trigger runs, while the drain owns the global rate lock before rows.
-- Never wait for the global lock here: an immediate serialization failure
-- rolls the user statement back and leaves the durable pending row for drain.
CREATE OR REPLACE FUNCTION public.enforce_system_push_claim_rate()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.actor_kind <> 'system'
OR NEW.status <> 'processing'
OR NEW.dispatch_lease_token IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.dispatch_lease_token IS NOT DISTINCT FROM NEW.dispatch_lease_token THEN
RETURN NEW;
END IF;
IF NOT pg_try_advisory_xact_lock(
hashtextextended('system-push-global', 73050)
) THEN
RAISE EXCEPTION 'push_claim_busy' USING ERRCODE = '40001';
END IF;
DELETE FROM public.push_dispatch_claim_events
WHERE claimed_at < now() - interval '2 days';
IF (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '1 minute'
) >= 500 OR (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '24 hours'
) >= 10000 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_claim_events (
attempt_id, dispatch_lease_token
) VALUES (
NEW.id, NEW.dispatch_lease_token
);
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.enforce_system_push_claim_rate()
FROM PUBLIC, anon, authenticated;
COMMIT;

View file

@ -0,0 +1,57 @@
BEGIN;
-- PostgREST versions backed by hasql-transaction automatically retry SQLSTATE
-- 40001. A contended try-lock would therefore busy-loop until the lock holder
-- exited instead of reaching Edge as an immediate conflict. 55P03 expresses
-- the same lock-not-available condition without transaction auto-retry.
CREATE OR REPLACE FUNCTION public.enforce_system_push_claim_rate()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.actor_kind <> 'system'
OR NEW.status <> 'processing'
OR NEW.dispatch_lease_token IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.dispatch_lease_token IS NOT DISTINCT FROM NEW.dispatch_lease_token THEN
RETURN NEW;
END IF;
IF NOT pg_try_advisory_xact_lock(
hashtextextended('system-push-global', 73050)
) THEN
RAISE EXCEPTION 'push_claim_busy' USING ERRCODE = '55P03';
END IF;
DELETE FROM public.push_dispatch_claim_events
WHERE claimed_at < now() - interval '2 days';
IF (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '1 minute'
) >= 500 OR (
SELECT count(*)
FROM public.push_dispatch_claim_events
WHERE claimed_at > now() - interval '24 hours'
) >= 10000 THEN
RAISE EXCEPTION 'push_rate_limited' USING ERRCODE = '54000';
END IF;
INSERT INTO public.push_dispatch_claim_events (
attempt_id, dispatch_lease_token
) VALUES (
NEW.id, NEW.dispatch_lease_token
);
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.enforce_system_push_claim_rate()
FROM PUBLIC, anon, authenticated;
COMMIT;

View file

@ -0,0 +1,50 @@
BEGIN;
-- Workers normally transition queued/running jobs to succeeded, but recovery
-- and import paths may insert an already-succeeded job or attach history_id
-- after the provider result was committed. Every server-owned completion edge
-- must enqueue the same immutable event exactly once.
CREATE OR REPLACE FUNCTION public.enqueue_processing_job_push()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.kind <> 'transcription'
OR NEW.status <> 'succeeded'
OR NEW.history_id IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.status IS NOT DISTINCT FROM 'succeeded'
AND OLD.history_id IS NOT DISTINCT FROM NEW.history_id THEN
RETURN NEW;
END IF;
IF EXISTS (
SELECT 1
FROM public.history
WHERE id = NEW.history_id
AND user_id = NEW.user_id
AND status = 'completed'
) THEN
PERFORM public.enqueue_immutable_push_event(
'transcription.completed', NEW.history_id
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER enqueue_processing_job_push_after_success
ON public.processing_jobs;
CREATE TRIGGER enqueue_processing_job_push_after_success
AFTER INSERT OR UPDATE OF status, history_id ON public.processing_jobs
FOR EACH ROW EXECUTE FUNCTION public.enqueue_processing_job_push();
REVOKE ALL ON FUNCTION public.enqueue_processing_job_push()
FROM PUBLIC, anon, authenticated;
COMMIT;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,396 @@
BEGIN;
-- Mobile capture is client-side, while processing_jobs remains server-owned. These narrowly
-- scoped RPCs validate ownership and make each meeting/audio/job transition atomic.
CREATE OR REPLACE FUNCTION public.mobile_begin_meeting_recording(p_meeting_id uuid)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
UPDATE public.meetings
SET status = 'recording',
started_at = now(),
ended_at = NULL,
duration_ms = NULL,
raw_transcript = NULL,
edited_transcript = NULL,
minutes_markdown = NULL,
minutes_json = NULL,
stt_model = NULL,
llm_model = NULL,
stt_latency_ms = NULL,
llm_latency_ms = NULL,
error_message = NULL,
audio_storage_key = NULL
WHERE id = p_meeting_id
AND user_id = current_user_id
RETURNING * INTO result;
IF result.id IS NULL THEN
RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501';
END IF;
DELETE FROM public.transcripts WHERE meeting_id = p_meeting_id;
UPDATE public.audio_files
SET meeting_id = NULL
WHERE meeting_id = p_meeting_id
AND user_id = current_user_id;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_begin_meeting_processing(
p_meeting_id uuid,
p_audio_file_id uuid,
p_idempotency_key text
)
RETURNS public.processing_jobs
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
owned_audio public.audio_files;
existing_job public.processing_jobs;
result public.processing_jobs;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF p_idempotency_key IS NULL
OR length(p_idempotency_key) NOT BETWEEN 8 AND 200
OR p_idempotency_key !~ '^[A-Za-z0-9:_-]+$' THEN
RAISE EXCEPTION 'invalid idempotency key' USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.meetings
WHERE id = p_meeting_id AND user_id = current_user_id
) THEN
RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501';
END IF;
SELECT * INTO owned_audio
FROM public.audio_files
WHERE id = p_audio_file_id
AND user_id = current_user_id
AND meeting_id = p_meeting_id
AND history_id IS NULL
AND upload_status = 'uploaded'
FOR UPDATE;
IF owned_audio.id IS NULL THEN
RAISE EXCEPTION 'uploaded meeting audio not found' USING ERRCODE = '22023';
END IF;
SELECT * INTO existing_job
FROM public.processing_jobs
WHERE user_id = current_user_id
AND idempotency_key = p_idempotency_key
FOR UPDATE;
IF existing_job.id IS NOT NULL AND existing_job.status = 'succeeded' THEN
RETURN existing_job;
END IF;
INSERT INTO public.processing_jobs (
user_id, audio_file_id, meeting_id, kind, status, progress,
attempt_count, idempotency_key, error_code, error_message, started_at,
completed_at
) VALUES (
current_user_id, p_audio_file_id, p_meeting_id, 'transcription', 'running', 70,
1, p_idempotency_key, NULL, NULL, now(), NULL
)
ON CONFLICT (user_id, idempotency_key) DO UPDATE
SET audio_file_id = EXCLUDED.audio_file_id,
meeting_id = EXCLUDED.meeting_id,
status = 'running',
progress = 70,
attempt_count = public.processing_jobs.attempt_count + 1,
error_code = NULL,
error_message = NULL,
started_at = now(),
completed_at = NULL
RETURNING * INTO result;
UPDATE public.meetings
SET status = 'processing',
ended_at = COALESCE(ended_at, now()),
duration_ms = owned_audio.duration_ms,
audio_storage_key = owned_audio.storage_key,
error_message = NULL
WHERE id = p_meeting_id AND user_id = current_user_id;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_queue_meeting_recording(
p_meeting_id uuid,
p_duration_ms bigint
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF p_duration_ms < 0 THEN
RAISE EXCEPTION 'invalid recording duration' USING ERRCODE = '22023';
END IF;
UPDATE public.meetings
SET status = 'processing',
ended_at = now(),
duration_ms = p_duration_ms,
error_message = NULL
WHERE id = p_meeting_id
AND user_id = current_user_id
AND status IN ('recording', 'processing')
RETURNING * INTO result;
IF result.id IS NULL THEN
RAISE EXCEPTION 'recordable meeting not found' USING ERRCODE = '22023';
END IF;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_complete_meeting_processing(
p_meeting_id uuid,
p_audio_file_id uuid,
p_idempotency_key text,
p_transcript text,
p_language text,
p_provider text,
p_duration_ms bigint,
p_stt_latency_ms integer
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
owned_audio public.audio_files;
owned_job public.processing_jobs;
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF p_transcript IS NULL OR length(p_transcript) > 1000000
OR p_language IS NULL OR length(p_language) NOT BETWEEN 1 AND 32
OR p_provider IS NULL OR length(p_provider) NOT BETWEEN 1 AND 120
OR p_duration_ms < 0 OR p_stt_latency_ms < 0 THEN
RAISE EXCEPTION 'invalid transcription result' USING ERRCODE = '22023';
END IF;
SELECT * INTO owned_audio
FROM public.audio_files
WHERE id = p_audio_file_id
AND user_id = current_user_id
AND meeting_id = p_meeting_id
AND history_id IS NULL
AND upload_status = 'uploaded'
FOR UPDATE;
IF owned_audio.id IS NULL THEN
RAISE EXCEPTION 'uploaded meeting audio not found' USING ERRCODE = '22023';
END IF;
SELECT * INTO owned_job
FROM public.processing_jobs
WHERE user_id = current_user_id
AND meeting_id = p_meeting_id
AND audio_file_id = p_audio_file_id
AND idempotency_key = p_idempotency_key
AND kind = 'transcription'
FOR UPDATE;
IF owned_job.id IS NULL THEN
RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023';
END IF;
INSERT INTO public.transcripts (
meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker, edited
) VALUES (
p_meeting_id, 0, 0, LEAST(p_duration_ms, 2147483647)::integer,
p_transcript, NULL, false
)
ON CONFLICT (meeting_id, segment_index) DO UPDATE
SET duration_ms = EXCLUDED.duration_ms,
text = EXCLUDED.text,
speaker = NULL,
edited = false,
updated_at = now();
UPDATE public.meetings
SET status = 'completed',
ended_at = COALESCE(ended_at, now()),
duration_ms = p_duration_ms,
raw_transcript = p_transcript,
edited_transcript = NULL,
stt_model = p_provider,
stt_latency_ms = p_stt_latency_ms,
error_message = NULL,
audio_storage_key = owned_audio.storage_key
WHERE id = p_meeting_id AND user_id = current_user_id
RETURNING * INTO result;
UPDATE public.processing_jobs
SET status = 'succeeded',
progress = 100,
error_code = NULL,
error_message = NULL,
result = jsonb_build_object(
'audio_file_id', p_audio_file_id,
'language', p_language,
'provider', p_provider,
'duration_ms', p_duration_ms
),
completed_at = now()
WHERE id = owned_job.id;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_mark_meeting_processing_failure(
p_meeting_id uuid,
p_idempotency_key text,
p_error_code text,
p_error_message text,
p_terminal boolean DEFAULT false
)
RETURNS public.processing_jobs
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
result public.processing_jobs;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF length(COALESCE(p_error_code, '')) NOT BETWEEN 1 AND 64
OR length(COALESCE(p_error_message, '')) NOT BETWEEN 1 AND 500 THEN
RAISE EXCEPTION 'invalid processing failure' USING ERRCODE = '22023';
END IF;
UPDATE public.processing_jobs
SET status = CASE WHEN p_terminal THEN 'failed' ELSE 'queued' END,
progress = CASE WHEN p_terminal THEN progress ELSE LEAST(progress, 69) END,
error_code = p_error_code,
error_message = p_error_message,
completed_at = CASE WHEN p_terminal THEN now() ELSE NULL END
WHERE user_id = current_user_id
AND meeting_id = p_meeting_id
AND idempotency_key = p_idempotency_key
AND kind = 'transcription'
AND status <> 'succeeded'
RETURNING * INTO result;
IF result.id IS NULL THEN
RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023';
END IF;
UPDATE public.meetings
SET status = CASE WHEN p_terminal THEN 'error' ELSE 'processing' END,
error_message = CASE WHEN p_terminal THEN p_error_message ELSE NULL END
WHERE id = p_meeting_id AND user_id = current_user_id;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_cancel_meeting_recording(p_meeting_id uuid)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
UPDATE public.meetings
SET status = 'error',
ended_at = now(),
error_message = 'Recording was cancelled before processing.'
WHERE id = p_meeting_id
AND user_id = current_user_id
AND status = 'recording'
RETURNING * INTO result;
IF result.id IS NULL THEN
RAISE EXCEPTION 'active meeting recording not found' USING ERRCODE = '22023';
END IF;
RETURN result;
END;
$$;
CREATE OR REPLACE FUNCTION public.mobile_fail_meeting_recording(
p_meeting_id uuid,
p_error_message text
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF length(COALESCE(p_error_message, '')) NOT BETWEEN 1 AND 500 THEN
RAISE EXCEPTION 'invalid recording failure' USING ERRCODE = '22023';
END IF;
UPDATE public.meetings
SET status = 'error',
ended_at = COALESCE(ended_at, now()),
error_message = p_error_message
WHERE id = p_meeting_id AND user_id = current_user_id
RETURNING * INTO result;
IF result.id IS NULL THEN
RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501';
END IF;
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.mobile_begin_meeting_recording(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_begin_meeting_processing(uuid, uuid, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_queue_meeting_recording(uuid, bigint) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_complete_meeting_processing(uuid, uuid, text, text, text, text, bigint, integer) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_mark_meeting_processing_failure(uuid, text, text, text, boolean) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_cancel_meeting_recording(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.mobile_fail_meeting_recording(uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_begin_meeting_recording(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_begin_meeting_processing(uuid, uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_queue_meeting_recording(uuid, bigint) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_complete_meeting_processing(uuid, uuid, text, text, text, text, bigint, integer) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_mark_meeting_processing_failure(uuid, text, text, text, boolean) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_cancel_meeting_recording(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.mobile_fail_meeting_recording(uuid, text) TO authenticated;
COMMIT;

View file

@ -0,0 +1,308 @@
-- Atomic, idempotent back-office mutations invoked only by the server-side admin app.
BEGIN;
CREATE TABLE IF NOT EXISTS public.admin_operation_requests (
actor_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
idempotency_key uuid NOT NULL,
operation text NOT NULL CHECK (char_length(operation) BETWEEN 1 AND 80),
request_hash text NOT NULL CHECK (char_length(request_hash) = 64),
response_data jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
PRIMARY KEY (actor_id, idempotency_key)
);
ALTER TABLE public.admin_operation_requests ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.admin_operation_requests FROM PUBLIC, anon, authenticated;
GRANT SELECT, INSERT, UPDATE ON TABLE public.admin_operation_requests TO service_role;
CREATE OR REPLACE FUNCTION public.resolve_external_admin_actor_v1(
p_email text,
p_minimum_role text DEFAULT 'manager'
)
RETURNS TABLE(actor_id uuid, actor_role text)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth
AS $$
DECLARE
minimum_level integer;
actual_level integer;
BEGIN
IF p_email IS NULL OR char_length(trim(p_email)) NOT BETWEEN 3 AND 150 THEN
RAISE EXCEPTION 'invalid_actor_email' USING ERRCODE = '22023';
END IF;
minimum_level := CASE p_minimum_role
WHEN 'manager' THEN 1
WHEN 'admin' THEN 2
WHEN 'super_admin' THEN 3
ELSE NULL
END;
IF minimum_level IS NULL THEN
RAISE EXCEPTION 'invalid_minimum_role' USING ERRCODE = '22023';
END IF;
SELECT profile.id, profile.role
INTO actor_id, actor_role
FROM auth.users AS account
JOIN public.profiles AS profile ON profile.id = account.id
WHERE lower(account.email) = lower(trim(p_email))
LIMIT 1;
IF actor_id IS NULL THEN
RAISE EXCEPTION 'admin_identity_not_linked' USING ERRCODE = '42501';
END IF;
actual_level := CASE actor_role
WHEN 'manager' THEN 1
WHEN 'admin' THEN 2
WHEN 'super_admin' THEN 3
ELSE 0
END;
IF actual_level < minimum_level THEN
RAISE EXCEPTION 'insufficient_admin_role' USING ERRCODE = '42501';
END IF;
RETURN NEXT;
END;
$$;
REVOKE ALL ON FUNCTION public.resolve_external_admin_actor_v1(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.resolve_external_admin_actor_v1(text, text) TO service_role;
CREATE OR REPLACE FUNCTION public.admin_change_user_role_v1(
p_actor_email text,
p_idempotency_key uuid,
p_target_user_id uuid,
p_new_role text,
p_memo text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor record;
before_role text;
request_payload jsonb;
request_digest text;
existing_request public.admin_operation_requests%ROWTYPE;
inserted boolean;
result jsonb;
BEGIN
SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'admin');
IF p_idempotency_key IS NULL OR p_target_user_id IS NULL THEN
RAISE EXCEPTION 'idempotency_key_and_target_required' USING ERRCODE = '22023';
END IF;
IF p_new_role NOT IN ('user', 'manager', 'admin', 'super_admin') THEN
RAISE EXCEPTION 'invalid_role' USING ERRCODE = '22023';
END IF;
IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN
RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023';
END IF;
request_payload := jsonb_build_object('target', p_target_user_id, 'role', p_new_role, 'memo', trim(p_memo));
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash)
VALUES (actor.actor_id, p_idempotency_key, 'user.role_change', request_digest)
ON CONFLICT DO NOTHING
RETURNING true INTO inserted;
IF NOT coalesce(inserted, false) THEN
SELECT * INTO existing_request FROM public.admin_operation_requests
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
IF existing_request.operation <> 'user.role_change' OR existing_request.request_hash <> request_digest THEN
RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023';
END IF;
IF existing_request.response_data IS NULL THEN
RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03';
END IF;
RETURN existing_request.response_data;
END IF;
SELECT role INTO before_role FROM public.profiles WHERE id = p_target_user_id FOR UPDATE;
IF before_role IS NULL THEN
RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002';
END IF;
IF actor.actor_role <> 'super_admin'
AND (before_role IN ('admin', 'super_admin') OR p_new_role IN ('admin', 'super_admin')) THEN
RAISE EXCEPTION 'super_admin_required_for_privileged_role' USING ERRCODE = '42501';
END IF;
IF before_role = 'super_admin' AND p_new_role <> 'super_admin'
AND (SELECT count(*) FROM public.profiles WHERE role = 'super_admin') <= 1 THEN
RAISE EXCEPTION 'cannot_demote_last_super_admin' USING ERRCODE = '23514';
END IF;
UPDATE public.profiles SET role = p_new_role, updated_at = now() WHERE id = p_target_user_id;
UPDATE auth.users
SET raw_app_meta_data = jsonb_set(coalesce(raw_app_meta_data, '{}'::jsonb), '{role}', to_jsonb(p_new_role), true),
updated_at = now()
WHERE id = p_target_user_id;
INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo)
VALUES (actor.actor_id, 'user.role_change', 'profile', p_target_user_id,
jsonb_build_object('role', before_role), jsonb_build_object('role', p_new_role), trim(p_memo));
result := jsonb_build_object('success', true, 'userId', p_target_user_id, 'newRole', p_new_role);
UPDATE public.admin_operation_requests SET response_data = result, completed_at = now()
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_change_user_role_v1(text, uuid, uuid, text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.admin_change_user_role_v1(text, uuid, uuid, text, text) TO service_role;
CREATE OR REPLACE FUNCTION public.admin_mutate_subscription_v1(
p_actor_email text,
p_idempotency_key uuid,
p_action text,
p_user_id uuid,
p_tier text DEFAULT NULL,
p_status text DEFAULT NULL,
p_current_period_end timestamptz DEFAULT NULL,
p_overage_credits integer DEFAULT NULL,
p_admin_note text DEFAULT NULL,
p_memo text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor record;
before_subscription public.subscriptions%ROWTYPE;
after_subscription public.subscriptions%ROWTYPE;
request_payload jsonb;
request_digest text;
existing_request public.admin_operation_requests%ROWTYPE;
inserted boolean;
result jsonb;
before_snapshot jsonb;
after_snapshot jsonb;
BEGIN
SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'manager');
IF p_action NOT IN ('create', 'update', 'delete') OR p_idempotency_key IS NULL OR p_user_id IS NULL THEN
RAISE EXCEPTION 'invalid_subscription_operation' USING ERRCODE = '22023';
END IF;
IF p_action IN ('create', 'delete') AND actor.actor_role = 'manager' THEN
RAISE EXCEPTION 'admin_role_required' USING ERRCODE = '42501';
END IF;
IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN
RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023';
END IF;
IF p_tier IS NOT NULL AND p_tier NOT IN ('free', 'pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_subscription_tier' USING ERRCODE = '22023';
END IF;
IF p_status IS NOT NULL AND p_status NOT IN ('active', 'canceled', 'past_due', 'expired') THEN
RAISE EXCEPTION 'invalid_subscription_status' USING ERRCODE = '22023';
END IF;
IF p_overage_credits IS NOT NULL AND (p_overage_credits < 0 OR p_overage_credits > 1000000) THEN
RAISE EXCEPTION 'invalid_overage_credits' USING ERRCODE = '22023';
END IF;
IF p_admin_note IS NOT NULL AND char_length(p_admin_note) > 2000 THEN
RAISE EXCEPTION 'admin_note_too_long' USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = p_user_id) THEN
RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002';
END IF;
request_payload := jsonb_build_object(
'action', p_action, 'user', p_user_id, 'tier', p_tier, 'status', p_status,
'periodEnd', p_current_period_end, 'credits', p_overage_credits,
'note', p_admin_note, 'memo', trim(p_memo)
);
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash)
VALUES (actor.actor_id, p_idempotency_key, 'subscription.' || p_action, request_digest)
ON CONFLICT DO NOTHING
RETURNING true INTO inserted;
IF NOT coalesce(inserted, false) THEN
SELECT * INTO existing_request FROM public.admin_operation_requests
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
IF existing_request.operation <> ('subscription.' || p_action) OR existing_request.request_hash <> request_digest THEN
RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023';
END IF;
IF existing_request.response_data IS NULL THEN
RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03';
END IF;
RETURN existing_request.response_data;
END IF;
SELECT * INTO before_subscription FROM public.subscriptions WHERE user_id = p_user_id FOR UPDATE;
IF p_action = 'create' THEN
IF before_subscription.id IS NOT NULL THEN
RAISE EXCEPTION 'subscription_already_exists' USING ERRCODE = '23505';
END IF;
IF p_tier IS NULL THEN RAISE EXCEPTION 'tier_required' USING ERRCODE = '22023'; END IF;
INSERT INTO public.subscriptions(user_id, tier, status, provider, payment_provider,
current_period_start, current_period_end, overage_credits, admin_note, created_at, updated_at)
VALUES (p_user_id, p_tier, coalesce(p_status, 'active'), 'none', 'none', now(),
p_current_period_end, coalesce(p_overage_credits, 0), p_admin_note, now(), now())
RETURNING * INTO after_subscription;
ELSIF p_action = 'update' THEN
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
UPDATE public.subscriptions SET
tier = coalesce(p_tier, tier),
status = coalesce(p_status, status),
current_period_end = CASE WHEN p_current_period_end IS NULL THEN current_period_end ELSE p_current_period_end END,
overage_credits = coalesce(p_overage_credits, overage_credits),
admin_note = coalesce(p_admin_note, admin_note),
updated_at = now()
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
ELSE
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
UPDATE public.subscriptions SET tier = 'free', status = 'expired', cancel_at = now(),
admin_note = '[DELETED] ' || trim(p_memo), updated_at = now()
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
END IF;
UPDATE public.profiles SET tier = after_subscription.tier, updated_at = now() WHERE id = p_user_id;
before_snapshot := CASE WHEN before_subscription.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', before_subscription.id,
'user_id', before_subscription.user_id,
'tier', before_subscription.tier,
'status', before_subscription.status,
'provider', before_subscription.provider,
'payment_provider', before_subscription.payment_provider,
'current_period_start', before_subscription.current_period_start,
'current_period_end', before_subscription.current_period_end,
'overage_credits', before_subscription.overage_credits,
'admin_note', before_subscription.admin_note,
'cancel_at', before_subscription.cancel_at,
'created_at', before_subscription.created_at,
'updated_at', before_subscription.updated_at
) END;
after_snapshot := jsonb_build_object(
'id', after_subscription.id,
'user_id', after_subscription.user_id,
'tier', after_subscription.tier,
'status', after_subscription.status,
'provider', after_subscription.provider,
'payment_provider', after_subscription.payment_provider,
'current_period_start', after_subscription.current_period_start,
'current_period_end', after_subscription.current_period_end,
'overage_credits', after_subscription.overage_credits,
'admin_note', after_subscription.admin_note,
'cancel_at', after_subscription.cancel_at,
'created_at', after_subscription.created_at,
'updated_at', after_subscription.updated_at
);
INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo)
VALUES (actor.actor_id, 'subscription.' || p_action, 'subscription', p_user_id,
before_snapshot, after_snapshot, trim(p_memo));
result := jsonb_build_object('success', true, 'subscription', after_snapshot);
UPDATE public.admin_operation_requests SET response_data = result, completed_at = now()
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) TO service_role;
COMMIT;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,357 @@
-- H-006: atomic meeting creation with explicit attendees, language, and a
-- user-owned meeting-document template. Legacy meetings keep unknown language
-- as NULL; successful recording completion writes the authoritative STT language.
CREATE OR REPLACE FUNCTION public.is_valid_meeting_attendees_v1(p_attendees jsonb)
RETURNS boolean
LANGUAGE plpgsql
IMMUTABLE
SET search_path = pg_catalog
AS $$
DECLARE
attendee jsonb;
BEGIN
IF p_attendees IS NULL
OR jsonb_typeof(p_attendees) <> 'array'
OR jsonb_array_length(p_attendees) > 50 THEN
RETURN false;
END IF;
FOR attendee IN SELECT value FROM jsonb_array_elements(p_attendees)
LOOP
IF jsonb_typeof(attendee) <> 'string'
OR char_length(regexp_replace(trim(attendee #>> '{}'), '[[:space:]]+', ' ', 'g')) NOT BETWEEN 1 AND 120
OR (attendee #>> '{}') ~ '[[:cntrl:]]' THEN
RETURN false;
END IF;
END LOOP;
RETURN (
SELECT count(*) = count(DISTINCT lower(regexp_replace(trim(value #>> '{}'), '[[:space:]]+', ' ', 'g')))
FROM jsonb_array_elements(p_attendees)
);
END;
$$;
REVOKE ALL ON FUNCTION public.is_valid_meeting_attendees_v1(jsonb)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.is_valid_meeting_attendees_v1(jsonb)
TO authenticated, service_role;
ALTER TABLE public.meetings
ADD COLUMN language text,
ADD COLUMN attendees jsonb NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN template_id uuid REFERENCES public.user_templates(id) ON DELETE RESTRICT,
ADD COLUMN creation_idempotency_key uuid,
ADD COLUMN creation_request_hash text,
ADD CONSTRAINT meetings_language_v1 CHECK (
language IS NULL OR (
char_length(language) BETWEEN 2 AND 35
AND language ~ '^[a-z]{2,3}(-[a-z0-9]{2,8})*$'
)
),
ADD CONSTRAINT meetings_attendees_v1 CHECK (
public.is_valid_meeting_attendees_v1(attendees)
),
ADD CONSTRAINT meetings_creation_hash_v1 CHECK (
(creation_idempotency_key IS NULL AND creation_request_hash IS NULL)
OR (
creation_idempotency_key IS NOT NULL
AND creation_request_hash ~ '^[0-9a-f]{64}$'
)
);
CREATE UNIQUE INDEX meetings_creation_idempotency_unique_v1
ON public.meetings(user_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL;
CREATE INDEX meetings_owner_template_v1
ON public.meetings(user_id, template_id)
WHERE template_id IS NOT NULL;
CREATE OR REPLACE FUNCTION public.enforce_meeting_creation_identity_v1()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth
AS $$
DECLARE
actor_id uuid := auth.uid();
rpc_actor text := current_setting('d3ro.meeting_creation_actor', true);
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.creation_idempotency_key IS NOT NULL THEN
IF actor_id IS NULL OR rpc_actor IS DISTINCT FROM actor_id::text THEN
RAISE EXCEPTION 'meeting_creation_rpc_required' USING ERRCODE = '42501';
END IF;
ELSIF NEW.template_id IS NOT NULL
OR NEW.language IS NOT NULL
OR NEW.attendees <> '[]'::jsonb THEN
RAISE EXCEPTION 'meeting_creation_metadata_requires_rpc' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
IF NEW.creation_idempotency_key IS DISTINCT FROM OLD.creation_idempotency_key
OR NEW.creation_request_hash IS DISTINCT FROM OLD.creation_request_hash THEN
RAISE EXCEPTION 'meeting_creation_identity_immutable' USING ERRCODE = '42501';
END IF;
IF OLD.creation_idempotency_key IS NOT NULL
AND NEW.template_id IS DISTINCT FROM OLD.template_id THEN
RAISE EXCEPTION 'meeting_creation_template_immutable' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.enforce_meeting_creation_identity_v1()
FROM PUBLIC, anon, authenticated;
CREATE TRIGGER meetings_creation_identity_v1
BEFORE INSERT OR UPDATE OF creation_idempotency_key, creation_request_hash, template_id
ON public.meetings
FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_creation_identity_v1();
CREATE OR REPLACE FUNCTION public.enforce_meeting_template_owner_v1()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF NEW.template_id IS NULL THEN
RETURN NEW;
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.user_templates
WHERE id = NEW.template_id
AND user_id = NEW.user_id
AND template_kind = 'meeting_document'
) THEN
RAISE EXCEPTION 'meeting_template_owner_mismatch' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.enforce_meeting_template_owner_v1()
FROM PUBLIC, anon, authenticated;
CREATE TRIGGER meetings_template_owner_v1
BEFORE INSERT OR UPDATE OF user_id, template_id
ON public.meetings
FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_template_owner_v1();
CREATE OR REPLACE FUNCTION public.mobile_create_meeting_workspace_v2(
p_title text,
p_attendees jsonb,
p_language text,
p_template_id uuid,
p_idempotency_key uuid
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor_id uuid := auth.uid();
safe_title text := regexp_replace(trim(p_title), '[[:space:]]+', ' ', 'g');
safe_language text := lower(trim(p_language));
safe_attendees jsonb;
request_digest text;
created public.meetings;
BEGIN
IF actor_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF p_idempotency_key IS NULL OR p_template_id IS NULL THEN
RAISE EXCEPTION 'meeting_creation_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_title IS NULL OR char_length(safe_title) NOT BETWEEN 1 AND 160 THEN
RAISE EXCEPTION 'invalid_meeting_title' USING ERRCODE = '22023';
END IF;
IF p_language IS NULL OR safe_language NOT IN ('ko', 'en', 'ja', 'zh-cn') THEN
RAISE EXCEPTION 'invalid_meeting_language' USING ERRCODE = '22023';
END IF;
IF NOT public.is_valid_meeting_attendees_v1(p_attendees) THEN
RAISE EXCEPTION 'invalid_meeting_attendees' USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.user_templates
WHERE id = p_template_id
AND user_id = actor_id
AND template_kind = 'meeting_document'
) THEN
RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM set_config('d3ro.meeting_creation_actor', actor_id::text, true);
SELECT coalesce(
jsonb_agg(
to_jsonb(regexp_replace(trim(item.value #>> '{}'), '[[:space:]]+', ' ', 'g'))
ORDER BY item.ordinality
),
'[]'::jsonb
)
INTO safe_attendees
FROM jsonb_array_elements(p_attendees) WITH ORDINALITY AS item(value, ordinality);
request_digest := encode(extensions.digest(
jsonb_build_object(
'title', safe_title,
'attendees', safe_attendees,
'language', safe_language,
'template_id', p_template_id
)::text,
'sha256'
), 'hex');
INSERT INTO public.meetings(
user_id, title, status, language, attendees, template_id,
creation_idempotency_key, creation_request_hash
)
VALUES (
actor_id, safe_title, 'recording', safe_language, safe_attendees,
p_template_id, p_idempotency_key, request_digest
)
ON CONFLICT (user_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL
DO NOTHING
RETURNING * INTO created;
IF created.id IS NULL THEN
SELECT * INTO created
FROM public.meetings
WHERE user_id = actor_id
AND creation_idempotency_key = p_idempotency_key;
IF created.id IS NULL THEN
RAISE EXCEPTION 'meeting_creation_retry_state_missing' USING ERRCODE = 'PT409';
END IF;
IF created.creation_request_hash <> request_digest THEN
RAISE EXCEPTION 'meeting_creation_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
END IF;
RETURN created;
END;
$$;
REVOKE ALL ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
TO authenticated;
-- Keep legacy/action-created meetings nullable until real STT succeeds, then
-- bind the meeting SSOT to the same validated language stored in the job result.
CREATE OR REPLACE FUNCTION public.mobile_complete_meeting_processing(
p_meeting_id uuid,
p_audio_file_id uuid,
p_idempotency_key text,
p_transcript text,
p_language text,
p_provider text,
p_duration_ms bigint,
p_stt_latency_ms integer
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
safe_language text := lower(trim(p_language));
owned_audio public.audio_files;
owned_job public.processing_jobs;
result public.meetings;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
END IF;
IF p_transcript IS NULL OR length(p_transcript) > 1000000
OR p_language IS NULL
OR char_length(safe_language) NOT BETWEEN 2 AND 35
OR safe_language !~ '^[a-z]{2,3}(-[a-z0-9]{2,8})*$'
OR p_provider IS NULL OR length(p_provider) NOT BETWEEN 1 AND 120
OR p_duration_ms < 0 OR p_stt_latency_ms < 0 THEN
RAISE EXCEPTION 'invalid transcription result' USING ERRCODE = '22023';
END IF;
SELECT * INTO owned_audio
FROM public.audio_files
WHERE id = p_audio_file_id
AND user_id = current_user_id
AND meeting_id = p_meeting_id
AND history_id IS NULL
AND upload_status = 'uploaded'
FOR UPDATE;
IF owned_audio.id IS NULL THEN
RAISE EXCEPTION 'uploaded meeting audio not found' USING ERRCODE = '22023';
END IF;
SELECT * INTO owned_job
FROM public.processing_jobs
WHERE user_id = current_user_id
AND meeting_id = p_meeting_id
AND audio_file_id = p_audio_file_id
AND idempotency_key = p_idempotency_key
AND kind = 'transcription'
FOR UPDATE;
IF owned_job.id IS NULL THEN
RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023';
END IF;
INSERT INTO public.transcripts (
meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker, edited
) VALUES (
p_meeting_id, 0, 0, LEAST(p_duration_ms, 2147483647)::integer,
p_transcript, NULL, false
)
ON CONFLICT (meeting_id, segment_index) DO UPDATE
SET duration_ms = EXCLUDED.duration_ms,
text = EXCLUDED.text,
speaker = NULL,
edited = false,
updated_at = now();
UPDATE public.meetings
SET status = 'completed',
ended_at = COALESCE(ended_at, now()),
duration_ms = p_duration_ms,
raw_transcript = p_transcript,
edited_transcript = NULL,
stt_model = p_provider,
stt_latency_ms = p_stt_latency_ms,
error_message = NULL,
audio_storage_key = owned_audio.storage_key,
language = safe_language
WHERE id = p_meeting_id AND user_id = current_user_id
RETURNING * INTO result;
UPDATE public.processing_jobs
SET status = 'succeeded',
progress = 100,
error_code = NULL,
error_message = NULL,
result = jsonb_build_object(
'audio_file_id', p_audio_file_id,
'language', safe_language,
'provider', p_provider,
'duration_ms', p_duration_ms
),
completed_at = now()
WHERE id = owned_job.id;
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.mobile_complete_meeting_processing(
uuid, uuid, text, text, text, text, bigint, integer
) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_complete_meeting_processing(
uuid, uuid, text, text, text, text, bigint, integer
) TO authenticated;

View file

@ -0,0 +1,283 @@
-- Reserve STT quota before provider work so concurrent requests cannot spend
-- provider capacity beyond the user's allowance. Failed/expired work refunds
-- the exact base or overage unit it reserved.
CREATE TABLE IF NOT EXISTS public.stt_quota_reservations (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature text NOT NULL DEFAULT 'stt_transcribe' CHECK (feature = 'stt_transcribe'),
usage_date date NOT NULL DEFAULT CURRENT_DATE,
consumed_from text NOT NULL CHECK (consumed_from IN ('base', 'overage', 'unlimited')),
status text NOT NULL DEFAULT 'reserved' CHECK (status IN ('reserved', 'completed', 'released')),
tier text NOT NULL,
quota_period text NOT NULL CHECK (quota_period IN ('daily', 'weekly')),
quota_limit integer NOT NULL,
current_count integer NOT NULL,
overage_after integer NOT NULL,
lease_expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
finalized_at timestamptz,
release_reason text
);
ALTER TABLE public.stt_quota_reservations ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.stt_quota_reservations FROM PUBLIC, anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.stt_quota_reservations TO service_role;
CREATE INDEX IF NOT EXISTS idx_stt_quota_reservations_reclaim
ON public.stt_quota_reservations(user_id, status, lease_expires_at)
WHERE status = 'reserved';
CREATE OR REPLACE FUNCTION public.reserve_stt_quota(
p_user_id uuid,
p_reservation_id uuid
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
existing public.stt_quota_reservations%ROWTYPE;
expired public.stt_quota_reservations%ROWTYPE;
subscription_tier text := 'free';
overage integer := 0;
quota_period text;
quota_limit integer;
current_count integer := 0;
new_count integer := 0;
new_overage integer := 0;
consumed_from text;
BEGIN
IF p_user_id IS NULL OR p_reservation_id IS NULL THEN
RAISE EXCEPTION 'invalid_stt_quota_reservation' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':stt_transcribe', 26026));
SELECT * INTO existing
FROM public.stt_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF FOUND THEN
IF existing.user_id <> p_user_id OR existing.feature <> 'stt_transcribe' THEN
RAISE EXCEPTION 'stt_quota_reservation_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN jsonb_build_object(
'allowed', existing.status IN ('reserved', 'completed'),
'reservation_id', existing.id,
'status', existing.status,
'current', existing.current_count,
'limit', existing.quota_limit,
'period', existing.quota_period,
'tier', existing.tier,
'overage_credits', existing.overage_after,
'consumed_from', existing.consumed_from
);
END IF;
-- Reclaim crashed requests before calculating the next allowance.
FOR expired IN
SELECT *
FROM public.stt_quota_reservations
WHERE user_id = p_user_id
AND feature = 'stt_transcribe'
AND status = 'reserved'
AND lease_expires_at <= now()
FOR UPDATE
LOOP
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = expired.user_id
AND date = expired.usage_date
AND feature = expired.feature;
IF expired.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = expired.user_id;
END IF;
UPDATE public.stt_quota_reservations
SET status = 'released', finalized_at = now(), release_reason = 'lease_expired'
WHERE id = expired.id;
END LOOP;
SELECT coalesce(tier, 'free'), coalesce(overage_credits, 0)
INTO subscription_tier, overage
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF NOT FOUND THEN
subscription_tier := 'free';
overage := 0;
END IF;
IF subscription_tier = 'free' THEN
quota_period := 'weekly';
quota_limit := 250;
ELSIF subscription_tier IN ('pro', 'pro_plus', 'team', 'enterprise') THEN
quota_period := 'daily';
quota_limit := -1;
ELSE
quota_period := 'daily';
quota_limit := 0;
END IF;
IF quota_period = 'weekly' THEN
SELECT coalesce(sum(count), 0)::integer INTO current_count
FROM public.daily_usage
WHERE user_id = p_user_id
AND feature = 'stt_transcribe'
AND date >= CURRENT_DATE - 6
AND date <= CURRENT_DATE;
ELSE
SELECT coalesce(count, 0) INTO current_count
FROM public.daily_usage
WHERE user_id = p_user_id
AND feature = 'stt_transcribe'
AND date = CURRENT_DATE;
current_count := coalesce(current_count, 0);
END IF;
IF quota_limit = 0 OR (quota_limit > -1 AND current_count >= quota_limit AND overage <= 0) THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', current_count,
'limit', quota_limit,
'period', quota_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', 'none'
);
END IF;
IF quota_limit = -1 THEN
consumed_from := 'unlimited';
ELSIF current_count < quota_limit THEN
consumed_from := 'base';
ELSE
consumed_from := 'overage';
UPDATE public.subscriptions
SET overage_credits = overage_credits - 1,
updated_at = now()
WHERE user_id = p_user_id
AND overage_credits > 0
RETURNING overage_credits INTO new_overage;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'allowed', false,
'reservation_id', NULL,
'status', 'denied',
'current', current_count,
'limit', quota_limit,
'period', quota_period,
'tier', subscription_tier,
'overage_credits', 0,
'consumed_from', 'none'
);
END IF;
overage := new_overage;
END IF;
INSERT INTO public.daily_usage(user_id, date, feature, count)
VALUES (p_user_id, CURRENT_DATE, 'stt_transcribe', 1)
ON CONFLICT (user_id, date, feature)
DO UPDATE SET count = public.daily_usage.count + 1
RETURNING count INTO new_count;
current_count := current_count + 1;
INSERT INTO public.stt_quota_reservations(
id, user_id, consumed_from, tier, quota_period, quota_limit,
current_count, overage_after, lease_expires_at
) VALUES (
p_reservation_id, p_user_id, consumed_from, subscription_tier, quota_period, quota_limit,
current_count, overage, now() + interval '10 minutes'
);
RETURN jsonb_build_object(
'allowed', true,
'reservation_id', p_reservation_id,
'status', 'reserved',
'current', current_count,
'limit', quota_limit,
'period', quota_period,
'tier', subscription_tier,
'overage_credits', overage,
'consumed_from', consumed_from
);
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_stt_quota(
p_reservation_id uuid,
p_succeeded boolean
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
reservation public.stt_quota_reservations%ROWTYPE;
final_status text;
BEGIN
IF p_reservation_id IS NULL OR p_succeeded IS NULL THEN
RAISE EXCEPTION 'invalid_stt_quota_finalize' USING ERRCODE = '22023';
END IF;
SELECT * INTO reservation
FROM public.stt_quota_reservations
WHERE id = p_reservation_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'stt_quota_reservation_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(reservation.user_id::text || ':stt_transcribe', 26026));
SELECT * INTO reservation
FROM public.stt_quota_reservations
WHERE id = p_reservation_id
FOR UPDATE;
IF reservation.status <> 'reserved' THEN
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', reservation.status);
END IF;
IF p_succeeded THEN
final_status := 'completed';
ELSE
UPDATE public.daily_usage
SET count = greatest(count - 1, 0)
WHERE user_id = reservation.user_id
AND date = reservation.usage_date
AND feature = reservation.feature;
IF reservation.consumed_from = 'overage' THEN
UPDATE public.subscriptions
SET overage_credits = overage_credits + 1,
updated_at = now()
WHERE user_id = reservation.user_id;
END IF;
final_status := 'released';
END IF;
UPDATE public.stt_quota_reservations
SET status = final_status,
finalized_at = now(),
release_reason = CASE WHEN p_succeeded THEN NULL ELSE 'provider_failed' END
WHERE id = reservation.id;
RETURN jsonb_build_object('reservation_id', reservation.id, 'status', final_status);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_stt_quota(uuid, uuid) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_stt_quota(uuid, boolean) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_stt_quota(uuid, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_stt_quota(uuid, boolean) TO service_role;
COMMENT ON TABLE public.stt_quota_reservations IS
'Service-only leases that reserve STT quota before provider work and refund failed or expired work.';

View file

@ -0,0 +1,111 @@
-- 회의 생성 시 문서 템플릿은 선택 사항이다. 템플릿 없이도 회의를 시작할 수
-- 있어야 하며(데스크톱 동작과 동일), 문서 생성 시점에 사용자 템플릿 라이브러리
-- 에서 템플릿이 해결된다. meetings.template_id 컬럼과 소유권/생성 무결성
-- 트리거(enforce_meeting_template_owner_v1, enforce_meeting_creation_identity_v1)
-- 는 이미 NULL을 허용하므로 생성 RPC의 인자 검증만 완화한다.
--
-- 주의: idempotency 요청 해시에 template_id가 포함되므로, 같은 idempotency
-- 키를 NULL/NOT NULL 템플릿으로 재시도하면 creation_request_hash 불일치로
-- meeting_creation_idempotency_conflict가 발생한다 — 의도된 방어 동작이다.
CREATE OR REPLACE FUNCTION public.mobile_create_meeting_workspace_v2(
p_title text,
p_attendees jsonb,
p_language text,
p_template_id uuid,
p_idempotency_key uuid
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor_id uuid := auth.uid();
safe_title text := regexp_replace(trim(p_title), '[[:space:]]+', ' ', 'g');
safe_language text := lower(trim(p_language));
safe_attendees jsonb;
request_digest text;
created public.meetings;
BEGIN
IF actor_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF p_idempotency_key IS NULL THEN
RAISE EXCEPTION 'meeting_creation_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_title IS NULL OR char_length(safe_title) NOT BETWEEN 1 AND 160 THEN
RAISE EXCEPTION 'invalid_meeting_title' USING ERRCODE = '22023';
END IF;
IF p_language IS NULL OR safe_language NOT IN ('ko', 'en', 'ja', 'zh-cn') THEN
RAISE EXCEPTION 'invalid_meeting_language' USING ERRCODE = '22023';
END IF;
IF NOT public.is_valid_meeting_attendees_v1(p_attendees) THEN
RAISE EXCEPTION 'invalid_meeting_attendees' USING ERRCODE = '22023';
END IF;
IF p_template_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.user_templates
WHERE id = p_template_id
AND user_id = actor_id
AND template_kind = 'meeting_document'
) THEN
RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM set_config('d3ro.meeting_creation_actor', actor_id::text, true);
SELECT coalesce(
jsonb_agg(
to_jsonb(regexp_replace(trim(item.value #>> '{}'), '[[:space:]]+', ' ', 'g'))
ORDER BY item.ordinality
),
'[]'::jsonb
)
INTO safe_attendees
FROM jsonb_array_elements(p_attendees) WITH ORDINALITY AS item(value, ordinality);
request_digest := encode(extensions.digest(
jsonb_build_object(
'title', safe_title,
'attendees', safe_attendees,
'language', safe_language,
'template_id', p_template_id
)::text,
'sha256'
), 'hex');
INSERT INTO public.meetings(
user_id, title, status, language, attendees, template_id,
creation_idempotency_key, creation_request_hash
)
VALUES (
actor_id, safe_title, 'recording', safe_language, safe_attendees,
p_template_id, p_idempotency_key, request_digest
)
ON CONFLICT (user_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL
DO NOTHING
RETURNING * INTO created;
IF created.id IS NULL THEN
SELECT * INTO created
FROM public.meetings
WHERE user_id = actor_id
AND creation_idempotency_key = p_idempotency_key;
IF created.id IS NULL THEN
RAISE EXCEPTION 'meeting_creation_retry_state_missing' USING ERRCODE = 'PT409';
END IF;
IF created.creation_request_hash <> request_digest THEN
RAISE EXCEPTION 'meeting_creation_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
END IF;
RETURN created;
END;
$$;
REVOKE ALL ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
TO authenticated;

View file

@ -0,0 +1,253 @@
-- ============================================================================
-- Durable AdMob SSV receipt replay protection
--
-- ad_reward_claims remains the ledger of rewards that were actually granted.
-- Every otherwise valid, verified SSV transaction is consumed first in the
-- service-only receipt ledger, including terminal cooldown/cap/tier outcomes.
-- ============================================================================
BEGIN;
CREATE TABLE public.ad_reward_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id text NOT NULL UNIQUE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
network text NOT NULL,
placement text NOT NULL,
ad_unit_id text NOT NULL,
reward_tokens integer NOT NULL CHECK (reward_tokens > 0 AND reward_tokens <= 100),
disposition text NOT NULL DEFAULT 'received'
CHECK (disposition IN (
'received',
'granted',
'unknown_user',
'ineligible_tier',
'daily_cap',
'cooldown',
'duplicate_claim'
)),
claim_id uuid REFERENCES public.ad_reward_claims(id) ON DELETE SET NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
-- Account deletion unlinks the subject while the provider transaction remains
-- as the minimum durable replay barrier.
COMMENT ON COLUMN public.ad_reward_receipts.user_id IS
'Verified callback subject; nulled on account deletion without deleting the transaction replay barrier.';
CREATE INDEX idx_ad_reward_receipts_user_received
ON public.ad_reward_receipts(user_id, received_at DESC);
ALTER TABLE public.ad_reward_receipts ENABLE ROW LEVEL SECURITY;
-- No authenticated policies: verified callback receipts are service-only.
-- Seed the replay barrier with every reward already granted before this
-- migration. ad_reward_claims continues to be the authoritative grant ledger.
INSERT INTO public.ad_reward_receipts (
transaction_id,
user_id,
network,
placement,
ad_unit_id,
reward_tokens,
disposition,
claim_id,
received_at,
processed_at
)
SELECT
claim.transaction_id,
claim.user_id,
claim.network,
claim.placement,
claim.ad_unit_id,
claim.reward_tokens,
'granted',
claim.id,
claim.verified_at,
claim.verified_at
FROM public.ad_reward_claims AS claim
ON CONFLICT (transaction_id) DO NOTHING;
CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward(
p_user_id uuid,
p_network text,
p_placement text,
p_ad_unit_id text,
p_transaction_id text,
p_reward_tokens integer
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_receipt_user_id uuid;
v_receipt_id uuid;
v_claim_id uuid;
v_balance integer;
v_result jsonb;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'invalid_user';
END IF;
IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8
OR length(trim(p_transaction_id)) > 128 THEN
RAISE EXCEPTION 'invalid_transaction';
END IF;
IF p_reward_tokens IS DISTINCT FROM 50 THEN
RAISE EXCEPTION 'invalid_reward_amount';
END IF;
IF length(trim(coalesce(p_network, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_placement, ''))) NOT BETWEEN 1 AND 80
OR length(trim(coalesce(p_ad_unit_id, ''))) NOT BETWEEN 1 AND 80 THEN
RAISE EXCEPTION 'invalid_reward_metadata';
END IF;
-- Consume the provider transaction before looking up any mutable account
-- state. A concurrent replay then blocks on the unique key and can only
-- return duplicate after this transaction commits.
INSERT INTO public.ad_reward_receipts (
transaction_id,
network,
placement,
ad_unit_id,
reward_tokens
) VALUES (
trim(p_transaction_id),
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_receipt_id;
IF v_receipt_id IS NULL THEN
RETURN jsonb_build_object('granted', false, 'reason', 'duplicate');
END IF;
-- Serialize distinct transactions for the same subject. FOR KEY SHARE then
-- closes the account-delete race until the receipt is linked or terminally
-- recorded as unknown.
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:ad-reward:' || p_user_id::text, 0)
);
SELECT id INTO v_receipt_user_id
FROM auth.users
WHERE id = p_user_id
FOR KEY SHARE;
IF v_receipt_user_id IS NULL THEN
v_result := jsonb_build_object('granted', false, 'reason', 'unknown_user');
UPDATE public.ad_reward_receipts
SET disposition = 'unknown_user', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
UPDATE public.ad_reward_receipts
SET user_id = v_receipt_user_id
WHERE id = v_receipt_id;
IF NOT EXISTS (
SELECT 1
FROM public.subscriptions
WHERE user_id = p_user_id
AND tier = 'free'
AND coalesce(status, 'active') IN ('active', 'trialing')
) THEN
v_result := jsonb_build_object('granted', false, 'reason', 'ineligible_tier');
UPDATE public.ad_reward_receipts
SET disposition = 'ineligible_tier', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
IF (
SELECT count(*)
FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at >= date_trunc('day', now())
) >= 20 THEN
v_result := jsonb_build_object('granted', false, 'reason', 'daily_cap');
UPDATE public.ad_reward_receipts
SET disposition = 'daily_cap', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
IF EXISTS (
SELECT 1 FROM public.ad_reward_claims
WHERE user_id = p_user_id
AND verified_at > now() - interval '15 seconds'
) THEN
v_result := jsonb_build_object('granted', false, 'reason', 'cooldown');
UPDATE public.ad_reward_receipts
SET disposition = 'cooldown', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
INSERT INTO public.ad_reward_claims (
user_id, network, placement, ad_unit_id, transaction_id, reward_tokens
) VALUES (
p_user_id,
trim(p_network),
trim(p_placement),
trim(p_ad_unit_id),
trim(p_transaction_id),
p_reward_tokens
)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING id INTO v_claim_id;
-- This can only occur for a legacy/direct claim that raced the receipt
-- backfill. The new receipt remains consumed, so later retries stay denied.
IF v_claim_id IS NULL THEN
v_result := jsonb_build_object('granted', false, 'reason', 'duplicate');
UPDATE public.ad_reward_receipts
SET disposition = 'duplicate_claim', processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END IF;
INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider)
VALUES (p_user_id, 'free', p_reward_tokens, 'none')
ON CONFLICT (user_id) DO UPDATE
SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits,
updated_at = now()
RETURNING overage_credits INTO v_balance;
v_result := jsonb_build_object(
'granted', true,
'claim_id', v_claim_id,
'tokens_added', p_reward_tokens,
'balance', v_balance
);
UPDATE public.ad_reward_receipts
SET disposition = 'granted',
claim_id = v_claim_id,
processed_at = now()
WHERE id = v_receipt_id;
RETURN v_result;
END;
$$;
REVOKE ALL ON TABLE public.ad_reward_receipts FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.ad_reward_receipts TO service_role;
REVOKE ALL ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward(
uuid, text, text, text, text, integer
) TO service_role;
COMMIT;

View file

@ -0,0 +1,564 @@
-- Authenticated content reporting for ephemeral AI output.
--
-- Generation receipts deliberately contain no prompt or generated text. A report
-- stores only the snapshot the reporter explicitly submits for moderation. All
-- tables remain service-only; user and moderator access crosses narrow RPCs.
BEGIN;
CREATE TABLE public.content_generation_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
purpose text NOT NULL CHECK (purpose ~ '^[a-z][a-z0-9_]{2,39}$'),
model text NOT NULL CHECK (char_length(model) BETWEEN 1 AND 120),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
CHECK (expires_at > created_at)
);
COMMENT ON TABLE public.content_generation_receipts IS
'Service-issued proof that a user received an AI generation; never stores prompts or generated text.';
CREATE INDEX content_generation_receipts_expiry_idx
ON public.content_generation_receipts(expires_at);
CREATE INDEX content_generation_receipts_user_created_idx
ON public.content_generation_receipts(user_id, created_at DESC);
CREATE TABLE public.content_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
idempotency_key uuid NOT NULL,
request_hash text NOT NULL CHECK (request_hash ~ '^[0-9a-f]{64}$'),
kind text NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_]{2,39}$'),
source_type text NOT NULL CHECK (source_type ~ '^[a-z][a-z0-9_]{2,39}$'),
source_id uuid NOT NULL,
generation_receipt_id uuid REFERENCES public.content_generation_receipts(id) ON DELETE SET NULL,
reason text NOT NULL CHECK (reason IN (
'harmful', 'sexual', 'hateful', 'violent', 'self_harm',
'misinformation', 'privacy', 'spam', 'other'
)),
reporter_comment text CHECK (
reporter_comment IS NULL
OR char_length(reporter_comment) BETWEEN 1 AND 500
),
reported_snapshot text NOT NULL CHECK (char_length(reported_snapshot) BETWEEN 1 AND 4000),
snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'reviewing', 'actioned', 'dismissed')),
resolution_code text CHECK (
resolution_code IS NULL
OR resolution_code IN ('dismiss', 'confirm_violation')
),
reviewed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
reviewed_at timestamptz,
evidence_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (
(status IN ('pending', 'reviewing') AND resolution_code IS NULL)
OR (status = 'dismissed' AND resolution_code = 'dismiss')
OR (status = 'actioned' AND resolution_code = 'confirm_violation')
),
CHECK (
(status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL)
OR (status <> 'pending' AND reviewed_at IS NOT NULL)
),
CHECK (
(status IN ('pending', 'reviewing') AND evidence_expires_at IS NULL)
OR (status IN ('actioned', 'dismissed') AND evidence_expires_at > reviewed_at)
)
);
COMMENT ON COLUMN public.content_reports.reported_snapshot IS
'Reporter-selected evidence retained for moderation; handlers must never copy it into application logs.';
CREATE UNIQUE INDEX content_reports_reporter_idempotency_idx
ON public.content_reports(reporter_id, idempotency_key)
WHERE reporter_id IS NOT NULL;
CREATE UNIQUE INDEX content_reports_reporter_source_idx
ON public.content_reports(reporter_id, source_type, source_id)
WHERE reporter_id IS NOT NULL;
CREATE INDEX content_reports_moderation_queue_idx
ON public.content_reports(status, created_at, id);
CREATE INDEX content_reports_reporter_rate_idx
ON public.content_reports(reporter_id, created_at DESC)
WHERE reporter_id IS NOT NULL;
CREATE TABLE public.content_report_review_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL REFERENCES public.content_reports(id) ON DELETE CASCADE,
actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
idempotency_key uuid NOT NULL,
request_hash text NOT NULL CHECK (request_hash ~ '^[0-9a-f]{64}$'),
action text NOT NULL CHECK (action IN ('begin_review', 'dismiss', 'confirm_violation')),
note text NOT NULL CHECK (char_length(note) BETWEEN 3 AND 1000),
before_status text NOT NULL,
after_status text NOT NULL,
response_data jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX content_report_review_action_idempotency_idx
ON public.content_report_review_actions(actor_id, idempotency_key)
WHERE actor_id IS NOT NULL;
CREATE INDEX content_report_review_actions_report_idx
ON public.content_report_review_actions(report_id, created_at DESC);
ALTER TABLE public.content_generation_receipts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.content_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.content_report_review_actions ENABLE ROW LEVEL SECURITY;
-- No RLS policies are created. Even the reporter cannot read or mutate the
-- moderation ledger through PostgREST; the Edge Function uses service RPCs.
REVOKE ALL ON TABLE public.content_generation_receipts FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.content_reports FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.content_report_review_actions FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.content_generation_receipts TO service_role;
GRANT ALL ON TABLE public.content_reports TO service_role;
GRANT ALL ON TABLE public.content_report_review_actions TO service_role;
CREATE OR REPLACE FUNCTION public.issue_content_generation_receipt_v1(
p_actor_id uuid,
p_purpose text,
p_model text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth
AS $$
DECLARE
receipt_id uuid;
receipt_expiry timestamptz;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF p_actor_id IS NULL OR p_purpose IS NULL OR p_model IS NULL THEN
RAISE EXCEPTION 'generation_receipt_fields_required' USING ERRCODE = '22023';
END IF;
IF p_purpose NOT IN ('talk_response', 'command_response', 'action_response') THEN
RAISE EXCEPTION 'invalid_generation_purpose' USING ERRCODE = '22023';
END IF;
IF char_length(trim(p_model)) NOT BETWEEN 1 AND 120 THEN
RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023';
END IF;
-- Protect the user foreign key from a concurrent account deletion until the
-- receipt has either been inserted or failed closed.
PERFORM 1 FROM auth.users WHERE id = p_actor_id FOR KEY SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION 'generation_user_not_found' USING ERRCODE = 'P0002';
END IF;
INSERT INTO public.content_generation_receipts(user_id, purpose, model)
VALUES (p_actor_id, p_purpose, trim(p_model))
RETURNING id, expires_at INTO receipt_id, receipt_expiry;
RETURN jsonb_build_object(
'generationId', receipt_id,
'expiresAt', receipt_expiry
);
END;
$$;
REVOKE ALL ON FUNCTION public.issue_content_generation_receipt_v1(uuid, text, text)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.issue_content_generation_receipt_v1(uuid, text, text)
TO service_role;
CREATE OR REPLACE FUNCTION public.submit_content_report_v1(
p_actor_id uuid,
p_idempotency_key uuid,
p_kind text,
p_source_type text,
p_source_id uuid,
p_reason text,
p_comment text,
p_snapshot text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
request_payload jsonb;
request_digest text;
existing_report public.content_reports%ROWTYPE;
receipt public.content_generation_receipts%ROWTYPE;
created_report public.content_reports%ROWTYPE;
normalized_comment text;
normalized_snapshot text;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_source_id IS NULL THEN
RAISE EXCEPTION 'content_report_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_kind <> 'ai_output'
OR p_source_type NOT IN ('talk_response', 'command_response', 'action_response') THEN
RAISE EXCEPTION 'unsupported_content_report_source' USING ERRCODE = '22023';
END IF;
IF p_reason NOT IN (
'harmful', 'sexual', 'hateful', 'violent', 'self_harm',
'misinformation', 'privacy', 'spam', 'other'
) THEN
RAISE EXCEPTION 'invalid_content_report_reason' USING ERRCODE = '22023';
END IF;
normalized_comment := CASE WHEN p_comment IS NULL THEN NULL ELSE trim(p_comment) END;
normalized_snapshot := trim(coalesce(p_snapshot, ''));
IF normalized_comment IS NOT NULL
AND char_length(normalized_comment) NOT BETWEEN 1 AND 500 THEN
RAISE EXCEPTION 'invalid_content_report_comment' USING ERRCODE = '22023';
END IF;
IF char_length(normalized_snapshot) NOT BETWEEN 1 AND 4000 THEN
RAISE EXCEPTION 'invalid_content_report_snapshot' USING ERRCODE = '22023';
END IF;
request_payload := jsonb_build_object(
'kind', p_kind,
'source', jsonb_build_object('type', p_source_type, 'generationId', p_source_id),
'reason', p_reason,
'comment', normalized_comment,
'snapshot', normalized_snapshot
);
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
-- One actor lock covers idempotency, duplicate-source detection and both
-- rate windows. Concurrent calls cannot observe the same remaining slot.
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:content-report:' || p_actor_id::text, 0)
);
SELECT * INTO existing_report
FROM public.content_reports
WHERE reporter_id = p_actor_id AND idempotency_key = p_idempotency_key;
IF existing_report.id IS NOT NULL THEN
IF existing_report.request_hash <> request_digest THEN
RAISE EXCEPTION 'content_report_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN jsonb_build_object(
'reportId', existing_report.id,
'status', 'submitted',
'idempotent', true,
'createdAt', existing_report.created_at
);
END IF;
-- Keep account deletion from invalidating the reporter foreign key between
-- source verification and the report insert. A deletion that wins first is
-- intentionally indistinguishable from any other missing source.
PERFORM 1 FROM auth.users WHERE id = p_actor_id FOR KEY SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
SELECT * INTO receipt
FROM public.content_generation_receipts
WHERE id = p_source_id
AND user_id = p_actor_id
AND purpose = p_source_type
AND expires_at > now()
FOR KEY SHARE;
IF receipt.id IS NULL THEN
-- Missing, expired and cross-user receipts intentionally look identical.
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
IF EXISTS (
SELECT 1 FROM public.content_reports
WHERE reporter_id = p_actor_id
AND source_type = p_source_type
AND source_id = p_source_id
) THEN
RAISE EXCEPTION 'content_report_source_already_reported' USING ERRCODE = 'PT409';
END IF;
IF (
SELECT count(*) FROM public.content_reports
WHERE reporter_id = p_actor_id
AND created_at > now() - interval '1 hour'
) >= 10 OR (
SELECT count(*) FROM public.content_reports
WHERE reporter_id = p_actor_id
AND created_at > now() - interval '24 hours'
) >= 30 THEN
RAISE EXCEPTION 'content_report_rate_limited' USING ERRCODE = 'PT429';
END IF;
INSERT INTO public.content_reports(
reporter_id, idempotency_key, request_hash, kind, source_type, source_id,
generation_receipt_id, reason, reporter_comment, reported_snapshot,
snapshot_sha256
) VALUES (
p_actor_id, p_idempotency_key, request_digest, p_kind, p_source_type, p_source_id,
receipt.id, p_reason, normalized_comment, normalized_snapshot,
encode(extensions.digest(normalized_snapshot, 'sha256'), 'hex')
)
RETURNING * INTO created_report;
RETURN jsonb_build_object(
'reportId', created_report.id,
'status', 'submitted',
'idempotent', false,
'createdAt', created_report.created_at
);
END;
$$;
REVOKE ALL ON FUNCTION public.submit_content_report_v1(
uuid, uuid, text, text, uuid, text, text, text
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.submit_content_report_v1(
uuid, uuid, text, text, uuid, text, text, text
) TO service_role;
CREATE OR REPLACE FUNCTION public.admin_list_content_reports_v1(
p_status text DEFAULT NULL,
p_limit integer DEFAULT 50,
p_before timestamptz DEFAULT NULL
) RETURNS TABLE (
report_id uuid,
reporter_id uuid,
kind text,
source_type text,
source_id uuid,
reason text,
reporter_comment text,
reported_snapshot text,
status text,
resolution_code text,
reviewed_by uuid,
reviewed_at timestamptz,
created_at timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth
AS $$
DECLARE
v_actor_id uuid;
v_actor_role text;
BEGIN
v_actor_id := auth.uid();
SELECT profile.role INTO v_actor_role
FROM public.profiles AS profile
WHERE profile.id = v_actor_id;
IF v_actor_role IS NULL OR v_actor_role NOT IN ('manager', 'admin', 'super_admin') THEN
RAISE EXCEPTION 'content_report_manager_required' USING ERRCODE = '42501';
END IF;
IF p_status IS NOT NULL
AND p_status NOT IN ('pending', 'reviewing', 'actioned', 'dismissed') THEN
RAISE EXCEPTION 'invalid_content_report_status' USING ERRCODE = '22023';
END IF;
IF p_limit IS NULL OR p_limit NOT BETWEEN 1 AND 100 THEN
RAISE EXCEPTION 'invalid_content_report_limit' USING ERRCODE = '22023';
END IF;
RETURN QUERY
SELECT
report.id,
report.reporter_id,
report.kind,
report.source_type,
report.source_id,
report.reason,
report.reporter_comment,
report.reported_snapshot,
report.status,
report.resolution_code,
report.reviewed_by,
report.reviewed_at,
report.created_at
FROM public.content_reports AS report
WHERE (p_status IS NULL OR report.status = p_status)
AND (p_before IS NULL OR report.created_at < p_before)
ORDER BY report.created_at DESC, report.id DESC
LIMIT p_limit;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_list_content_reports_v1(text, integer, timestamptz)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.admin_list_content_reports_v1(text, integer, timestamptz)
TO authenticated;
CREATE OR REPLACE FUNCTION public.admin_act_on_content_report_v1(
p_report_id uuid,
p_idempotency_key uuid,
p_action text,
p_note text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
v_actor_id uuid;
v_actor_role text;
report public.content_reports%ROWTYPE;
existing_action public.content_report_review_actions%ROWTYPE;
normalized_note text;
request_digest text;
next_status text;
next_resolution text;
result jsonb;
BEGIN
v_actor_id := auth.uid();
SELECT profile.role INTO v_actor_role
FROM public.profiles AS profile
WHERE profile.id = v_actor_id;
IF v_actor_role IS NULL OR v_actor_role NOT IN ('manager', 'admin', 'super_admin') THEN
RAISE EXCEPTION 'content_report_manager_required' USING ERRCODE = '42501';
END IF;
IF p_report_id IS NULL OR p_idempotency_key IS NULL THEN
RAISE EXCEPTION 'content_report_action_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_action NOT IN ('begin_review', 'dismiss', 'confirm_violation') THEN
RAISE EXCEPTION 'invalid_content_report_action' USING ERRCODE = '22023';
END IF;
normalized_note := trim(coalesce(p_note, ''));
IF char_length(normalized_note) NOT BETWEEN 3 AND 1000 THEN
RAISE EXCEPTION 'invalid_content_report_action_note' USING ERRCODE = '22023';
END IF;
request_digest := encode(extensions.digest(jsonb_build_object(
'reportId', p_report_id,
'action', p_action,
'note', normalized_note
)::text, 'sha256'), 'hex');
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:content-report-review:' || v_actor_id::text, 0)
);
SELECT * INTO existing_action
FROM public.content_report_review_actions
WHERE actor_id = v_actor_id AND idempotency_key = p_idempotency_key;
IF existing_action.id IS NOT NULL THEN
IF existing_action.request_hash <> request_digest THEN
RAISE EXCEPTION 'content_report_action_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN existing_action.response_data || jsonb_build_object('idempotent', true);
END IF;
SELECT * INTO report FROM public.content_reports
WHERE id = p_report_id FOR UPDATE;
IF report.id IS NULL THEN
RAISE EXCEPTION 'content_report_not_found' USING ERRCODE = 'P0002';
END IF;
IF p_action = 'begin_review' THEN
IF report.status NOT IN ('pending', 'reviewing') THEN
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
END IF;
next_status := 'reviewing';
next_resolution := NULL;
ELSIF p_action = 'dismiss' THEN
IF report.status NOT IN ('pending', 'reviewing') THEN
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
END IF;
next_status := 'dismissed';
next_resolution := 'dismiss';
ELSE
IF report.status NOT IN ('pending', 'reviewing') THEN
RAISE EXCEPTION 'content_report_already_resolved' USING ERRCODE = 'PT409';
END IF;
next_status := 'actioned';
next_resolution := 'confirm_violation';
END IF;
UPDATE public.content_reports
SET status = next_status,
resolution_code = next_resolution,
reviewed_by = v_actor_id,
reviewed_at = now(),
evidence_expires_at = CASE
WHEN next_status IN ('actioned', 'dismissed') THEN now() + interval '180 days'
ELSE NULL
END,
updated_at = now()
WHERE id = report.id
RETURNING reviewed_at INTO report.reviewed_at;
result := jsonb_build_object(
'reportId', report.id,
'status', next_status,
'action', p_action,
'reviewedAt', report.reviewed_at,
'idempotent', false
);
INSERT INTO public.content_report_review_actions(
report_id, actor_id, idempotency_key, request_hash, action, note,
before_status, after_status, response_data
) VALUES (
report.id, v_actor_id, p_idempotency_key, request_digest, p_action,
normalized_note, report.status, next_status, result
);
-- Audit metadata only: never duplicate the report snapshot into general logs.
INSERT INTO public.audit_log(
admin_id, action, target_type, target_id, before_data, after_data, memo
) VALUES (
v_actor_id,
'content_report.' || p_action,
'content_report',
report.id,
jsonb_build_object('status', report.status),
jsonb_build_object('status', next_status, 'resolution', next_resolution),
normalized_note
);
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_act_on_content_report_v1(uuid, uuid, text, text)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.admin_act_on_content_report_v1(uuid, uuid, text, text)
TO authenticated;
CREATE OR REPLACE FUNCTION public.purge_expired_content_reporting_data_v1()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth
AS $$
DECLARE
purged_reports integer;
purged_receipts integer;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
-- Pending/reviewing evidence is never time-purged. Final moderation evidence
-- receives a fixed 180-day appeal/abuse-defense window when it is resolved.
DELETE FROM public.content_reports
WHERE status IN ('actioned', 'dismissed')
AND evidence_expires_at <= now();
GET DIAGNOSTICS purged_reports = ROW_COUNT;
-- Receipts never contain content. Once their reporting window ends they no
-- longer prove a live source and can be removed; linked reports retain their
-- source UUID while the foreign-key pointer is nulled.
DELETE FROM public.content_generation_receipts
WHERE expires_at <= now();
GET DIAGNOSTICS purged_receipts = ROW_COUNT;
RETURN jsonb_build_object(
'purgedReports', purged_reports,
'purgedReceipts', purged_receipts
);
END;
$$;
REVOKE ALL ON FUNCTION public.purge_expired_content_reporting_data_v1()
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.purge_expired_content_reporting_data_v1()
TO service_role;
COMMIT;

View file

@ -0,0 +1,14 @@
BEGIN;
-- profiles_update_own intentionally allows a user to edit their own profile
-- row, but row-level security cannot distinguish user-editable columns from
-- service-managed authorization and entitlement columns. Supabase grants table
-- UPDATE to authenticated by default, so without a column ACL a user could
-- promote their own role or change their tier through PostgREST.
REVOKE UPDATE ON TABLE public.profiles FROM anon, authenticated;
-- Keep only the public profile fields user-editable. Role changes go through
-- the service-role admin RPC and tier changes go through verified billing.
GRANT UPDATE (name, avatar_url, locale) ON TABLE public.profiles TO authenticated;
COMMIT;

View file

@ -0,0 +1,18 @@
BEGIN;
-- Audit evidence must survive an account deletion, but it must not block the
-- deletion or retain a recoverable administrator identity. Existing audit_log
-- rows are preserved; only the actor reference becomes NULL on deletion.
ALTER TABLE public.audit_log
ALTER COLUMN admin_id DROP NOT NULL;
ALTER TABLE public.audit_log
DROP CONSTRAINT IF EXISTS audit_log_admin_id_fkey;
ALTER TABLE public.audit_log
ADD CONSTRAINT audit_log_admin_id_fkey
FOREIGN KEY (admin_id)
REFERENCES auth.users(id)
ON DELETE SET NULL;
COMMIT;

View file

@ -0,0 +1,180 @@
-- Extend the authenticated AI-output report boundary to generated meeting
-- documents. Existing ephemeral generations continue to require a live receipt;
-- meeting documents instead require an owned document plus its immutable
-- generation audit row.
BEGIN;
CREATE OR REPLACE FUNCTION public.submit_content_report_v1(
p_actor_id uuid,
p_idempotency_key uuid,
p_kind text,
p_source_type text,
p_source_id uuid,
p_reason text,
p_comment text,
p_snapshot text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
request_payload jsonb;
request_digest text;
existing_report public.content_reports%ROWTYPE;
receipt public.content_generation_receipts%ROWTYPE;
created_report public.content_reports%ROWTYPE;
resolved_generation_receipt_id uuid;
normalized_comment text;
normalized_snapshot text;
BEGIN
IF auth.role() <> 'service_role' THEN
RAISE EXCEPTION 'service_role_required' USING ERRCODE = '42501';
END IF;
IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_source_id IS NULL THEN
RAISE EXCEPTION 'content_report_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_kind <> 'ai_output'
OR p_source_type NOT IN (
'talk_response', 'command_response', 'action_response', 'meeting_document'
) THEN
RAISE EXCEPTION 'unsupported_content_report_source' USING ERRCODE = '22023';
END IF;
IF p_reason NOT IN (
'harmful', 'sexual', 'hateful', 'violent', 'self_harm',
'misinformation', 'privacy', 'spam', 'other'
) THEN
RAISE EXCEPTION 'invalid_content_report_reason' USING ERRCODE = '22023';
END IF;
normalized_comment := CASE WHEN p_comment IS NULL THEN NULL ELSE trim(p_comment) END;
normalized_snapshot := trim(coalesce(p_snapshot, ''));
IF normalized_comment IS NOT NULL
AND char_length(normalized_comment) NOT BETWEEN 1 AND 500 THEN
RAISE EXCEPTION 'invalid_content_report_comment' USING ERRCODE = '22023';
END IF;
IF char_length(normalized_snapshot) NOT BETWEEN 1 AND 4000 THEN
RAISE EXCEPTION 'invalid_content_report_snapshot' USING ERRCODE = '22023';
END IF;
request_payload := jsonb_build_object(
'kind', p_kind,
'source', jsonb_build_object('type', p_source_type, 'generationId', p_source_id),
'reason', p_reason,
'comment', normalized_comment,
'snapshot', normalized_snapshot
);
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
-- One actor lock covers idempotency, duplicate-source detection and both
-- rate windows. Concurrent calls cannot observe the same remaining slot.
PERFORM pg_advisory_xact_lock(
pg_catalog.hashtextextended('d3ro:content-report:' || p_actor_id::text, 0)
);
SELECT * INTO existing_report
FROM public.content_reports
WHERE reporter_id = p_actor_id AND idempotency_key = p_idempotency_key;
IF existing_report.id IS NOT NULL THEN
IF existing_report.request_hash <> request_digest THEN
RAISE EXCEPTION 'content_report_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
RETURN jsonb_build_object(
'reportId', existing_report.id,
'status', 'submitted',
'idempotent', true,
'createdAt', existing_report.created_at
);
END IF;
-- Keep account deletion from invalidating the reporter foreign key between
-- source verification and the report insert. A deletion that wins first is
-- intentionally indistinguishable from any other missing source.
PERFORM 1 FROM auth.users WHERE id = p_actor_id FOR KEY SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
IF p_source_type = 'meeting_document' THEN
-- A document is reportable only when it belongs to the actor and an
-- immutable generation audit row proves that the document came from the
-- server-side generation path. Manual or cross-user rows fail identically.
PERFORM 1
FROM public.meeting_documents AS document
INNER JOIN public.meeting_document_generation_audit AS generation
ON generation.document_id = document.id
AND generation.user_id = document.user_id
AND generation.meeting_id = document.meeting_id
WHERE document.id = p_source_id
AND document.user_id = p_actor_id
FOR KEY SHARE OF document, generation;
IF NOT FOUND THEN
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
resolved_generation_receipt_id := NULL;
ELSE
SELECT * INTO receipt
FROM public.content_generation_receipts
WHERE id = p_source_id
AND user_id = p_actor_id
AND purpose = p_source_type
AND expires_at > now()
FOR KEY SHARE;
IF receipt.id IS NULL THEN
-- Missing, expired and cross-user receipts intentionally look identical.
RAISE EXCEPTION 'content_report_source_not_found' USING ERRCODE = 'P0002';
END IF;
resolved_generation_receipt_id := receipt.id;
END IF;
IF EXISTS (
SELECT 1 FROM public.content_reports
WHERE reporter_id = p_actor_id
AND source_type = p_source_type
AND source_id = p_source_id
) THEN
RAISE EXCEPTION 'content_report_source_already_reported' USING ERRCODE = 'PT409';
END IF;
IF (
SELECT count(*) FROM public.content_reports
WHERE reporter_id = p_actor_id
AND created_at > now() - interval '1 hour'
) >= 10 OR (
SELECT count(*) FROM public.content_reports
WHERE reporter_id = p_actor_id
AND created_at > now() - interval '24 hours'
) >= 30 THEN
RAISE EXCEPTION 'content_report_rate_limited' USING ERRCODE = 'PT429';
END IF;
INSERT INTO public.content_reports(
reporter_id, idempotency_key, request_hash, kind, source_type, source_id,
generation_receipt_id, reason, reporter_comment, reported_snapshot,
snapshot_sha256
) VALUES (
p_actor_id, p_idempotency_key, request_digest, p_kind, p_source_type, p_source_id,
resolved_generation_receipt_id, p_reason, normalized_comment, normalized_snapshot,
encode(extensions.digest(normalized_snapshot, 'sha256'), 'hex')
)
RETURNING * INTO created_report;
RETURN jsonb_build_object(
'reportId', created_report.id,
'status', 'submitted',
'idempotent', false,
'createdAt', created_report.created_at
);
END;
$$;
REVOKE ALL ON FUNCTION public.submit_content_report_v1(
uuid, uuid, text, text, uuid, text, text, text
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.submit_content_report_v1(
uuid, uuid, text, text, uuid, text, text, text
) TO service_role;
COMMIT;