616 lines
22 KiB
PL/PgSQL
616 lines
22 KiB
PL/PgSQL
-- ============================================================================
|
|
-- 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;
|