create extension if not exists pgcrypto with schema extensions; create extension if not exists citext with schema extensions; create schema if not exists app; create schema if not exists app_private; revoke all on schema app_private from public; revoke all on schema app_private from anon; revoke all on schema app_private from authenticated; create or replace function app.touch_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; create or replace function app.jwt_text(claim_name text) returns text language sql stable as $$ select nullif(coalesce(auth.jwt() ->> claim_name, current_setting('request.jwt.claim.' || claim_name, true)), '') $$; create or replace function app.current_tenant_id() returns uuid language sql stable as $$ select app.jwt_text('tenant_id')::uuid $$; create or replace function app.current_role() returns text language sql stable as $$ select coalesce(app.jwt_text('app_role'), app.jwt_text('role'), '') $$; create or replace function app.is_platform_admin() returns boolean language sql stable as $$ select app.current_role() in ('platform_admin', 'service_role') $$; create table if not exists public.tenants ( id uuid primary key default gen_random_uuid(), slug extensions.citext not null unique, name text not null, legal_name text, status text not null default 'active' check (status in ('draft', 'active', 'suspended', 'archived')), mode text not null default 'saas' check (mode in ('platform_owned', 'saas', 'dedicated')), billing_status text not null default 'trial' check (billing_status in ('trial', 'active', 'past_due', 'cancelled')), owner_user_id uuid, legacy_id text, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.platform_users ( id uuid primary key default gen_random_uuid(), auth_user_id uuid unique references auth.users(id) on delete set null, legacy_id text unique, username text, email extensions.citext, phone text, name text, avatar_url text, primary_role text not null default 'student', score integer not null default 0, last_seen_at timestamptz, legacy_password_hash text, password_migration_required boolean not null default false, raw_profile jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.tenants drop constraint if exists tenants_owner_user_id_fkey, add constraint tenants_owner_user_id_fkey foreign key (owner_user_id) references public.platform_users(id) on delete set null; create table if not exists public.user_identities ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.platform_users(id) on delete cascade, provider text not null, provider_subject text not null, union_id text, open_id text, phone text, email extensions.citext, secret_payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (provider, provider_subject) ); create table if not exists public.tenant_memberships ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, role text not null check (role in ('platform_admin', 'tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student')), status text not null default 'active' check (status in ('active', 'invited', 'disabled')), permissions jsonb not null default '{}'::jsonb, legacy_role text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, user_id, role) ); create table if not exists public.tenant_domains ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, host extensions.citext not null unique, domain_type text not null default 'custom' check (domain_type in ('system', 'custom', 'miniapp')), status text not null default 'pending' check (status in ('pending', 'active', 'failed', 'disabled')), is_primary boolean not null default false, verification_token text, verified_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_branding ( tenant_id uuid primary key references public.tenants(id) on delete cascade, brand_name text not null, short_name text, slogan text, org_name text, logo_url text, favicon_url text, service_wechat text, service_account_name text, theme jsonb not null default '{}'::jsonb, public_assets jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_settings ( tenant_id uuid primary key references public.tenants(id) on delete cascade, feature_flags jsonb not null default '{}'::jsonb, admin_feature_flags jsonb not null default '{}'::jsonb, public_config jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_payment_accounts ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, provider text not null, mode text not null default 'platform_collect' check (mode in ('platform_collect', 'tenant_collect', 'service_provider')), display_name text, status text not null default 'disabled' check (status in ('active', 'disabled', 'pending')), config_public jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, provider) ); create table if not exists app_private.tenant_secrets ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, secret_scope text not null check (secret_scope in ('payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system')), secret_key text not null, secret_value text, secret_json jsonb not null default '{}'::jsonb, provider text, last_rotated_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, secret_scope, secret_key) ); comment on table app_private.tenant_secrets is 'Private tenant secrets. Do not expose through PostgREST/anon/authenticated roles. Prefer external vault or encrypted values in production.'; create table if not exists public.tenant_subscriptions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, plan_code text not null, status text not null default 'trial' check (status in ('trial', 'active', 'past_due', 'cancelled')), starts_at timestamptz, expires_at timestamptz, billing_cycle text default 'yearly', amount_cents integer not null default 0, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_usage_records ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, metric_key text not null, metric_value numeric not null default 0, period_start date not null, period_end date not null, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create table if not exists public.regions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, name text not null, code text, short_name text, full_name text, icon text, pinyin text, sort_order integer not null default 0, is_hot boolean not null default false, is_active boolean not null default true, config jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.region_modules ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, name text not null, type text, icon text, color text, text_color text, description text, route text, sort_order integer not null default 0, is_primary_school_module boolean not null default false, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.module_nodes ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, module_id uuid references public.region_modules(id) on delete set null, parent_id uuid references public.module_nodes(id) on delete cascade, legacy_id text, legacy_parent_id text, legacy_module_id text, type text not null check (type in ('category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom')), name text not null, path text, sort_order integer not null default 0, is_active boolean not null default true, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.schools ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, module_id uuid references public.region_modules(id) on delete set null, legacy_id text, name text not null, professional_exam_date text, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.majors ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, school_id uuid references public.schools(id) on delete cascade, legacy_id text, name text not null, description text, study_tips text, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.subjects ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, module_id uuid references public.region_modules(id) on delete set null, school_id uuid references public.schools(id) on delete set null, major_id uuid references public.majors(id) on delete set null, node_id uuid references public.module_nodes(id) on delete set null, legacy_id text, name text not null, type text check (type in ('cultural', 'professional')), major_legacy_ids jsonb not null default '[]'::jsonb, icon text, description text, stats jsonb not null default '{}'::jsonb, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.categories ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, subject_id uuid references public.subjects(id) on delete cascade, node_id uuid references public.module_nodes(id) on delete set null, legacy_id text, name text not null, category_type text check (category_type in ('chapter', 'paper')), sort_order integer not null default 0, svip_question_limit integer, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.question_banks ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, name text not null, source_scope text not null default 'tenant' check (source_scope in ('platform', 'tenant')), status text not null default 'active' check (status in ('active', 'archived')), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.questions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, question_bank_id uuid references public.question_banks(id) on delete set null, subject_id uuid references public.subjects(id) on delete set null, category_id uuid references public.categories(id) on delete set null, node_id uuid references public.module_nodes(id) on delete set null, legacy_id text, legacy_subject_id text, legacy_category_id text, legacy_node_id text, type text not null default 'choice', type_label text, difficulty integer, tags jsonb not null default '[]'::jsonb, media_url text, has_video_explanation boolean not null default false, status text not null default 'published' check (status in ('draft', 'published', 'archived')), current_version_id uuid, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.question_versions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, question_id uuid not null references public.questions(id) on delete cascade, version_no integer not null default 1, content text, options jsonb not null default '[]'::jsonb, correct_option_index integer, correct_option_indices jsonb not null default '[]'::jsonb, answer_text text, explanation text, sub_questions jsonb not null default '[]'::jsonb, code_lang text, code_template text, source_hash text, created_by uuid references public.platform_users(id) on delete set null, created_at timestamptz not null default now(), unique (question_id, version_no) ); alter table public.questions drop constraint if exists questions_current_version_id_fkey, add constraint questions_current_version_id_fkey foreign key (current_version_id) references public.question_versions(id) on delete set null; create table if not exists public.student_profiles ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, legacy_user_id text, region_id uuid references public.regions(id) on delete set null, selected_school_id uuid references public.schools(id) on delete set null, selected_major_id uuid references public.majors(id) on delete set null, questions_answered_today integer not null default 0, mastered_words_count integer not null default 0, last_check_in_date date, stats jsonb not null default '{}'::jsonb, progress jsonb not null default '{}'::jsonb, module_selections jsonb not null default '{}'::jsonb, recent_activities jsonb not null default '[]'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, user_id) ); create table if not exists public.practice_sessions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, mode text not null default 'chapter', target_type text, target_id uuid, started_at timestamptz not null default now(), finished_at timestamptz, metadata jsonb not null default '{}'::jsonb ); create table if not exists public.answer_records ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, question_id uuid references public.questions(id) on delete set null, question_version_id uuid references public.question_versions(id) on delete set null, practice_session_id uuid references public.practice_sessions(id) on delete set null, legacy_id text, legacy_question_id text, legacy_category_id text, selected_options jsonb not null default '[]'::jsonb, answer_text text, is_correct boolean, answered_at timestamptz not null default now(), created_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.favorite_questions ( tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, question_id uuid not null references public.questions(id) on delete cascade, source text not null default 'imported', created_at timestamptz not null default now(), primary key (tenant_id, user_id, question_id) ); create table if not exists public.wrong_questions ( tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, question_id uuid not null references public.questions(id) on delete cascade, wrong_count integer not null default 1, last_wrong_at timestamptz not null default now(), resolved_at timestamptz, primary key (tenant_id, user_id, question_id) ); create table if not exists public.svip_plans ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, name text not null, price_cents integer not null default 0, original_price_cents integer, days integer not null default 0, description text, per_day_label text, badge text, recommended boolean not null default false, coupon_only boolean not null default false, vp_product_id text, vp_enabled boolean not null default false, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.orders ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid references public.platform_users(id) on delete set null, legacy_id text, legacy_user_id text, order_no text not null, status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'closed', 'refunded')), product_type text, product_name text, amount_cents integer not null default 0, pay_method text, pay_provider text, trade_no text, plan_id uuid references public.svip_plans(id) on delete set null, legacy_plan_id text, days integer, region_id uuid references public.regions(id) on delete set null, legacy_region_id text, paid_at timestamptz, raw_payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, order_no), unique (tenant_id, legacy_id) ); create table if not exists public.order_items ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, order_id uuid not null references public.orders(id) on delete cascade, legacy_id text, item_type text not null, item_id uuid, name text not null, quantity integer not null default 1, unit_amount_cents integer not null default 0, total_amount_cents integer not null default 0, metadata jsonb not null default '{}'::jsonb, unique (tenant_id, legacy_id) ); create table if not exists public.payments ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, order_id uuid not null references public.orders(id) on delete cascade, legacy_id text, legacy_order_id text, provider text not null, method text, status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'cancelled', 'refunded')), amount_cents integer not null, provider_trade_no text, paid_at timestamptz, raw_payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.payment_events ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, payment_id uuid references public.payments(id) on delete set null, provider text not null, event_type text not null, event_id text, signature_valid boolean, payload jsonb not null, processed_at timestamptz, error text, created_at timestamptz not null default now(), unique (provider, event_id) ); create table if not exists public.entitlements ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, entitlement_type text not null default 'svip', scope_type text not null default 'tenant' check (scope_type in ('tenant', 'region', 'module', 'subject', 'question_bank')), scope_id uuid, source_type text not null default 'migration', source_id uuid, legacy_source_id text, starts_at timestamptz not null default now(), expires_at timestamptz, status text not null default 'active' check (status in ('active', 'revoked', 'expired')), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create table if not exists public.code_batches ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, name text not null, sale_type text, channel text, campaign_name text, default_unit_price_cents integer not null default 0, cost_price_cents integer not null default 0, total_count integer not null default 0, days integer, region_id uuid references public.regions(id) on delete set null, legacy_region_id text, issued_at timestamptz, created_by uuid references public.platform_users(id) on delete set null, remark text, commission_rate numeric(6,4), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.activation_codes ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, code extensions.citext not null, days integer not null default 0, is_used boolean not null default false, used_by uuid references public.platform_users(id) on delete set null, used_at timestamptz, agent_user_id uuid references public.platform_users(id) on delete set null, batch_id uuid references public.code_batches(id) on delete set null, sale_type text, unit_price_cents integer, sold_to text, used_region_id uuid references public.regions(id) on delete set null, coupon_code text, coupon_redemption_id uuid, remark text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, code), unique (tenant_id, legacy_id) ); create table if not exists public.coupons ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, code extensions.citext not null, plan_id uuid references public.svip_plans(id) on delete set null, discount_type text check (discount_type in ('percent', 'fixed')), discount_value numeric, valid_from timestamptz, valid_to timestamptz, max_uses integer, used_count integer not null default 0, source text, remark text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, code), unique (tenant_id, legacy_id) ); create table if not exists public.coupon_redemptions ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, coupon_id uuid references public.coupons(id) on delete set null, coupon_code text, user_id uuid references public.platform_users(id) on delete set null, plan_id uuid references public.svip_plans(id) on delete set null, order_id uuid references public.orders(id) on delete set null, status text not null default 'claimed', discount_applied_cents integer, region_id uuid references public.regions(id) on delete set null, source text, remark text, claimed_at timestamptz, used_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.vocabulary_units ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, name text not null, description text, word_count integer, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.vocabulary_words ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, unit_id uuid references public.vocabulary_units(id) on delete set null, legacy_id text, word text not null, phonetic text, meaning text, example text, example_translation text, difficulty integer, tags jsonb not null default '[]'::jsonb, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.handbook_subjects ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, name text not null, type text, icon text, color text, description text, sort_order integer not null default 0, is_active boolean not null default true, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.handbook_chapters ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, subject_id uuid references public.handbook_subjects(id) on delete cascade, legacy_id text, name text not null, description text, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.handbook_entries ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, chapter_id uuid references public.handbook_chapters(id) on delete cascade, legacy_id text, title text not null, summary text, content text, tags jsonb not null default '[]'::jsonb, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.banners ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, title text, subtitle text, content text, button_text text, button_link text, bg_color text, border_color text, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.faqs ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, region_id uuid references public.regions(id) on delete set null, legacy_id text, question text, answer text, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.announcements ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, content text, link text, bg_color text, sort_order integer not null default 0, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.reports ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, question_id uuid references public.questions(id) on delete set null, user_id uuid references public.platform_users(id) on delete set null, type text, description text, status text not null default 'pending', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, legacy_id) ); create table if not exists public.audit_logs ( id uuid primary key default gen_random_uuid(), tenant_id uuid references public.tenants(id) on delete cascade, actor_user_id uuid references public.platform_users(id) on delete set null, action text not null, target_type text, target_id text, details jsonb not null default '{}'::jsonb, ip_address text, user_agent text, created_at timestamptz not null default now() ); create table if not exists public.crm_config ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, enabled boolean not null default false, url text, secret_ref text, form_name text, exam_type text, timeout_sec integer, delay_sec integer, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id) ); create table if not exists public.pb_import_runs ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, source_name text not null, source_kind text not null default 'json', status text not null default 'running' check (status in ('running', 'completed', 'failed')), stats jsonb not null default '{}'::jsonb, started_at timestamptz not null default now(), finished_at timestamptz ); create table if not exists public.pb_raw_records ( run_id uuid not null references public.pb_import_runs(id) on delete cascade, tenant_id uuid not null references public.tenants(id) on delete cascade, collection_name text not null, legacy_id text not null, record jsonb not null, normalized boolean not null default false, errors jsonb not null default '[]'::jsonb, imported_at timestamptz not null default now(), primary key (run_id, collection_name, legacy_id) ); create table if not exists public.pb_import_issues ( id uuid primary key default gen_random_uuid(), run_id uuid references public.pb_import_runs(id) on delete cascade, tenant_id uuid not null references public.tenants(id) on delete cascade, collection_name text not null, legacy_id text, severity text not null check (severity in ('info', 'warning', 'error', 'critical')), issue_code text not null, message text not null, field_path text, raw_value_sample text, created_at timestamptz not null default now() ); create index if not exists idx_tenant_domains_host on public.tenant_domains(host); create index if not exists idx_memberships_tenant_user on public.tenant_memberships(tenant_id, user_id); create index if not exists idx_regions_tenant_active on public.regions(tenant_id, is_active, sort_order); create index if not exists idx_module_nodes_tenant_parent on public.module_nodes(tenant_id, parent_id, sort_order); create index if not exists idx_questions_tenant_subject on public.questions(tenant_id, subject_id); create index if not exists idx_questions_tenant_node on public.questions(tenant_id, node_id); create index if not exists idx_question_versions_question on public.question_versions(question_id, version_no desc); create index if not exists idx_orders_tenant_status on public.orders(tenant_id, status, created_at desc); create index if not exists idx_orders_tenant_user on public.orders(tenant_id, user_id, created_at desc); create index if not exists idx_entitlements_tenant_user on public.entitlements(tenant_id, user_id, status, expires_at); create index if not exists idx_raw_records_lookup on public.pb_raw_records(tenant_id, collection_name, legacy_id); create index if not exists idx_import_issues_run on public.pb_import_issues(run_id, severity, collection_name); create index if not exists idx_tenant_secrets_lookup on app_private.tenant_secrets(tenant_id, secret_scope, secret_key); alter table public.tenants enable row level security; alter table public.platform_users enable row level security; alter table public.user_identities enable row level security; alter table app_private.tenant_secrets enable row level security; drop policy if exists platform_admin_tenants on public.tenants; create policy platform_admin_tenants on public.tenants for all using (app.is_platform_admin()) with check (app.is_platform_admin()); drop policy if exists tenant_member_can_read_own_tenants on public.tenants; create policy tenant_member_can_read_own_tenants on public.tenants for select using ( exists ( select 1 from public.tenant_memberships tm join public.platform_users pu on pu.id = tm.user_id where tm.tenant_id = tenants.id and pu.auth_user_id = auth.uid() and tm.status = 'active' ) ); drop policy if exists platform_admin_platform_users on public.platform_users; create policy platform_admin_platform_users on public.platform_users for all using (app.is_platform_admin() or auth_user_id = auth.uid()) with check (app.is_platform_admin() or auth_user_id = auth.uid()); drop policy if exists platform_admin_user_identities on public.user_identities; create policy platform_admin_user_identities on public.user_identities for all using ( app.is_platform_admin() or exists ( select 1 from public.platform_users pu where pu.id = user_id and pu.auth_user_id = auth.uid() ) ) with check ( app.is_platform_admin() or exists ( select 1 from public.platform_users pu where pu.id = user_id and pu.auth_user_id = auth.uid() ) ); drop policy if exists platform_admin_tenant_secrets on app_private.tenant_secrets; create policy platform_admin_tenant_secrets on app_private.tenant_secrets for all using (app.is_platform_admin()) with check (app.is_platform_admin()); do $$ declare table_name text; begin foreach table_name in array array[ 'tenant_domains', 'tenant_branding', 'tenant_settings', 'tenant_payment_accounts', 'tenant_subscriptions', 'tenant_usage_records', 'tenant_memberships', 'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects', 'categories', 'question_banks', 'questions', 'question_versions', 'student_profiles', 'practice_sessions', 'answer_records', 'favorite_questions', 'wrong_questions', 'svip_plans', 'orders', 'order_items', 'payments', 'payment_events', 'entitlements', 'code_batches', 'activation_codes', 'coupons', 'coupon_redemptions', 'vocabulary_units', 'vocabulary_words', 'handbook_subjects', 'handbook_chapters', 'handbook_entries', 'banners', 'faqs', 'announcements', 'reports', 'audit_logs', 'crm_config', 'pb_import_runs', 'pb_raw_records', 'pb_import_issues' ] loop execute format('alter table public.%I enable row level security', table_name); execute format('drop policy if exists tenant_isolation on public.%I', table_name); execute format( 'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())', table_name ); end loop; end $$; do $$ declare table_name text; begin foreach table_name in array array[ 'tenants', 'platform_users', 'user_identities', 'tenant_domains', 'tenant_branding', 'tenant_settings', 'tenant_payment_accounts', 'tenant_subscriptions', 'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects', 'categories', 'question_banks', 'questions', 'student_profiles', 'svip_plans', 'orders', 'payments', 'code_batches', 'activation_codes', 'coupons', 'coupon_redemptions', 'vocabulary_units', 'vocabulary_words', 'handbook_subjects', 'handbook_chapters', 'handbook_entries', 'banners', 'faqs', 'announcements', 'reports', 'crm_config' ] loop execute format('drop trigger if exists set_updated_at on public.%I', table_name); execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name); end loop; end $$;