Files
gongxue-base/supabase/migrations/202606210001_core_multitenant_schema.sql
2026-06-21 21:54:43 +08:00

996 lines
38 KiB
PL/PgSQL

create extension if not exists pgcrypto;
create extension if not exists citext;
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 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 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 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 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 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 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 $$;