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 $$;