forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
55
supabase/config.toml
Normal file
55
supabase/config.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
project_id = "tiku-saas-local"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
port = 54321
|
||||
schemas = ["public", "storage", "graphql_public"]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
max_rows = 1000
|
||||
|
||||
[db]
|
||||
port = 54322
|
||||
shadow_port = 54320
|
||||
major_version = 15
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
port = 54329
|
||||
pool_mode = "transaction"
|
||||
default_pool_size = 20
|
||||
max_client_conn = 100
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
port = 54323
|
||||
api_url = "http://127.0.0.1:54321"
|
||||
|
||||
[inbucket]
|
||||
enabled = true
|
||||
port = 54324
|
||||
smtp_port = 54325
|
||||
pop3_port = 54326
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
file_size_limit = "100MiB"
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
site_url = "http://127.0.0.1:5173"
|
||||
additional_redirect_urls = ["http://127.0.0.1:5173", "http://localhost:5173"]
|
||||
jwt_expiry = 604800
|
||||
enable_refresh_token_rotation = true
|
||||
refresh_token_reuse_interval = 10
|
||||
enable_signup = true
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
policy = "oneshot"
|
||||
inspector_port = 8083
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
995
supabase/migrations/202606210001_core_multitenant_schema.sql
Normal file
995
supabase/migrations/202606210001_core_multitenant_schema.sql
Normal file
@@ -0,0 +1,995 @@
|
||||
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 $$;
|
||||
@@ -0,0 +1,257 @@
|
||||
create table if not exists public.products (
|
||||
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 not null,
|
||||
price_label text,
|
||||
link text,
|
||||
type text,
|
||||
tags jsonb not null default '[]'::jsonb,
|
||||
cover text,
|
||||
preview_iframe text,
|
||||
detail_images jsonb not null default '[]'::jsonb,
|
||||
sort_order integer not null default 0,
|
||||
status text not null default 'active' check (status in ('active', 'inactive', 'archived')),
|
||||
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.timelines (
|
||||
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 set null,
|
||||
legacy_id text,
|
||||
type text,
|
||||
title text not null,
|
||||
description text,
|
||||
event_date date,
|
||||
link 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.video_explanations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
title text not null,
|
||||
description text,
|
||||
video_url text,
|
||||
thumbnail_url text,
|
||||
duration_seconds integer,
|
||||
knowledge_tags jsonb not null default '[]'::jsonb,
|
||||
is_general boolean not null default false,
|
||||
subject_id uuid references public.subjects(id) on delete set null,
|
||||
legacy_subject_id text,
|
||||
difficulty 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.question_videos (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
question_id uuid references public.questions(id) on delete cascade,
|
||||
video_id uuid references public.video_explanations(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_question_id text,
|
||||
legacy_video_id text,
|
||||
video_type text not null default 'specific',
|
||||
sort_order integer not null default 0,
|
||||
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.scoreline_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,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
short_name text,
|
||||
type text,
|
||||
is_hot boolean not null default false,
|
||||
sort_order integer not null default 0,
|
||||
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.scoreline_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.scoreline_schools(id) on delete cascade,
|
||||
legacy_id text,
|
||||
name text not null,
|
||||
sort_order integer not null default 0,
|
||||
has_restriction boolean not null default false,
|
||||
restriction_desc text,
|
||||
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.scoreline_fields (
|
||||
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,
|
||||
field_key text not null,
|
||||
field_name text not null,
|
||||
field_type text,
|
||||
unit text,
|
||||
is_filter boolean not null default false,
|
||||
is_required boolean not null default false,
|
||||
is_visible boolean not null default true,
|
||||
is_trend boolean not null default false,
|
||||
options jsonb not null default '[]'::jsonb,
|
||||
placeholder text,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, region_id, field_key)
|
||||
);
|
||||
|
||||
create table if not exists public.scoreline_records (
|
||||
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.scoreline_schools(id) on delete set null,
|
||||
major_id uuid references public.scoreline_majors(id) on delete set null,
|
||||
legacy_id text,
|
||||
year integer not null,
|
||||
school_name text,
|
||||
major_name text,
|
||||
field_values 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.referral_tracks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
event_type text not null,
|
||||
ref_code text,
|
||||
ref_user_id uuid references public.platform_users(id) on delete set null,
|
||||
target_user_id uuid references public.platform_users(id) on delete set null,
|
||||
source text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
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.badges (
|
||||
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,
|
||||
description text,
|
||||
category text,
|
||||
icon_url text,
|
||||
level integer,
|
||||
unlock_type text,
|
||||
condition_field text,
|
||||
condition_operator text,
|
||||
condition_value numeric,
|
||||
condition_extra 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.user_badges (
|
||||
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 cascade,
|
||||
badge_id uuid references public.badges(id) on delete cascade,
|
||||
granted_by uuid references public.platform_users(id) on delete set null,
|
||||
legacy_id text,
|
||||
note text,
|
||||
granted_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.crm_webhook_queue (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
record_id text,
|
||||
status text not null default 'pending',
|
||||
scheduled_at timestamptz,
|
||||
attempts integer not null default 0,
|
||||
next_attempt_at timestamptz,
|
||||
last_error text,
|
||||
last_http_code integer,
|
||||
lead_id text,
|
||||
sent_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.crm_webhook_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
record_id text,
|
||||
http_code integer,
|
||||
outcome text,
|
||||
error_message text,
|
||||
lead_id text,
|
||||
request_body text,
|
||||
response_summary text,
|
||||
signed_at timestamptz,
|
||||
attempt integer,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_products_tenant_region on public.products(tenant_id, region_id, sort_order);
|
||||
create index if not exists idx_timelines_tenant_region on public.timelines(tenant_id, region_id, event_date);
|
||||
create index if not exists idx_video_explanations_tenant_subject on public.video_explanations(tenant_id, subject_id);
|
||||
create index if not exists idx_scoreline_records_tenant_year on public.scoreline_records(tenant_id, region_id, year desc);
|
||||
create index if not exists idx_referral_tracks_tenant_event on public.referral_tracks(tenant_id, event_type, created_at desc);
|
||||
create index if not exists idx_crm_queue_tenant_status on public.crm_webhook_queue(tenant_id, status, next_attempt_at);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'products', 'timelines', 'video_explanations', 'question_videos',
|
||||
'scoreline_schools', 'scoreline_majors', 'scoreline_fields', 'scoreline_records',
|
||||
'referral_tracks', 'badges', 'user_badges', 'crm_webhook_queue', 'crm_webhook_log'
|
||||
]
|
||||
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
|
||||
);
|
||||
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 $$;
|
||||
@@ -0,0 +1,180 @@
|
||||
create table if not exists public.exam_dates (
|
||||
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 set null,
|
||||
legacy_id text,
|
||||
exam_name text not null,
|
||||
exam_date date,
|
||||
exam_type 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.question_type_groups (
|
||||
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,
|
||||
legacy_id text,
|
||||
legacy_subject_id text,
|
||||
display_name text not null,
|
||||
types 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.subject_shares (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
source_subject_id uuid references public.subjects(id) on delete cascade,
|
||||
target_subject_id uuid references public.subjects(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_source_subject_id text,
|
||||
legacy_target_subject_id text,
|
||||
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.recent_practices (
|
||||
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 cascade,
|
||||
legacy_id text,
|
||||
practice_type text,
|
||||
target_legacy_id text,
|
||||
target_name text,
|
||||
progress integer not null default 0,
|
||||
color text,
|
||||
last_access_at timestamptz,
|
||||
last_practice_at timestamptz,
|
||||
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.user_word_progress (
|
||||
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 cascade,
|
||||
word_id uuid references public.vocabulary_words(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_user_id text,
|
||||
legacy_word_id text,
|
||||
status text not null default 'new' check (status in ('new', 'learning', 'mastered', 'reviewing')),
|
||||
correct_count integer not null default 0,
|
||||
wrong_count integer not null default 0,
|
||||
last_review_date timestamptz,
|
||||
next_review_date timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, user_id, word_id)
|
||||
);
|
||||
|
||||
create table if not exists public.user_word_favorites (
|
||||
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 cascade,
|
||||
word_id uuid references public.vocabulary_words(id) on delete cascade,
|
||||
legacy_id text,
|
||||
legacy_user_id text,
|
||||
legacy_word_id text,
|
||||
note text,
|
||||
favorited_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, legacy_id),
|
||||
unique (tenant_id, user_id, word_id)
|
||||
);
|
||||
|
||||
create table if not exists public.content_assets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
asset_key text,
|
||||
title text,
|
||||
category text,
|
||||
description text,
|
||||
file_name text,
|
||||
cdn_url text,
|
||||
is_public boolean not null default false,
|
||||
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.dashboard_daily_stats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
stat_date date not null,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
new_users integer not null default 0,
|
||||
new_questions integer not null default 0,
|
||||
new_orders integer not null default 0,
|
||||
new_revenue_cents integer not null default 0,
|
||||
active_users integer not null default 0,
|
||||
rebuilt_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, stat_date, legacy_region_id)
|
||||
);
|
||||
|
||||
create table if not exists public.revenue_daily_stats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
legacy_id text,
|
||||
stat_date date not null,
|
||||
region_id uuid references public.regions(id) on delete set null,
|
||||
legacy_region_id text,
|
||||
sale_type text,
|
||||
real_revenue_cents integer not null default 0,
|
||||
order_count integer not null default 0,
|
||||
code_count integer not null default 0,
|
||||
code_used integer not null default 0,
|
||||
code_estimated_cents integer not null default 0,
|
||||
estimated_revenue_cents integer not null default 0,
|
||||
rebuilt_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, stat_date, legacy_region_id, sale_type)
|
||||
);
|
||||
|
||||
create index if not exists idx_exam_dates_tenant_region on public.exam_dates(tenant_id, region_id, exam_date);
|
||||
create index if not exists idx_question_type_groups_subject on public.question_type_groups(tenant_id, subject_id, sort_order);
|
||||
create index if not exists idx_recent_practices_user on public.recent_practices(tenant_id, user_id, last_practice_at desc);
|
||||
create index if not exists idx_user_word_progress_user on public.user_word_progress(tenant_id, user_id, status);
|
||||
create index if not exists idx_content_assets_tenant_key on public.content_assets(tenant_id, asset_key);
|
||||
create index if not exists idx_dashboard_daily_stats_date on public.dashboard_daily_stats(tenant_id, stat_date desc);
|
||||
create index if not exists idx_revenue_daily_stats_date on public.revenue_daily_stats(tenant_id, stat_date desc);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'exam_dates', 'question_type_groups', 'subject_shares', 'recent_practices',
|
||||
'user_word_progress', 'user_word_favorites', 'content_assets',
|
||||
'dashboard_daily_stats', 'revenue_daily_stats'
|
||||
]
|
||||
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
|
||||
);
|
||||
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 $$;
|
||||
122
supabase/migrations/202606210004_auth_china_login_extensions.sql
Normal file
122
supabase/migrations/202606210004_auth_china_login_extensions.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
create table if not exists public.tenant_auth_providers (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
provider text not null,
|
||||
status text not null default 'disabled' check (status in ('active', 'disabled', 'testing')),
|
||||
display_name text,
|
||||
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)
|
||||
);
|
||||
|
||||
comment on table public.tenant_auth_providers is
|
||||
'Public, non-secret auth provider settings for each tenant. Secrets stay in app_private.tenant_secrets or an external vault.';
|
||||
|
||||
create table if not exists public.sms_verification_codes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
phone text not null,
|
||||
purpose text not null default 'login' check (purpose in ('login', 'bind_phone', 'reset_password')),
|
||||
code_hash text not null,
|
||||
provider text not null default 'mock',
|
||||
status text not null default 'pending' check (status in ('pending', 'sent', 'verified', 'expired', 'blocked')),
|
||||
attempts integer not null default 0,
|
||||
expires_at timestamptz not null,
|
||||
consumed_at timestamptz,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.sms_verification_codes is
|
||||
'SMS verification records. Plain verification codes are never stored; only one-way hashes are kept.';
|
||||
|
||||
create table if not exists public.auth_login_events (
|
||||
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,
|
||||
provider text not null,
|
||||
identifier text,
|
||||
result text not null check (result in ('sent', 'success', 'failed', 'blocked')),
|
||||
failure_code text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists app_private.auth_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,
|
||||
token_hash text not null unique,
|
||||
provider text not null,
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table app_private.auth_sessions is
|
||||
'API-issued session token hashes for the migration period before full Supabase Auth JWT adoption.';
|
||||
|
||||
create index if not exists idx_auth_providers_tenant_status
|
||||
on public.tenant_auth_providers(tenant_id, provider, status);
|
||||
|
||||
create index if not exists idx_sms_codes_tenant_phone_purpose
|
||||
on public.sms_verification_codes(tenant_id, phone, purpose, expires_at desc);
|
||||
|
||||
create index if not exists idx_sms_codes_pending_lookup
|
||||
on public.sms_verification_codes(tenant_id, phone, purpose, created_at desc)
|
||||
where consumed_at is null and status in ('pending', 'sent');
|
||||
|
||||
create index if not exists idx_auth_login_events_tenant_user
|
||||
on public.auth_login_events(tenant_id, user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_auth_sessions_user
|
||||
on app_private.auth_sessions(tenant_id, user_id, expires_at desc)
|
||||
where revoked_at is null;
|
||||
|
||||
alter table public.tenant_auth_providers enable row level security;
|
||||
alter table public.sms_verification_codes enable row level security;
|
||||
alter table public.auth_login_events enable row level security;
|
||||
alter table app_private.auth_sessions enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.tenant_auth_providers;
|
||||
create policy tenant_isolation on public.tenant_auth_providers
|
||||
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());
|
||||
|
||||
drop policy if exists tenant_isolation on public.sms_verification_codes;
|
||||
create policy tenant_isolation on public.sms_verification_codes
|
||||
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());
|
||||
|
||||
drop policy if exists tenant_isolation on public.auth_login_events;
|
||||
create policy tenant_isolation on public.auth_login_events
|
||||
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());
|
||||
|
||||
drop policy if exists platform_admin_auth_sessions on app_private.auth_sessions;
|
||||
create policy platform_admin_auth_sessions on app_private.auth_sessions
|
||||
for all
|
||||
using (app.is_platform_admin())
|
||||
with check (app.is_platform_admin());
|
||||
|
||||
drop trigger if exists set_updated_at on public.tenant_auth_providers;
|
||||
create trigger set_updated_at
|
||||
before update on public.tenant_auth_providers
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on app_private.auth_sessions;
|
||||
create trigger set_updated_at
|
||||
before update on app_private.auth_sessions
|
||||
for each row execute function app.touch_updated_at();
|
||||
181
supabase/migrations/202606210005_platform_admin_billing.sql
Normal file
181
supabase/migrations/202606210005_platform_admin_billing.sql
Normal file
@@ -0,0 +1,181 @@
|
||||
create table if not exists public.platform_saas_plans (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
description text,
|
||||
billing_cycle text not null default 'yearly' check (billing_cycle in ('monthly', 'quarterly', 'yearly', 'one_time')),
|
||||
base_amount_cents integer not null default 0,
|
||||
currency text not null default 'CNY',
|
||||
included_quotas jsonb not null default '{}'::jsonb,
|
||||
overage_prices jsonb not null default '{}'::jsonb,
|
||||
feature_flags jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'active' check (status in ('active', 'archived')),
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_billing_profiles (
|
||||
tenant_id uuid primary key references public.tenants(id) on delete cascade,
|
||||
billing_name text,
|
||||
tax_id text,
|
||||
contact_name text,
|
||||
contact_phone text,
|
||||
contact_email citext,
|
||||
billing_address text,
|
||||
invoice_title text,
|
||||
invoice_type text check (invoice_type in ('none', 'normal_vat', 'special_vat')),
|
||||
bank_name text,
|
||||
bank_account_masked 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.tenant_invoices (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_no text not null unique,
|
||||
invoice_type text not null default 'subscription' check (invoice_type in ('subscription', 'service_fee', 'usage_overage', 'manual_adjustment')),
|
||||
status text not null default 'draft' check (status in ('draft', 'issued', 'paid', 'void', 'overdue')),
|
||||
currency text not null default 'CNY',
|
||||
subtotal_cents integer not null default 0,
|
||||
discount_cents integer not null default 0,
|
||||
tax_cents integer not null default 0,
|
||||
total_cents integer not null default 0,
|
||||
paid_cents integer not null default 0,
|
||||
balance_cents integer not null default 0,
|
||||
billing_period_start date,
|
||||
billing_period_end date,
|
||||
due_date date,
|
||||
issued_at timestamptz,
|
||||
paid_at timestamptz,
|
||||
note text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_invoice_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
|
||||
item_type text not null default 'subscription',
|
||||
item_ref_id uuid,
|
||||
description text not null,
|
||||
quantity numeric(12,2) not null default 1,
|
||||
unit_amount_cents integer not null default 0,
|
||||
amount_cents integer not null default 0,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.tenant_invoice_payments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
|
||||
payment_no text not null unique,
|
||||
provider text not null default 'manual',
|
||||
method text,
|
||||
status text not null default 'paid' check (status in ('pending', 'paid', 'failed', 'refunded')),
|
||||
amount_cents integer not null default 0,
|
||||
paid_at timestamptz,
|
||||
provider_trade_no text,
|
||||
received_by uuid references public.platform_users(id) on delete set null,
|
||||
raw_payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_saas_plans_status on public.platform_saas_plans(status, sort_order);
|
||||
create index if not exists idx_tenant_subscriptions_tenant_status on public.tenant_subscriptions(tenant_id, status, expires_at desc);
|
||||
create index if not exists idx_tenant_usage_records_lookup on public.tenant_usage_records(tenant_id, metric_key, period_start, period_end);
|
||||
create index if not exists idx_tenant_invoices_tenant_status on public.tenant_invoices(tenant_id, status, due_date desc);
|
||||
create index if not exists idx_tenant_invoice_items_invoice on public.tenant_invoice_items(invoice_id);
|
||||
create index if not exists idx_tenant_invoice_payments_invoice on public.tenant_invoice_payments(invoice_id, status);
|
||||
|
||||
alter table public.platform_saas_plans enable row level security;
|
||||
alter table public.tenant_billing_profiles enable row level security;
|
||||
alter table public.tenant_invoices enable row level security;
|
||||
alter table public.tenant_invoice_items enable row level security;
|
||||
alter table public.tenant_invoice_payments enable row level security;
|
||||
|
||||
drop policy if exists platform_admin_saas_plans on public.platform_saas_plans;
|
||||
create policy platform_admin_saas_plans on public.platform_saas_plans
|
||||
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_billing_profiles',
|
||||
'tenant_invoices',
|
||||
'tenant_invoice_items',
|
||||
'tenant_invoice_payments'
|
||||
]
|
||||
loop
|
||||
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[
|
||||
'platform_saas_plans',
|
||||
'tenant_billing_profiles',
|
||||
'tenant_invoices',
|
||||
'tenant_invoice_payments'
|
||||
]
|
||||
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 $$;
|
||||
|
||||
insert into public.platform_saas_plans (
|
||||
code, name, description, billing_cycle, base_amount_cents,
|
||||
included_quotas, overage_prices, feature_flags, sort_order
|
||||
)
|
||||
values
|
||||
(
|
||||
'starter_yearly',
|
||||
'合作商基础版',
|
||||
'适合单地区题库合作商,含基础品牌与域名能力。',
|
||||
'yearly',
|
||||
980000,
|
||||
'{"students":1000,"questions":5000,"storageGb":20}'::jsonb,
|
||||
'{"studentsExtraPerYearCents":500,"storageGbPerYearCents":12000}'::jsonb,
|
||||
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":false}'::jsonb,
|
||||
10
|
||||
),
|
||||
(
|
||||
'pro_yearly',
|
||||
'合作商专业版',
|
||||
'适合多地区、多课程运营,支持更多运营工具。',
|
||||
'yearly',
|
||||
1980000,
|
||||
'{"students":5000,"questions":30000,"storageGb":100}'::jsonb,
|
||||
'{"studentsExtraPerYearCents":300,"storageGbPerYearCents":9000}'::jsonb,
|
||||
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":true,"crmWebhook":true}'::jsonb,
|
||||
20
|
||||
)
|
||||
on conflict (code)
|
||||
do update set name = excluded.name,
|
||||
description = excluded.description,
|
||||
billing_cycle = excluded.billing_cycle,
|
||||
base_amount_cents = excluded.base_amount_cents,
|
||||
included_quotas = excluded.included_quotas,
|
||||
overage_prices = excluded.overage_prices,
|
||||
feature_flags = excluded.feature_flags,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now();
|
||||
150
supabase/migrations/202606210006_growth_referral_crm.sql
Normal file
150
supabase/migrations/202606210006_growth_referral_crm.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
create table if not exists public.referral_codes (
|
||||
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,
|
||||
code citext not null,
|
||||
status text not null default 'active' check (status in ('active', 'disabled')),
|
||||
channel text,
|
||||
landing_path text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, user_id),
|
||||
unique (tenant_id, code)
|
||||
);
|
||||
|
||||
comment on table public.referral_codes is
|
||||
'Tenant-scoped referral codes for sales, agents, teachers, operators, and other permitted referrers.';
|
||||
|
||||
create table if not exists public.referral_leads (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
student_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
referrer_user_id uuid references public.platform_users(id) on delete set null,
|
||||
ref_code citext,
|
||||
source text,
|
||||
first_track_id uuid references public.referral_tracks(id) on delete set null,
|
||||
bind_type text not null default 'first_touch' check (bind_type in ('first_touch', 'manual', 'imported')),
|
||||
status text not null default 'protected' check (status in ('protected', 'invalid', 'released')),
|
||||
protected_until timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
bound_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, student_user_id)
|
||||
);
|
||||
|
||||
comment on table public.referral_leads is
|
||||
'First-binding lead ownership. Once a student is bound to a referrer, normal scan/share events cannot rebind the lead.';
|
||||
|
||||
create table if not exists public.referral_team_edges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
member_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
leader_user_id uuid references public.platform_users(id) on delete set null,
|
||||
relation_type text not null default 'sales_team' check (relation_type in ('sales_team', 'agent_network', 'teacher_class')),
|
||||
status text not null default 'active' check (status in ('active', 'disabled')),
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, member_user_id, relation_type)
|
||||
);
|
||||
|
||||
create table if not exists public.referral_qrcodes (
|
||||
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,
|
||||
ref_code citext not null,
|
||||
scene text not null,
|
||||
page text not null default 'pages/index/index',
|
||||
provider text not null default 'wechat-miniapp',
|
||||
qrcode_url text,
|
||||
status text not null default 'pending' check (status in ('pending', 'ready', 'failed', 'disabled')),
|
||||
error_message text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider, scene, page)
|
||||
);
|
||||
|
||||
alter table public.referral_tracks
|
||||
add column if not exists metadata jsonb not null default '{}'::jsonb,
|
||||
add column if not exists lead_id uuid references public.referral_leads(id) on delete set null;
|
||||
|
||||
alter table public.crm_webhook_queue
|
||||
add column if not exists source text,
|
||||
add column if not exists payload jsonb not null default '{}'::jsonb,
|
||||
add column if not exists idempotency_key text,
|
||||
add column if not exists target_url text;
|
||||
|
||||
alter table public.crm_webhook_log
|
||||
add column if not exists request_payload jsonb not null default '{}'::jsonb;
|
||||
|
||||
create unique index if not exists idx_crm_queue_tenant_record_id
|
||||
on public.crm_webhook_queue(tenant_id, record_id)
|
||||
where record_id is not null;
|
||||
|
||||
create unique index if not exists idx_crm_queue_tenant_idempotency
|
||||
on public.crm_webhook_queue(tenant_id, idempotency_key)
|
||||
where idempotency_key is not null;
|
||||
|
||||
create index if not exists idx_referral_codes_tenant_code
|
||||
on public.referral_codes(tenant_id, code, status);
|
||||
|
||||
create index if not exists idx_referral_leads_referrer
|
||||
on public.referral_leads(tenant_id, referrer_user_id, bound_at desc);
|
||||
|
||||
create index if not exists idx_referral_team_leader
|
||||
on public.referral_team_edges(tenant_id, leader_user_id, status);
|
||||
|
||||
create index if not exists idx_referral_tracks_lead
|
||||
on public.referral_tracks(tenant_id, lead_id, created_at desc);
|
||||
|
||||
alter table public.referral_codes enable row level security;
|
||||
alter table public.referral_leads enable row level security;
|
||||
alter table public.referral_team_edges enable row level security;
|
||||
alter table public.referral_qrcodes enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_codes;
|
||||
create policy tenant_isolation on public.referral_codes
|
||||
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());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_leads;
|
||||
create policy tenant_isolation on public.referral_leads
|
||||
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());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_team_edges;
|
||||
create policy tenant_isolation on public.referral_team_edges
|
||||
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());
|
||||
|
||||
drop policy if exists tenant_isolation on public.referral_qrcodes;
|
||||
create policy tenant_isolation on public.referral_qrcodes
|
||||
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());
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_codes;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_codes
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_leads;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_leads
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_team_edges;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_team_edges
|
||||
for each row execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.referral_qrcodes;
|
||||
create trigger set_updated_at
|
||||
before update on public.referral_qrcodes
|
||||
for each row execute function app.touch_updated_at();
|
||||
164
supabase/migrations/202606210007_content_import_assets.sql
Normal file
164
supabase/migrations/202606210007_content_import_assets.sql
Normal file
@@ -0,0 +1,164 @@
|
||||
alter table public.content_assets
|
||||
add column if not exists asset_type text not null default 'document',
|
||||
add column if not exists storage_provider text not null default 'external_url',
|
||||
add column if not exists bucket text,
|
||||
add column if not exists object_key text,
|
||||
add column if not exists mime_type text,
|
||||
add column if not exists file_size_bytes bigint,
|
||||
add column if not exists checksum_sha256 text,
|
||||
add column if not exists visibility text not null default 'tenant',
|
||||
add column if not exists region_id uuid references public.regions(id) on delete set null,
|
||||
add column if not exists subject_id uuid references public.subjects(id) on delete set null,
|
||||
add column if not exists category_id uuid references public.categories(id) on delete set null,
|
||||
add column if not exists node_id uuid references public.module_nodes(id) on delete set null,
|
||||
add column if not exists preview_url text,
|
||||
add column if not exists status text not null default 'active',
|
||||
add column if not exists sort_order integer not null default 0,
|
||||
add column if not exists access_rules jsonb not null default '{}'::jsonb,
|
||||
add column if not exists created_by uuid references public.platform_users(id) on delete set null,
|
||||
add column if not exists updated_by uuid references public.platform_users(id) on delete set null,
|
||||
add column if not exists source text not null default 'manual',
|
||||
add column if not exists download_count integer not null default 0;
|
||||
|
||||
update public.content_assets
|
||||
set visibility = case when is_public then 'public' else visibility end,
|
||||
storage_provider = case when cdn_url is not null and cdn_url <> '' then 'external_url' else storage_provider end
|
||||
where visibility = 'tenant' or storage_provider = 'external_url';
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_asset_type_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_asset_type_check
|
||||
check (asset_type in ('pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_storage_provider_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_storage_provider_check
|
||||
check (storage_provider in ('external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_visibility_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_visibility_check
|
||||
check (visibility in ('public', 'tenant', 'members', 'svip', 'private'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_status_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_status_check
|
||||
check (status in ('draft', 'active', 'archived'));
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_file_size_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_file_size_check
|
||||
check (file_size_bytes is null or file_size_bytes >= 0);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_download_count_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_download_count_check
|
||||
check (download_count >= 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create table if not exists public.content_import_jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
import_type text not null check (import_type in ('questions', 'vocabulary', 'handbook', 'scoreline', 'assets', 'videos')),
|
||||
source_format text not null default 'json' check (source_format in ('json', 'excel', 'csv', 'pocketbase', 'api')),
|
||||
status text not null default 'preview' check (status in ('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')),
|
||||
source_name text,
|
||||
source_hash text,
|
||||
target_region_id uuid references public.regions(id) on delete set null,
|
||||
target_subject_id uuid references public.subjects(id) on delete set null,
|
||||
target_category_id uuid references public.categories(id) on delete set null,
|
||||
target_node_id uuid references public.module_nodes(id) on delete set null,
|
||||
target_question_bank_id uuid references public.question_banks(id) on delete set null,
|
||||
dry_run boolean not null default true,
|
||||
total_count integer not null default 0,
|
||||
valid_count integer not null default 0,
|
||||
error_count integer not null default 0,
|
||||
warning_count integer not null default 0,
|
||||
inserted_count integer not null default 0,
|
||||
updated_count integer not null default 0,
|
||||
skipped_count integer not null default 0,
|
||||
summary jsonb not null default '{}'::jsonb,
|
||||
raw_payload jsonb not null default '[]'::jsonb,
|
||||
normalized_payload jsonb not null default '[]'::jsonb,
|
||||
error_message text,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.content_import_items (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
|
||||
row_no integer not null,
|
||||
external_id text,
|
||||
status text not null default 'valid' check (status in ('valid', 'invalid', 'inserted', 'updated', 'skipped', 'failed')),
|
||||
target_type text,
|
||||
target_id uuid,
|
||||
source_payload jsonb not null default '{}'::jsonb,
|
||||
normalized_payload jsonb not null default '{}'::jsonb,
|
||||
content_hash text,
|
||||
issues_count integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (job_id, row_no)
|
||||
);
|
||||
|
||||
create table if not exists public.content_import_issues (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
|
||||
item_id uuid references public.content_import_items(id) on delete cascade,
|
||||
row_no integer,
|
||||
severity text not null default 'error' check (severity in ('error', 'warning')),
|
||||
code text not null,
|
||||
field_path text,
|
||||
message text not null,
|
||||
details jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_content_assets_filters
|
||||
on public.content_assets(tenant_id, status, visibility, asset_type, region_id, subject_id, category_id, sort_order);
|
||||
create index if not exists idx_content_assets_object
|
||||
on public.content_assets(tenant_id, storage_provider, bucket, object_key);
|
||||
create index if not exists idx_content_assets_checksum
|
||||
on public.content_assets(tenant_id, checksum_sha256)
|
||||
where checksum_sha256 is not null;
|
||||
|
||||
create index if not exists idx_content_import_jobs_tenant_status
|
||||
on public.content_import_jobs(tenant_id, import_type, status, created_at desc);
|
||||
create index if not exists idx_content_import_items_job_status
|
||||
on public.content_import_items(tenant_id, job_id, status, row_no);
|
||||
create index if not exists idx_content_import_issues_job
|
||||
on public.content_import_issues(tenant_id, job_id, severity, row_no);
|
||||
|
||||
do $$
|
||||
declare
|
||||
table_name text;
|
||||
begin
|
||||
foreach table_name in array array[
|
||||
'content_import_jobs', 'content_import_items', 'content_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
|
||||
);
|
||||
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 $$;
|
||||
47
supabase/seed.sql
Normal file
47
supabase/seed.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
insert into public.tenants (id, slug, name, status, mode)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'master',
|
||||
'升本刷题通主租户',
|
||||
'active',
|
||||
'platform_owned'
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'localhost',
|
||||
'system',
|
||||
'active',
|
||||
true
|
||||
)
|
||||
on conflict (host) do nothing;
|
||||
|
||||
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'升本刷题通',
|
||||
'刷题通',
|
||||
'多租户专升本题库 SaaS'
|
||||
)
|
||||
on conflict (tenant_id) do nothing;
|
||||
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'{"enableStore":true,"enableLeaderboard":true,"enableVocabulary":true,"enableHandbook":true,"enableScoreline":true}'::jsonb,
|
||||
'{"enableTenantManagement":true,"enableQuestionCRUD":true,"enableRevenueLedger":true,"enableMarketing":true}'::jsonb,
|
||||
'{"examDate":"","appUrl":"http://127.0.0.1:5173"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id) do nothing;
|
||||
|
||||
insert into public.tenant_auth_providers (tenant_id, provider, status, display_name, config_public)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'mock',
|
||||
'testing',
|
||||
'本地模拟短信',
|
||||
'{"channel":"local-dev"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
Reference in New Issue
Block a user