feat: add vocabulary handbook import pipeline

This commit is contained in:
Codex
2026-06-22 00:11:01 +08:00
parent c1159f8745
commit b458317a81
13 changed files with 2448 additions and 186 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,14 @@ import {
upsertAssetRoute,
} from './assets.js';
import {
importHandbookRoute,
importIssuesRoute,
importJobsRoute,
importQuestionsRoute,
importVocabularyRoute,
previewHandbookImportRoute,
previewQuestionsImportRoute,
previewVocabularyImportRoute,
} from './imports.js';
import {
contentEntriesAdminRoute,
@@ -66,6 +70,10 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute],
['POST', '/api/tenant-content/imports/vocabulary', importVocabularyRoute],
['POST', '/api/tenant-content/imports/preview/handbook', previewHandbookImportRoute],
['POST', '/api/tenant-content/imports/handbook', importHandbookRoute],
['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute],

View File

@@ -1,4 +1,5 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import type pg from 'pg';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor } from './auth.js';
@@ -20,6 +21,41 @@ async function assertOptionalReference(
}
}
async function resolveOptionalContentNavigation(
client: pg.PoolClient,
tenantId: string,
entryId: string | null,
contentNodeId: string | null,
expectedEntryType: 'vocabulary' | 'handbook',
) {
let resolvedEntryId = entryId;
if (resolvedEntryId) {
const entry = await client.query<{ id: string; entry_type: string }>(
'select id, entry_type from public.content_entries where tenant_id = $1 and id = $2 limit 1',
[tenantId, resolvedEntryId],
);
if (!entry.rows[0]) throw new HttpError(400, 'content entry reference is not in this tenant', 'ENTRY_NOT_FOUND');
if (entry.rows[0].entry_type !== expectedEntryType) {
throw new HttpError(400, `content entry must be ${expectedEntryType}`, 'ENTRY_TYPE_MISMATCH');
}
}
if (contentNodeId) {
const node = await client.query<{ id: string; entry_id: string }>(
'select id, entry_id from public.content_nodes where tenant_id = $1 and id = $2 limit 1',
[tenantId, contentNodeId],
);
if (!node.rows[0]) throw new HttpError(400, 'content node reference is not in this tenant', 'CONTENT_NODE_NOT_FOUND');
if (resolvedEntryId && node.rows[0].entry_id !== resolvedEntryId) {
throw new HttpError(400, 'content node is not under the selected entry', 'CONTENT_NODE_ENTRY_MISMATCH');
}
resolvedEntryId = resolvedEntryId || node.rows[0].entry_id;
}
return { entryId: resolvedEntryId, contentNodeId };
}
async function syncPrimaryCollectionItem(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string,
@@ -652,7 +688,8 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, description, word_count as "wordCount",
select id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units
where tenant_id = $1
@@ -666,35 +703,53 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_units (
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
const item = await transaction(async client => {
const navigation = await resolveOptionalContentNavigation(
client,
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
nullableString(body.entryId),
nullableString(body.contentNodeId),
'vocabulary',
);
const result = await client.query(
`
insert into public.vocabulary_units (
id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
name, description, word_count, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item };
}
@@ -704,7 +759,8 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select id, unit_id as "unitId", word, phonetic, meaning, example,
select id, unit_id as "unitId", entry_id as "entryId",
content_node_id as "contentNodeId", word, phonetic, meaning, example,
example_translation as "exampleTranslation", difficulty, tags,
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words
@@ -720,46 +776,76 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_words (
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
example, example_translation, difficulty, tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
on conflict (id)
do update set unit_id = excluded.unit_id,
word = excluded.word,
phonetic = excluded.phonetic,
meaning = excluded.meaning,
example = excluded.example,
example_translation = excluded.example_translation,
difficulty = excluded.difficulty,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, unit_id as "unitId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
const item = await transaction(async client => {
const unitId = nullableString(body.unitId);
let inheritedEntryId: string | null = null;
let inheritedContentNodeId: string | null = null;
if (unitId) {
const unit = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
'select id, entry_id, content_node_id from public.vocabulary_units where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, unitId],
);
if (!unit.rows[0]) throw new HttpError(400, 'unitId is not in this tenant', 'VOCABULARY_UNIT_NOT_FOUND');
inheritedEntryId = unit.rows[0].entry_id;
inheritedContentNodeId = unit.rows[0].content_node_id;
}
const navigation = await resolveOptionalContentNavigation(
client,
auth.tenantId,
nullableString(body.unitId),
nullableString(body.legacyId),
requiredString(body, 'word'),
nullableString(body.phonetic),
nullableString(body.meaning),
nullableString(body.example),
nullableString(body.exampleTranslation),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
nullableString(body.entryId) || inheritedEntryId,
nullableString(body.contentNodeId) || inheritedContentNodeId,
'vocabulary',
);
const result = await client.query(
`
insert into public.vocabulary_words (
id, tenant_id, unit_id, entry_id, content_node_id, legacy_id, word,
phonetic, meaning, example, example_translation, difficulty, tags,
sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16::jsonb)
on conflict (id)
do update set unit_id = excluded.unit_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
word = excluded.word,
phonetic = excluded.phonetic,
meaning = excluded.meaning,
example = excluded.example,
example_translation = excluded.example_translation,
difficulty = excluded.difficulty,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, unit_id as "unitId", entry_id as "entryId",
content_node_id as "contentNodeId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
unitId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'word'),
nullableString(body.phonetic),
nullableString(body.meaning),
nullableString(body.example),
nullableString(body.exampleTranslation),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item };
}
@@ -767,7 +853,8 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, type, icon, color,
select id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive", metadata
from public.handbook_subjects
where tenant_id = $1
@@ -781,43 +868,58 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_subjects (
id, tenant_id, region_id, legacy_id, name, type, icon, color,
description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
const item = await transaction(async client => {
const navigation = await resolveOptionalContentNavigation(
client,
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.type),
nullableString(body.icon),
nullableString(body.color),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
nullableString(body.entryId),
nullableString(body.contentNodeId),
'handbook',
);
const result = await client.query(
`
insert into public.handbook_subjects (
id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
name, type, icon, color, description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.type),
nullableString(body.icon),
nullableString(body.color),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item };
}
@@ -826,7 +928,8 @@ export async function handbookChaptersAdminRoute(ctx: RequestContext) {
const subjectId = stringParam(ctx, 'subjectId');
const items = await query(
`
select id, subject_id as "subjectId", name, description,
select id, subject_id as "subjectId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description,
sort_order as "order", is_active as "isActive"
from public.handbook_chapters
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
@@ -840,33 +943,57 @@ export async function handbookChaptersAdminRoute(ctx: RequestContext) {
export async function upsertHandbookChapterRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_chapters (
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
on conflict (id)
do update set subject_id = excluded.subject_id,
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
const item = await transaction(async client => {
const subjectId = requiredString(body, 'subjectId');
const subject = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
'select id, entry_id, content_node_id from public.handbook_subjects where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, subjectId],
);
if (!subject.rows[0]) throw new HttpError(400, 'subjectId is not in this tenant', 'HANDBOOK_SUBJECT_NOT_FOUND');
const navigation = await resolveOptionalContentNavigation(
client,
auth.tenantId,
requiredString(body, 'subjectId'),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
nullableString(body.entryId) || subject.rows[0].entry_id,
nullableString(body.contentNodeId) || subject.rows[0].content_node_id,
'handbook',
);
const result = await client.query(
`
insert into public.handbook_chapters (
id, tenant_id, subject_id, entry_id, content_node_id, legacy_id,
name, description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11::jsonb)
on conflict (id)
do update set subject_id = excluded.subject_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, subject_id as "subjectId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description,
sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
subjectId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item };
}
@@ -875,7 +1002,8 @@ export async function handbookEntriesAdminRoute(ctx: RequestContext) {
const chapterId = stringParam(ctx, 'chapterId');
const items = await query(
`
select id, chapter_id as "chapterId", title, summary, content,
select id, chapter_id as "chapterId", entry_id as "entryId",
content_node_id as "contentNodeId", title, summary, content,
tags, sort_order as "order", is_active as "isActive"
from public.handbook_entries
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
@@ -889,37 +1017,60 @@ export async function handbookEntriesAdminRoute(ctx: RequestContext) {
export async function upsertHandbookEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_entries (
id, tenant_id, chapter_id, legacy_id, title, summary, content,
tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
on conflict (id)
do update set chapter_id = excluded.chapter_id,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
const item = await transaction(async client => {
const chapterId = requiredString(body, 'chapterId');
const chapter = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
'select id, entry_id, content_node_id from public.handbook_chapters where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, chapterId],
);
if (!chapter.rows[0]) throw new HttpError(400, 'chapterId is not in this tenant', 'HANDBOOK_CHAPTER_NOT_FOUND');
const navigation = await resolveOptionalContentNavigation(
client,
auth.tenantId,
requiredString(body, 'chapterId'),
nullableString(body.legacyId),
requiredString(body, 'title'),
nullableString(body.summary),
nullableString(body.content),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
nullableString(body.entryId) || chapter.rows[0].entry_id,
nullableString(body.contentNodeId) || chapter.rows[0].content_node_id,
'handbook',
);
const result = await client.query(
`
insert into public.handbook_entries (
id, tenant_id, chapter_id, entry_id, content_node_id, legacy_id, title,
summary, content, tags, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12, $13::jsonb)
on conflict (id)
do update set chapter_id = excluded.chapter_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, chapter_id as "chapterId", entry_id as "entryId",
content_node_id as "contentNodeId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
chapterId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'title'),
nullableString(body.summary),
nullableString(body.content),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item };
}