Files
gongxue-base/scripts/import-pocketbase/src/validate-import.ts
2026-06-30 12:47:27 +08:00

695 lines
20 KiB
TypeScript

import { closeDb, query } from './db.js';
import { loadEnv } from './env.js';
loadEnv();
type CheckStatus = 'pass' | 'warn' | 'fail';
interface CheckResult {
name: string;
status: CheckStatus;
count?: number;
message: string;
}
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const failOnWarnings = process.env.FAIL_ON_WARNINGS === 'true';
async function scalar(sql: string, params: unknown[] = []) {
const rows = await query<{ count: string }>(sql, params);
return Number(rows[0]?.count || 0);
}
function result(name: string, count: number, failMessage: string, passMessage: string, warnOnly = false): CheckResult {
if (count > 0) {
return {
name,
status: warnOnly ? 'warn' : 'fail',
count,
message: failMessage,
};
}
return { name, status: 'pass', count, message: passMessage };
}
async function tableCounts(): Promise<CheckResult[]> {
const tables = [
'platform_users',
'regions',
'region_modules',
'module_nodes',
'schools',
'majors',
'subjects',
'categories',
'questions',
'orders',
'svip_plans',
'activation_codes',
'vocabulary_units',
'vocabulary_words',
'handbook_subjects',
'handbook_chapters',
'handbook_entries',
'products',
];
const checks: CheckResult[] = [];
for (const table of tables) {
const count =
table === 'platform_users'
? await scalar('select count(*) from public.platform_users')
: await scalar(`select count(*) from public.${table} where tenant_id = $1`, [tenantId]);
checks.push({
name: `count:${table}`,
status: 'pass',
count,
message: `${table} rows: ${count}`,
});
}
return checks;
}
async function validationChecks(): Promise<CheckResult[]> {
const checks: CheckResult[] = [];
checks.push(
result(
'tenant_exists',
(await scalar('select count(*) from public.tenants where id = $1', [tenantId])) === 0 ? 1 : 0,
'Tenant seed is missing. Run Supabase seed or set TENANT_ID to an existing tenant.',
'Tenant exists.',
),
);
checks.push(
result(
'raw_records_not_normalized',
await scalar(
`
select count(*)
from public.pb_raw_records r
join public.pb_import_runs run on run.id = r.run_id
where r.tenant_id = $1
and run.id = (
select id from public.pb_import_runs
where tenant_id = $1
order by started_at desc
limit 1
)
and r.collection_name in (
'regions','region_modules','module_nodes','schools','majors','subjects',
'categories','questions','users','orders','svip_plans','codes',
'vocabulary_units','vocabulary','handbook_subjects','handbook_chapters',
'handbook_entries','banners','faqs','announcements'
)
and r.normalized = false
`,
[tenantId],
),
'Some core raw records from the latest run were not normalized.',
'All latest-run core raw records are marked normalized.',
),
);
checks.push(
result(
'questions_without_current_version',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and current_version_id is null
`,
[tenantId],
),
'Questions exist without a current version.',
'Every question has a current version.',
),
);
checks.push(
result(
'question_versions_wrong_tenant',
await scalar(
`
select count(*)
from public.questions q
join public.question_versions v on v.question_id = q.id
where q.tenant_id = $1 and v.tenant_id <> q.tenant_id
`,
[tenantId],
),
'Some question_versions have a different tenant_id from their question.',
'Question version tenant_id values match their questions.',
),
);
checks.push(
result(
'questions_unresolved_subjects',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and legacy_subject_id is not null and subject_id is null
`,
[tenantId],
),
'Some questions still have legacy_subject_id but no resolved subject_id.',
'Question subject references are resolved.',
true,
),
);
checks.push(
result(
'questions_unresolved_categories',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and legacy_category_id is not null and category_id is null
`,
[tenantId],
),
'Some questions still have legacy_category_id but no resolved category_id.',
'Question category references are resolved.',
true,
),
);
checks.push(
result(
'orders_unresolved_users',
await scalar(
`
select count(*)
from public.orders
where tenant_id = $1 and legacy_user_id is not null and user_id is null
`,
[tenantId],
),
'Some orders still have legacy_user_id but no resolved user_id.',
'Order user references are resolved.',
true,
),
);
checks.push(
result(
'orders_paid_without_payment',
await scalar(
`
select count(*)
from public.orders o
where o.tenant_id = $1
and o.status = 'paid'
and not exists (
select 1 from public.payments p
where p.tenant_id = o.tenant_id and p.order_id = o.id
)
`,
[tenantId],
),
'Paid orders exist without payment rows.',
'Paid orders have payment rows.',
),
);
checks.push(
result(
'legacy_answers_missing_user',
await scalar(
`
select count(*)
from public.answer_records ar
left join public.platform_users u on u.id = ar.user_id
where ar.tenant_id = $1
and ar.legacy_id is not null
and u.id is null
`,
[tenantId],
),
'Legacy answer_records exist without a valid user.',
'Legacy answer_records reference valid users.',
),
);
checks.push(
result(
'legacy_answers_unresolved_questions',
await scalar(
`
select count(*)
from public.answer_records
where tenant_id = $1
and legacy_question_id is not null
and question_id is null
`,
[tenantId],
),
'Some legacy answer_records kept only legacy_question_id because questions were not resolved.',
'Legacy answer_records question references are resolved.',
true,
),
);
checks.push(
result(
'wrong_questions_invalid_references',
await scalar(
`
select count(*)
from public.wrong_questions wq
left join public.platform_users u on u.id = wq.user_id
left join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
where wq.tenant_id = $1
and (u.id is null or q.id is null)
`,
[tenantId],
),
'Wrong-question rows reference missing users or questions.',
'Wrong-question rows reference valid users and questions.',
),
);
checks.push(
result(
'legacy_practice_navigation_missing_entries',
await scalar(
`
select case
when exists (
select 1 from public.questions
where tenant_id = $1 and legacy_id is not null
)
and not exists (
select 1 from public.content_entries
where tenant_id = $1
and entry_type = 'question_practice'
and legacy_id is not null
)
then 1 else 0 end
`,
[tenantId],
),
'Imported legacy questions exist but no question_practice content entries were generated.',
'Legacy question practice content entries are present when imported questions exist.',
),
);
checks.push(
result(
'legacy_practice_navigation_missing_nodes',
await scalar(
`
select case
when exists (
select 1 from public.questions
where tenant_id = $1 and legacy_id is not null
)
and not exists (
select 1 from public.content_nodes
where tenant_id = $1
and legacy_id is not null
and metadata->>'source' in (
'pocketbase.module_nodes',
'pocketbase.subjects',
'pocketbase.categories',
'pocketbase.migration_review.unresolved_category'
)
)
then 1 else 0 end
`,
[tenantId],
),
'Imported legacy questions exist but no content nodes were generated.',
'Legacy content nodes are present when imported questions exist.',
),
);
checks.push(
result(
'legacy_practice_collections_missing_items',
await scalar(
`
select count(*)
from public.question_collections qc
where qc.tenant_id = $1
and qc.legacy_id is not null
and qc.status = 'active'
and qc.question_count = 0
and exists (
select 1
from public.questions q
left join public.content_nodes cn
on cn.tenant_id = q.tenant_id
and cn.id = q.content_node_id
where q.tenant_id = qc.tenant_id
and q.status = 'published'
and (
q.content_node_id = qc.node_id
or q.category_id = qc.category_id
or (qc.category_id is null and q.subject_id = qc.subject_id)
)
)
`,
[tenantId],
),
'Active legacy question collections exist without collection items even though matching questions exist.',
'Active legacy question collections have item counts when matching questions exist.',
),
);
checks.push(
result(
'legacy_questions_without_navigation',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1
and legacy_id is not null
and status = 'published'
and (
entry_id is null
or content_node_id is null
or primary_collection_id is null
)
`,
[tenantId],
),
'Some imported published questions are not attached to entry/node/collection navigation.',
'Imported published questions are attached to entry/node/collection navigation.',
),
);
checks.push(
result(
'legacy_unresolved_categories_not_isolated',
await scalar(
`
select count(*)
from public.questions q
left join public.question_collections qc
on qc.tenant_id = q.tenant_id
and qc.id = q.primary_collection_id
left join public.content_nodes cn
on cn.tenant_id = q.tenant_id
and cn.id = q.content_node_id
where q.tenant_id = $1
and q.legacy_id is not null
and q.legacy_category_id is not null
and q.category_id is null
and q.node_id is null
and (
qc.id is null
or qc.status <> 'draft'
or qc.access_rules->>'requiresReview' <> 'true'
or cn.id is null
or cn.is_active <> false
or cn.access_rules->>'requiresReview' <> 'true'
)
`,
[tenantId],
),
'Questions with unresolved legacy categories are not isolated into draft review collections/inactive nodes.',
'Questions with unresolved legacy categories are isolated for tenant-admin review.',
),
);
checks.push(
result(
'legacy_orphan_node_questions_not_isolated',
await scalar(
`
select count(*)
from public.questions q
left join public.question_collections qc
on qc.tenant_id = q.tenant_id
and qc.id = q.primary_collection_id
left join public.content_nodes cn
on cn.tenant_id = q.tenant_id
and cn.id = q.content_node_id
left join public.content_entries ce
on ce.tenant_id = q.tenant_id
and ce.id = q.entry_id
where q.tenant_id = $1
and q.legacy_id is not null
and q.legacy_node_id is not null
and q.node_id is null
and q.category_id is null
and q.legacy_category_id is null
and not (
qc.status = 'draft'
and qc.access_rules->>'requiresReview' = 'true'
and qc.metadata->>'source' = 'pocketbase.migration_review.orphan_subject'
and cn.is_active = false
and cn.access_rules->>'requiresReview' = 'true'
and ce.visibility = 'hidden'
and ce.access_rules->>'requiresReview' = 'true'
)
and not (
qc.status = 'active'
and qc.metadata->>'source' = 'pocketbase.subjects'
and qc.metadata->>'scope' = 'subject_all'
and cn.node_type = 'subject'
and cn.marker_type = 'subject'
and cn.is_active = true
and ce.visibility <> 'hidden'
)
`,
[tenantId],
),
'Questions with unresolved legacy nodeId are neither isolated into hidden review navigation nor attached to a public subject fallback.',
'Questions with unresolved legacy nodeId are isolated or attached to a public subject fallback.',
),
);
checks.push(
result(
'legacy_node_questions_wrong_navigation',
await scalar(
`
select count(*)
from public.questions q
join public.module_nodes mn
on mn.tenant_id = q.tenant_id
and mn.id = q.node_id
left join public.content_nodes cn
on cn.tenant_id = q.tenant_id
and cn.legacy_id = 'module_node:' || mn.legacy_id
left join public.question_collections qc
on qc.tenant_id = q.tenant_id
and qc.legacy_id = 'module_node:' || mn.legacy_id || ':direct'
where q.tenant_id = $1
and q.legacy_id is not null
and q.node_id is not null
and (
cn.id is null
or q.content_node_id is distinct from cn.id
or qc.id is null
or q.primary_collection_id is distinct from qc.id
)
`,
[tenantId],
),
'Some imported questions with legacy nodeId are not attached to the matching content node/direct collection.',
'Imported questions with legacy nodeId are attached to matching content nodes and direct collections.',
),
);
checks.push(
result(
'legacy_mock_blueprints_incomplete',
await scalar(
`
select count(*)
from public.practice_blueprints
where tenant_id = $1
and mode = 'mock_exam'
and legacy_id is not null
and (
question_limit is null
or jsonb_array_length(sections) = 0
or rules->>'legacySubjectId' is null
)
`,
[tenantId],
),
'Legacy mock exam blueprints are missing limits, sections, or legacy subject traceability.',
'Legacy mock exam blueprints have limits, sections, and legacy subject traceability.',
),
);
checks.push(
result(
'referral_qrcodes_incomplete',
await scalar(
`
select count(*)
from public.referral_qrcodes
where tenant_id = $1
and (ref_code is null or scene = '' or page = '')
`,
[tenantId],
),
'Referral qrcodes are missing ref_code, scene, or page.',
'Referral qrcodes have ref_code, scene, and page.',
),
);
checks.push(
result(
'commission_settings_invalid_rate',
await scalar(
`
select count(*)
from public.tenant_commission_settings
where tenant_id = $1
and (default_rate < 0 or default_rate > 1)
`,
[tenantId],
),
'Tenant commission settings contain invalid default_rate values.',
'Tenant commission settings default_rate values are valid.',
),
);
checks.push(
result(
'entitlements_unresolved_user',
await scalar(
`
select count(*)
from public.entitlements e
left join public.platform_users u on u.id = e.user_id
where e.tenant_id = $1 and u.id is null
`,
[tenantId],
),
'Entitlements exist without a valid user.',
'Entitlements reference valid users.',
),
);
checks.push(
result(
'public_sensitive_profile_keys',
await scalar(
`
select count(*)
from public.platform_users
where raw_profile::text ~* '"(password|tokenKey|secret|sessionKey|openId|unionId|wechatSessionKey|qqOpenId|wechatOpenId|wechatUnionId)"'
`,
),
'Sensitive identity or credential keys remain in platform_users.raw_profile.',
'No sensitive identity or credential keys found in platform_users.raw_profile.',
),
);
checks.push(
result(
'student_stats_legacy_arrays',
await scalar(
`
select count(*)
from public.student_profiles
where tenant_id = $1 and (stats ? 'favorites' or stats ? 'wrongBook')
`,
[tenantId],
),
'student_profiles.stats still contains favorites/wrongBook legacy arrays.',
'student profile stats no longer contain favorites/wrongBook arrays.',
),
);
checks.push(
result(
'public_settings_sensitive_keys',
await scalar(
`
select count(*)
from public.tenant_settings
where tenant_id = $1
and public_config::text ~* '(secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey)'
`,
[tenantId],
),
'Sensitive-looking keys remain in tenant_settings.public_config.',
'No sensitive-looking keys found in tenant public settings.',
),
);
checks.push(
result(
'public_crm_secret_leak',
await scalar(
`
select count(*)
from public.crm_config
where tenant_id = $1 and secret_ref is not null and secret_ref !~ '^app_private\\.tenant_secrets:'
`,
[tenantId],
),
'CRM config contains an unsafe secret_ref value.',
'CRM config stores only private secret references.',
),
);
checks.push(
result(
'critical_import_issues',
await scalar(
`
select count(*)
from public.pb_import_issues
where tenant_id = $1
and severity = 'critical'
and run_id = (
select id from public.pb_import_runs
where tenant_id = $1
order by started_at desc
limit 1
)
`,
[tenantId],
),
'Critical import issues exist in the latest run. Review pb_import_issues before launch.',
'No critical import issues in the latest run.',
true,
),
);
return checks;
}
async function main() {
const checks = [...(await tableCounts()), ...(await validationChecks())];
let failures = 0;
let warnings = 0;
for (const check of checks) {
const prefix = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
console.log(`[${prefix}] ${check.name}: ${check.message}${check.count === undefined ? '' : ` (${check.count})`}`);
if (check.status === 'fail') failures += 1;
if (check.status === 'warn') warnings += 1;
}
console.log(`Validation complete: ${failures} failures, ${warnings} warnings.`);
if (failures > 0 || (failOnWarnings && warnings > 0)) {
process.exitCode = 1;
}
}
main()
.catch(error => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await closeDb();
});