d3ro-voice/server/supabase/migrations/20260821000001_mobile_platform_foundation.sql
2026-08-29 18:33:45 +09:00

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;