feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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.';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
564
server/supabase/migrations/20260824000029_content_reporting.sql
Normal file
564
server/supabase/migrations/20260824000029_content_reporting.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue