forked from wangziqi/gongxue-base
feat: add vocabulary handbook import pipeline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user