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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue