task-p.5.1

This commit is contained in:
phaichayon
2026-06-30 09:21:34 +07:00
parent 4570495a77
commit e4573ac0c9
28 changed files with 8566 additions and 1875 deletions

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { duplicateDocumentTemplateVersion } from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(_request: Request, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateCreate,
});
const version = await duplicateDocumentTemplateVersion(
id,
organization.id,
session.user.id,
);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_version',
entityId: version.id,
action: 'duplicate',
afterData: version,
});
return NextResponse.json({
success: true,
message: 'Document template version duplicated successfully',
version,
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable duplicate document template version' },
{ status: 500 },
);
}
}

View File

@@ -915,6 +915,7 @@ export const crmDocumentTemplateVersions = pgTable(
version: text('version').notNull(),
filePath: text('file_path'),
schemaJson: jsonb('schema_json').notNull(),
metadataJson: jsonb('metadata_json'),
previewImageUrl: text('preview_image_url'),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),

View File

@@ -14,10 +14,12 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
activateDocumentTemplateVersionMutation,
archiveDocumentTemplateVersionMutation,
compareDocumentTemplateVersionsMutation,
duplicateDocumentTemplateVersionMutation,
previewDocumentTemplateVersionMutation,
publishDocumentTemplateVersionMutation,
rollbackDocumentTemplateVersionMutation,
@@ -26,8 +28,10 @@ import {
import { documentTemplateVersionManagementQueryOptions } from '../queries';
import type {
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail,
DocumentTemplateVersionValidationSummary,
DocumentTemplateVersionVisualSummary,
} from '../types';
function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') {
@@ -66,12 +70,12 @@ function SummaryCard({
return (
<div className='rounded-lg bg-muted/40 p-3'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='mt-1 text-sm font-medium'>{value}</div>
<div className='mt-1 text-sm font-medium break-words'>{value}</div>
</div>
);
}
function IssueList({
function StringList({
title,
items,
}: {
@@ -96,28 +100,35 @@ function IssueList({
);
}
function RuntimeIssueList({
function ValidationIssues({
validation,
}: {
validation: DocumentTemplateVersionValidationSummary | null | undefined;
}) {
if (!validation?.runtimeIssues.length) {
if (!validation?.issues.length) {
return null;
}
return (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>Runtime Issues</div>
<div className='text-sm font-medium'>Template Health Issues</div>
<div className='space-y-2'>
{validation.runtimeIssues.map((issue) => (
<div key={`${issue.code}-${issue.message}`} className='rounded-md bg-muted/40 p-2 text-sm'>
<div className='flex items-center gap-2'>
{validation.issues.map((issue) => (
<div
key={`${issue.code}-${issue.location}-${issue.message}`}
className='rounded-md bg-muted/40 p-3 text-sm'
>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}>
{issue.severity}
</Badge>
<span className='font-medium'>{issue.code}</span>
<span className='text-muted-foreground'>{issue.location}</span>
</div>
<div className='text-muted-foreground mt-1'>{issue.message}</div>
<div className='mt-1'>{issue.message}</div>
{issue.suggestedFix ? (
<div className='text-muted-foreground mt-1'>{issue.suggestedFix}</div>
) : null}
</div>
))}
</div>
@@ -125,6 +136,45 @@ function RuntimeIssueList({
);
}
function AuditSummary({
audit,
visual,
}: {
audit: DocumentTemplateVersionAuditSummary | null;
visual: DocumentTemplateVersionVisualSummary | null | undefined;
}) {
if (!audit) {
return (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No audit summary available.
</div>
);
}
return (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Audit'
value={<Badge variant={getStatusBadgeVariant(audit.status)}>{audit.status}</Badge>}
/>
<SummaryCard
label='Visual Regression'
value={
<Badge variant={getStatusBadgeVariant(audit.visualRegressionStatus)}>
{audit.visualRegressionStatus}
</Badge>
}
/>
<SummaryCard label='Audited At' value={audit.auditedAt ?? 'Not available'} />
<SummaryCard label='Runtime Version' value={audit.runtimeVersion ?? 'Not set'} />
<SummaryCard label='Visual Generated' value={visual?.generatedAt ?? 'Not run'} />
<SummaryCard label='Changed Pages' value={visual?.changedPages ?? 0} />
<SummaryCard label='Layout Differences' value={visual?.layoutDifferences ?? 0} />
<SummaryCard label='Visual Status' value={visual?.status ?? 'NOT_RUN'} />
</div>
);
}
function CompareDialog({
open,
onOpenChange,
@@ -141,6 +191,10 @@ function CompareDialog({
const [rightVersionId, setRightVersionId] = React.useState(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
const [comparison, setComparison] = React.useState<
DocumentTemplateVersionCompareResponse['comparison'] | null
>(null);
const compareMutation = useMutation({
...compareDocumentTemplateVersionsMutation,
onError: (error) =>
@@ -149,6 +203,7 @@ function CompareDialog({
React.useEffect(() => {
if (open) {
setComparison(null);
setRightVersionId(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
@@ -161,29 +216,21 @@ function CompareDialog({
leftVersionId: currentVersion.id,
rightVersionId,
});
const metadataCount = result.comparison.metadataChanges.length;
const placeholderCount =
result.comparison.placeholderOnlyLeft.length +
result.comparison.placeholderOnlyRight.length;
const mappingCount = result.comparison.mappingDifferences.length;
toast.success(
`Compare complete: ${metadataCount} metadata, ${placeholderCount} placeholders, ${mappingCount} mappings`,
);
onOpenChange(false);
setComparison(result.comparison);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>Compare Versions</DialogTitle>
<DialogDescription>
Compare current version against another version in the same template family.
Review metadata, placeholders, mappings, schema fields, and health state
without opening raw JSON manually.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Base Version</div>
<div className='rounded-lg border px-3 py-2 text-sm'>{currentVersion.version}</div>
@@ -205,6 +252,123 @@ function CompareDialog({
</select>
</div>
</div>
{comparison ? (
<Tabs defaultValue='metadata' className='space-y-4'>
<TabsList className='flex flex-wrap'>
<TabsTrigger value='metadata'>Metadata</TabsTrigger>
<TabsTrigger value='placeholders'>Placeholders</TabsTrigger>
<TabsTrigger value='mappings'>Mappings</TabsTrigger>
<TabsTrigger value='schema'>Schema</TabsTrigger>
<TabsTrigger value='health'>Health</TabsTrigger>
</TabsList>
<TabsContent value='metadata' className='space-y-3'>
{comparison.metadataChanges.length ? (
comparison.metadataChanges.map((change) => (
<div key={change.field} className='rounded-lg border p-3 text-sm'>
<div className='font-medium'>{change.field}</div>
<div className='text-muted-foreground mt-1'>
Left: {String(change.left ?? 'null')}
</div>
<div className='text-muted-foreground'>
Right: {String(change.right ?? 'null')}
</div>
</div>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No metadata differences detected.
</div>
)}
</TabsContent>
<TabsContent value='placeholders' className='space-y-3'>
<StringList
title='Only In Base Version'
items={comparison.placeholderOnlyLeft}
/>
<StringList
title='Only In Compared Version'
items={comparison.placeholderOnlyRight}
/>
{!comparison.placeholderOnlyLeft.length &&
!comparison.placeholderOnlyRight.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Placeholder sets are identical.
</div>
) : null}
</TabsContent>
<TabsContent value='mappings' className='space-y-3'>
{comparison.mappingDifferences.length ? (
comparison.mappingDifferences.map((difference) => (
<div
key={`${difference.placeholderKey}-${difference.difference}`}
className='rounded-lg border p-3 text-sm'
>
<div className='font-medium'>{difference.placeholderKey}</div>
<div className='text-muted-foreground mt-1'>
{difference.difference}
</div>
</div>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No mapping differences detected.
</div>
)}
</TabsContent>
<TabsContent value='schema' className='space-y-3'>
<StringList
title='Added Fields'
items={comparison.fieldSchemaChanges.addedFields}
/>
<StringList
title='Removed Fields'
items={comparison.fieldSchemaChanges.removedFields}
/>
{comparison.fieldSchemaChanges.changedFields.length ? (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>Changed Field Types</div>
{comparison.fieldSchemaChanges.changedFields.map((item) => (
<div key={item.field} className='text-muted-foreground text-sm'>
{item.field}: {item.difference}
</div>
))}
</div>
) : null}
{comparison.jsonDiff.length ? (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>JSON Differences</div>
{comparison.jsonDiff.map((item) => (
<div key={item.path} className='text-muted-foreground text-sm'>
{item.path}: {String(item.left ?? 'null')} {'->'}{' '}
{String(item.right ?? 'null')}
</div>
))}
</div>
) : null}
</TabsContent>
<TabsContent value='health' className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2'>
<SummaryCard
label='Base Validation'
value={comparison.leftValidation.status}
/>
<SummaryCard
label='Compared Validation'
value={comparison.rightValidation.status}
/>
</div>
<ValidationIssues validation={comparison.leftValidation} />
<ValidationIssues validation={comparison.rightValidation} />
</TabsContent>
</Tabs>
) : null}
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Close
@@ -267,6 +431,12 @@ export function TemplateVersionManagementPanel({
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Archive failed'),
});
const duplicateMutation = useMutation({
...duplicateDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Draft duplicate created'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Duplicate failed'),
});
const previewMutation = useMutation({
...previewDocumentTemplateVersionMutation,
onSuccess: (result) => {
@@ -280,37 +450,7 @@ export function TemplateVersionManagementPanel({
const validation = managedVersion.validationSummary ?? null;
const audit = managedVersion.auditSummary ?? null;
function renderAuditSummary(auditSummary: DocumentTemplateVersionAuditSummary | null) {
if (!auditSummary) {
return <div className='text-muted-foreground text-sm'>No audit summary available.</div>;
}
return (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Audit'
value={<Badge variant={getStatusBadgeVariant(auditSummary.status)}>{auditSummary.status}</Badge>}
/>
<SummaryCard
label='Visual Regression'
value={
<Badge variant={getStatusBadgeVariant(auditSummary.visualRegressionStatus)}>
{auditSummary.visualRegressionStatus}
</Badge>
}
/>
<SummaryCard
label='Audited At'
value={auditSummary.auditedAt ?? 'Not available'}
/>
<SummaryCard
label='Runtime Version'
value={auditSummary.runtimeVersion ?? 'Not set'}
/>
</div>
);
}
const visual = managedVersion.visualSummary ?? null;
return (
<>
@@ -332,6 +472,7 @@ export function TemplateVersionManagementPanel({
) : null}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
@@ -341,6 +482,17 @@ export function TemplateVersionManagementPanel({
<Icons.post className='mr-2 h-4 w-4' />
Preview
</Button>
<Button
variant='outline'
onClick={() =>
duplicateMutation.mutate({ templateId, versionId: version.id })
}
isLoading={duplicateMutation.isPending}
disabled={managedVersion.lifecycleStatus === 'archived'}
>
<Icons.add className='mr-2 h-4 w-4' />
Duplicate as Draft
</Button>
<Button variant='outline' onClick={() => setCompareOpen(true)}>
<Icons.share className='mr-2 h-4 w-4' />
Compare
@@ -428,24 +580,34 @@ export function TemplateVersionManagementPanel({
value={validation.validatedAt ?? 'Not validated'}
/>
<SummaryCard
label='Required Missing'
value={validation.requiredFieldsMissing.length}
label='Health Status'
value={validation.healthStatus}
/>
<SummaryCard
label='Unmapped Placeholders'
value={validation.unmappedPlaceholders.length}
label='Suggested Next Step'
value={validation.suggestedNextStep ?? '-'}
/>
</div>
<IssueList
<StringList
title='Required Fields Missing'
items={validation.requiredFieldsMissing}
/>
<IssueList title='Duplicate Fields' items={validation.duplicateFields} />
<IssueList
<StringList title='Duplicate Fields' items={validation.duplicateFields} />
<StringList title='Orphan Mappings' items={validation.orphanMappings} />
<StringList
title='Unmapped Placeholders'
items={validation.unmappedPlaceholders}
/>
<RuntimeIssueList validation={validation} />
<StringList
title='Unsupported Schema Types'
items={validation.unsupportedSchemaTypes}
/>
<StringList
title='Section Marker Issues'
items={validation.sectionMarkerIssues}
/>
<ValidationIssues validation={validation} />
</div>
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
@@ -456,7 +618,7 @@ export function TemplateVersionManagementPanel({
<div className='space-y-3'>
<div className='font-medium'>Audit Summary</div>
{renderAuditSummary(audit)}
<AuditSummary audit={audit} visual={visual} />
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
createDocumentTemplateVersion,
deleteDocumentTemplate,
deleteDocumentTemplateMapping,
duplicateDocumentTemplateVersion,
previewDocumentTemplateVersion,
publishDocumentTemplateVersion,
rollbackDocumentTemplateVersion,
@@ -254,6 +255,25 @@ export const archiveDocumentTemplateVersionMutation = mutationOptions({
},
});
export const duplicateDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => duplicateDocumentTemplateVersion(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateTemplateLists(),
invalidateTemplateDetail(variables.templateId),
invalidateVersionManagement(variables.versionId),
]);
}
},
});
export const previewDocumentTemplateVersionMutation = mutationOptions({
mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId),
});

View File

@@ -11,20 +11,6 @@ import { AuthError } from '@/lib/auth/session';
import { generatePdfFromTemplate } from '@/features/foundation/pdf-generator/server/service';
import { buildQuotationPdfRuntime } from '@/features/crm/quotations/document/server/quotation-pdf-runtime';
import type { QuotationDocumentData } from '@/features/crm/quotations/document/types';
import {
getDocumentTemplate,
mapDocumentDataToTemplateInput,
resolveTemplateMappings,
} from './service';
import type {
DocumentTemplateDetail,
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail,
DocumentTemplateVersionPreviewResponse,
DocumentTemplateVersionValidationSummary,
} from '../types';
import {
PDFME_STATIC_LABEL_FIELD_KEYS,
buildAuditPayload,
@@ -33,27 +19,30 @@ import {
getEffectiveMappedKeys,
normalizeTemplateInputAliases,
summarizeIssues,
} from '../../../../../scripts/pdf-audit-utils';
} from '@/features/foundation/pdf-audit/server/shared';
import {
getDocumentTemplate,
mapDocumentDataToTemplateInput,
resolveTemplateMappings,
} from './service';
import {
CURRENT_TEMPLATE_RUNTIME_VERSION,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
import type {
DocumentTemplateDetail,
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionDetail,
DocumentTemplateVersionPreviewResponse,
DocumentTemplateVersionVisualSummary,
DocumentTemplateVersionValidationSummary,
} from '../types';
type VersionRow = typeof crmDocumentTemplateVersions.$inferSelect;
type DocumentTemplateManagementMetadata = {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
};
const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'customer_name',
'quotation_code_data',
@@ -63,38 +52,14 @@ const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'app3',
] as const;
function getSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
const schema = getSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
function withManagementMetadata(
schemaJson: unknown,
metadata: DocumentTemplateManagementMetadata,
): unknown {
return {
...getSchemaRecord(schemaJson),
__templateManagement: {
...getManagementMetadata(schemaJson),
...metadata,
},
};
}
function inferLifecycleStatus(row: VersionRow): DocumentTemplateLifecycleStatus {
if (row.isActive) {
return 'active';
}
return getManagementMetadata(row.schemaJson).lifecycleStatus ?? 'draft';
return getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
}).lifecycleStatus ?? 'draft';
}
async function getVersionRow(versionId: string, organizationId: string) {
@@ -136,7 +101,10 @@ function decorateVersionDetail(args: {
auditSummary: DocumentTemplateVersionAuditSummary | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null;
}): DocumentTemplateVersionDetail {
const metadata = getManagementMetadata(args.version.schemaJson);
const metadata = getManagementMetadata({
metadataJson: args.version.metadataJson,
schemaJson: args.version.schemaJson,
});
const siblingIds = args.siblings.map((item) => item.id);
const currentIndex = siblingIds.indexOf(args.version.id);
@@ -168,8 +136,9 @@ function decorateVersionDetail(args: {
(currentIndex >= 0 && currentIndex < siblingIds.length - 1
? siblingIds[currentIndex + 1]
: null),
auditSummary: args.auditSummary,
validationSummary: args.validationSummary ?? null,
auditSummary: args.auditSummary ?? metadata.auditSummary ?? null,
validationSummary: args.validationSummary ?? metadata.validationSummary ?? null,
visualSummary: metadata.visualSummary ?? null,
};
}
@@ -217,6 +186,40 @@ async function loadAuditSummary(
return auditSummary;
}
async function loadVisualSummary(): Promise<DocumentTemplateVersionVisualSummary | null> {
const visualReportPath = path.resolve(
process.cwd(),
'artifacts/pdf-visual/visual-regression-report.json',
);
try {
const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as {
generatedAt?: string;
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
comparisons?: Array<{ changedPages?: number; diffCount?: number }>;
};
const comparisons = Array.isArray(visualReport.comparisons)
? visualReport.comparisons
: [];
return {
status: visualReport.overallStatus ?? 'NOT_RUN',
generatedAt: visualReport.generatedAt ?? null,
changedPages: comparisons.reduce(
(sum, item) => sum + (item.changedPages ?? 0),
0,
),
layoutDifferences: comparisons.reduce(
(sum, item) => sum + (item.diffCount ?? 0),
0,
),
};
} catch {
return null;
}
}
async function buildFixtureDocumentData(): Promise<QuotationDocumentData> {
const sql = createSqlClient();
try {
@@ -252,10 +255,7 @@ export async function validateDocumentTemplateVersion(
versionId: string,
organizationId: string,
): Promise<DocumentTemplateVersionValidationSummary> {
const { template, version, siblings } = await getTemplateContext(
versionId,
organizationId,
);
const { template, version } = await getTemplateContext(versionId, organizationId);
const fields = extractTemplateFields(version.schemaJson);
const mappedKeys = getEffectiveMappedKeys(fields, version.mappings);
const schemaFieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
@@ -285,6 +285,11 @@ export async function validateDocumentTemplateVersion(
.map(([name]) => name)
.sort();
const orphanMappings = version.mappings
.filter((mapping) => !schemaFieldNames.has(mapping.placeholderKey))
.map((mapping) => mapping.placeholderKey)
.sort();
const unmappedPlaceholders = [...schemaFieldNames]
.filter(
(fieldName) =>
@@ -299,6 +304,30 @@ export async function validateDocumentTemplateVersion(
)
.sort();
const unsupportedSchemaTypes = fields
.filter(
(field) =>
![
'text',
'line',
'rectangle',
'ellipse',
'table',
'image',
'pdf',
].includes(field.type),
)
.map((field) => `${field.name}:${field.type}`)
.sort();
const unknownPlugins: string[] = [];
const sectionMarkerIssues: string[] = [];
if (hasProductSection && !schemaFieldNames.has('items_table')) {
sectionMarkerIssues.push(
'Product section marker exists but items_table placeholder is missing',
);
}
const documentData = await buildFixtureDocumentData();
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
@@ -315,6 +344,74 @@ export async function validateDocumentTemplateVersion(
templateInput: normalizedTemplateInput,
});
const issues: DocumentTemplateVersionValidationSummary['issues'] = [];
for (const field of requiredFieldsMissing) {
issues.push({
code: 'missing_required_placeholder',
severity: 'error',
location: field,
message: `Required placeholder ${field} is missing`,
suggestedFix: 'Add the placeholder into the template schema.',
});
}
for (const field of duplicateFields) {
issues.push({
code: 'duplicate_field_name',
severity: 'error',
location: field,
message: `Field ${field} is duplicated in the template schema`,
suggestedFix: 'Rename or remove duplicated fields so each placeholder is unique.',
});
}
for (const field of orphanMappings) {
issues.push({
code: 'orphan_mapping',
severity: 'warning',
location: field,
message: `Mapping ${field} does not exist in the current schema`,
suggestedFix: 'Remove the mapping or restore the matching placeholder in the schema.',
});
}
for (const field of unmappedPlaceholders) {
issues.push({
code: 'unmapped_placeholder',
severity: 'warning',
location: field,
message: `Placeholder ${field} does not have a mapping`,
suggestedFix: 'Add a mapping or convert the field into a static designer label.',
});
}
for (const field of unsupportedSchemaTypes) {
issues.push({
code: 'unsupported_schema_type',
severity: 'error',
location: field,
message: `Unsupported schema field type detected: ${field}`,
suggestedFix: 'Use only supported PDFMe field types for runtime rendering.',
});
}
for (const issue of sectionMarkerIssues) {
issues.push({
code: 'section_marker_invalid',
severity: 'error',
location: 'items_table',
message: issue,
suggestedFix: 'Add items_table to the template and map it to quotation items.',
});
}
for (const issue of runtime.issues) {
issues.push({
code: issue.code,
severity: issue.severity,
location: 'runtime',
message: issue.message,
suggestedFix:
issue.severity === 'error'
? 'Resolve runtime compatibility before publish.'
: 'Review runtime warning and confirm it is acceptable.',
});
}
const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = [];
if (requiredFieldsMissing.length) {
statuses.push('FAIL');
@@ -322,19 +419,40 @@ export async function validateDocumentTemplateVersion(
if (duplicateFields.length) {
statuses.push('FAIL');
}
if (orphanMappings.length) {
statuses.push('WARNING');
}
if (unmappedPlaceholders.length) {
statuses.push('WARNING');
}
if (unsupportedSchemaTypes.length || sectionMarkerIssues.length || unknownPlugins.length) {
statuses.push('FAIL');
}
statuses.push(
...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')),
);
const status = summarizeIssues(statuses.length ? statuses : ['PASS']);
return {
status: summarizeIssues(statuses.length ? statuses : ['PASS']),
status,
validatedAt: new Date().toISOString(),
requiredFieldsMissing,
duplicateFields,
unmappedPlaceholders,
orphanMappings,
unsupportedSchemaTypes,
unknownPlugins,
sectionMarkerIssues,
healthStatus:
status === 'FAIL' ? 'fail' : status === 'WARNING' ? 'warning' : 'pass',
issues,
suggestedNextStep:
status === 'FAIL'
? 'Fix blocking template issues before publish.'
: status === 'WARNING'
? 'Review warnings and confirm template intent.'
: 'Template is ready for publish.',
runtimeIssues: runtime.issues.map((issue) => ({
code: issue.code,
severity: issue.severity,
@@ -346,13 +464,15 @@ export async function validateDocumentTemplateVersion(
async function persistVersionManagementMetadata(args: {
versionId: string;
organizationId: string;
schemaJson: unknown;
schemaJson?: unknown;
metadataJson?: unknown;
isActive?: boolean;
}) {
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
schemaJson: args.schemaJson,
...(args.schemaJson === undefined ? {} : { schemaJson: args.schemaJson }),
...(args.metadataJson === undefined ? {} : { metadataJson: args.metadataJson }),
...(args.isActive === undefined ? {} : { isActive: args.isActive }),
updatedAt: new Date(),
})
@@ -373,8 +493,12 @@ export async function getManagedDocumentTemplateVersion(
) {
const { version, siblings } = await getTemplateContext(versionId, organizationId);
const auditSummary = await loadAuditSummary(version);
const visualSummary = await loadVisualSummary();
return decorateVersionDetail({
version,
version: {
...version,
visualSummary,
},
siblings,
auditSummary,
});
@@ -388,18 +512,30 @@ export async function validateAndPersistDocumentTemplateVersion(args: {
args.versionId,
args.organizationId,
);
const { version } = await getTemplateContext(args.versionId, args.organizationId);
const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
const auditSummary = await loadAuditSummary(version);
const visualSummary = await loadVisualSummary();
if (validation.status !== 'FAIL') {
await persistVersionManagementMetadata({
versionId: args.versionId,
organizationId: args.organizationId,
schemaJson: withManagementMetadata(row.schemaJson, {
...metadata,
metadataJson: mergeManagementMetadata({
currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus:
inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row),
validatedAt: validation.validatedAt,
validationSummary: validation,
auditSummary,
visualSummary,
},
}),
});
}
@@ -418,18 +554,25 @@ export async function publishDocumentTemplateVersion(args: {
}
const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({
versionId: args.versionId,
organizationId: args.organizationId,
isActive: false,
schemaJson: withManagementMetadata(row.schemaJson, {
...metadata,
lifecycleStatus: 'published',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: new Date().toISOString(),
publishedBy: args.userId,
validatedAt: validation.validatedAt,
metadataJson: mergeManagementMetadata({
currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'published',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: new Date().toISOString(),
publishedBy: args.userId,
validatedAt: validation.validatedAt,
validationSummary: validation,
},
}),
});
@@ -451,34 +594,48 @@ export async function activateDocumentTemplateVersion(args: {
const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null;
if (currentActive) {
const previousMetadata = getManagementMetadata(currentActive.schemaJson);
const previousMetadata = getManagementMetadata({
metadataJson: currentActive.metadataJson,
schemaJson: currentActive.schemaJson,
});
await persistVersionManagementMetadata({
versionId: currentActive.id,
organizationId: args.organizationId,
isActive: false,
schemaJson: withManagementMetadata(currentActive.schemaJson, {
...previousMetadata,
lifecycleStatus: 'published',
nextVersionId: row.id,
metadataJson: mergeManagementMetadata({
currentMetadataJson: currentActive.metadataJson,
currentSchemaJson: currentActive.schemaJson,
patch: {
...previousMetadata,
lifecycleStatus: 'published',
nextVersionId: row.id,
},
}),
});
}
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({
versionId: row.id,
organizationId: args.organizationId,
isActive: true,
schemaJson: withManagementMetadata(row.schemaJson, {
...metadata,
lifecycleStatus: 'active',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: metadata.publishedAt ?? new Date().toISOString(),
publishedBy: metadata.publishedBy ?? args.userId,
activatedAt: new Date().toISOString(),
activatedBy: args.userId,
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
nextVersionId: null,
schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
metadataJson: mergeManagementMetadata({
currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'active',
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
publishedAt: metadata.publishedAt ?? new Date().toISOString(),
publishedBy: metadata.publishedBy ?? args.userId,
activatedAt: new Date().toISOString(),
activatedBy: args.userId,
previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
nextVersionId: null,
},
}),
});
@@ -495,7 +652,10 @@ export async function rollbackDocumentTemplateVersion(args: {
throw new AuthError('Rollback is only allowed from the current active version.', 409);
}
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
let targetVersionId = metadata.previousVersionId ?? null;
if (!targetVersionId) {
@@ -535,16 +695,22 @@ export async function archiveDocumentTemplateVersion(args: {
throw new AuthError('Active versions must be rolled back before archive.', 409);
}
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
await persistVersionManagementMetadata({
versionId: row.id,
organizationId: args.organizationId,
isActive: false,
schemaJson: withManagementMetadata(row.schemaJson, {
...metadata,
lifecycleStatus: 'archived',
archivedAt: new Date().toISOString(),
archivedBy: args.userId,
metadataJson: mergeManagementMetadata({
currentMetadataJson: row.metadataJson,
currentSchemaJson: row.schemaJson,
patch: {
lifecycleStatus: 'archived',
archivedAt: new Date().toISOString(),
archivedBy: args.userId,
},
}),
});
@@ -585,6 +751,10 @@ export async function compareDocumentTemplateVersions(args: {
const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey));
const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey));
const leftFields = extractTemplateFields(left.schemaJson);
const rightFields = extractTemplateFields(right.schemaJson);
const leftFieldNames = new Set(leftFields.map((field) => field.name).filter(Boolean));
const rightFieldNames = new Set(rightFields.map((field) => field.name).filter(Boolean));
const mappingDifferences = [
...left.mappings.flatMap((mapping) => {
@@ -614,12 +784,52 @@ export async function compareDocumentTemplateVersions(args: {
});
return currentSignature === otherSignature
? []
: [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }];
: [{
placeholderKey: mapping.placeholderKey,
difference: 'Mapping contract changed',
leftValue: currentSignature,
rightValue: otherSignature,
}];
}),
];
const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId);
const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId);
const jsonDiff: DocumentTemplateVersionCompareResponse['comparison']['jsonDiff'] = [];
const fieldSchemaChanges = {
addedFields: [...rightFieldNames].filter((field) => !leftFieldNames.has(field)).sort(),
removedFields: [...leftFieldNames].filter((field) => !rightFieldNames.has(field)).sort(),
changedFields: leftFields
.flatMap((leftField) => {
const rightField = rightFields.find((item) => item.name === leftField.name);
if (!rightField) {
return [];
}
if (leftField.type === rightField.type) {
return [];
}
return [
{
field: leftField.name,
difference: `${leftField.type} -> ${rightField.type}`,
},
];
})
.sort((a, b) => a.field.localeCompare(b.field)),
};
const leftSchema = sanitizeTemplateSchemaJson(left.schemaJson) as Record<string, unknown>;
const rightSchema = sanitizeTemplateSchemaJson(right.schemaJson) as Record<string, unknown>;
const leftPages = Array.isArray(leftSchema.schemas) ? leftSchema.schemas.length : 0;
const rightPages = Array.isArray(rightSchema.schemas) ? rightSchema.schemas.length : 0;
if (leftPages !== rightPages) {
jsonDiff.push({
path: 'schemas.length',
left: leftPages,
right: rightPages,
});
}
return {
leftVersionId: left.id,
@@ -628,10 +838,14 @@ export async function compareDocumentTemplateVersions(args: {
placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)),
placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)),
mappingDifferences,
fieldSchemaChanges,
jsonDiff,
runtimeCompatibility: {
leftStatus: leftValidation.status,
rightStatus: rightValidation.status,
},
leftValidation,
rightValidation,
};
}

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getLegacyManagementMetadata,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
test('sanitizeTemplateSchemaJson removes embedded legacy management metadata', () => {
const schemaJson = {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
__templateManagement: {
lifecycleStatus: 'published',
runtimeVersion: 'p5',
},
};
assert.deepEqual(sanitizeTemplateSchemaJson(schemaJson), {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
});
});
test('getManagementMetadata falls back to legacy schema metadata when metadata_json is absent', () => {
const metadata = getManagementMetadata({
schemaJson: {
schemas: [],
__templateManagement: {
lifecycleStatus: 'validated',
runtimeVersion: 'legacy-runtime',
},
},
});
assert.equal(metadata.lifecycleStatus, 'validated');
assert.equal(metadata.runtimeVersion, 'legacy-runtime');
});
test('mergeManagementMetadata prefers dedicated metadata_json but preserves compatibility fallback', () => {
const metadata = mergeManagementMetadata({
currentMetadataJson: {
lifecycleStatus: 'draft',
runtimeVersion: 'p5.1',
},
currentSchemaJson: {
__templateManagement: {
lifecycleStatus: 'published',
runtimeVersion: 'legacy-runtime',
},
},
patch: {
lifecycleStatus: 'active',
publishedBy: 'user-1',
},
});
assert.equal(metadata.lifecycleStatus, 'active');
assert.equal(metadata.runtimeVersion, 'p5.1');
assert.equal(metadata.publishedBy, 'user-1');
});
test('getLegacyManagementMetadata returns empty object for clean PDFMe schema', () => {
assert.deepEqual(
getLegacyManagementMetadata({
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [],
}),
{},
);
});

View File

@@ -0,0 +1,86 @@
import type {
DocumentTemplateLifecycleStatus,
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionValidationSummary,
} from '../types';
export const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5.1-template-management';
export interface DocumentTemplateVisualSummary {
status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
generatedAt: string | null;
changedPages: number;
layoutDifferences: number;
}
export interface DocumentTemplateManagementMetadata {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null;
visualSummary?: DocumentTemplateVisualSummary | null;
}
function getSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
export function getLegacyManagementMetadata(
schemaJson: unknown,
): DocumentTemplateManagementMetadata {
const schema = getSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
export function getManagementMetadata(args: {
metadataJson?: unknown;
schemaJson?: unknown;
}): DocumentTemplateManagementMetadata {
if (args.metadataJson && typeof args.metadataJson === 'object') {
return { ...(args.metadataJson as DocumentTemplateManagementMetadata) };
}
return getLegacyManagementMetadata(args.schemaJson);
}
export function sanitizeTemplateSchemaJson(schemaJson: unknown): unknown {
const schema = getSchemaRecord(schemaJson);
if (!('__templateManagement' in schema)) {
return schemaJson;
}
const { __templateManagement: _management, ...sanitized } = schema;
return sanitized;
}
export function mergeManagementMetadata(args: {
currentMetadataJson?: unknown;
currentSchemaJson?: unknown;
patch: DocumentTemplateManagementMetadata;
}): DocumentTemplateManagementMetadata {
return {
...getManagementMetadata({
metadataJson: args.currentMetadataJson,
schemaJson: args.currentSchemaJson,
}),
...args.patch,
};
}

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDuplicateVersionLabel } from './service';
test('createDuplicateVersionLabel creates draft label first', () => {
const label = createDuplicateVersionLabel(['1.0', '2.0'], '2.0');
assert.equal(label, '2.0-draft');
});
test('createDuplicateVersionLabel increments copy suffix when draft already exists', () => {
const label = createDuplicateVersionLabel(
['2.0', '2.0-draft', '2.0-copy-1'],
'2.0',
);
assert.equal(label, '2.0-copy-2');
});

View File

@@ -12,6 +12,12 @@ import {
formatPdfDate,
normalizePdfmeTable
} from '@/features/crm/quotations/document/server/pdfme-transforms';
import {
CURRENT_TEMPLATE_RUNTIME_VERSION,
getManagementMetadata,
mergeManagementMetadata,
sanitizeTemplateSchemaJson,
} from './metadata';
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
@@ -30,54 +36,9 @@ import type {
ResolvedDocumentTemplate
} from '../types';
const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
type DocumentTemplateManagementMetadata = {
lifecycleStatus?: DocumentTemplateLifecycleStatus;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
activatedAt?: string | null;
activatedBy?: string | null;
archivedAt?: string | null;
archivedBy?: string | null;
previousVersionId?: string | null;
nextVersionId?: string | null;
validatedAt?: string | null;
};
function getTemplateSchemaRecord(schemaJson: unknown): Record<string, unknown> {
return schemaJson && typeof schemaJson === 'object'
? { ...(schemaJson as Record<string, unknown>) }
: {};
}
function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
const schema = getTemplateSchemaRecord(schemaJson);
const metadata = schema.__templateManagement;
return metadata && typeof metadata === 'object'
? { ...(metadata as DocumentTemplateManagementMetadata) }
: {};
}
function withManagementMetadata(
schemaJson: unknown,
metadata: DocumentTemplateManagementMetadata
): unknown {
return {
...getTemplateSchemaRecord(schemaJson),
__templateManagement: {
...getManagementMetadata(schemaJson),
...metadata
}
};
}
function inferLifecycleStatus(args: {
isActive: boolean;
metadata: DocumentTemplateManagementMetadata;
metadata: ReturnType<typeof getManagementMetadata>;
}): DocumentTemplateLifecycleStatus {
if (args.isActive) {
return 'active';
@@ -108,7 +69,10 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord {
const metadata = getManagementMetadata(row.schemaJson);
const metadata = getManagementMetadata({
metadataJson: row.metadataJson,
schemaJson: row.schemaJson,
});
return {
id: row.id,
@@ -116,7 +80,8 @@ function mapVersionRecord(
templateId: row.templateId,
version: row.version,
filePath: row.filePath,
schemaJson: row.schemaJson,
schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
metadataJson: row.metadataJson ?? null,
previewImageUrl: row.previewImageUrl,
isActive: row.isActive,
lifecycleStatus: inferLifecycleStatus({
@@ -502,6 +467,15 @@ export async function createDocumentTemplateVersion(
payload: DocumentTemplateVersionMutationPayload
) {
await assertTemplate(templateId, organizationId);
const sanitizedSchemaJson = sanitizeTemplateSchemaJson(payload.schemaJson);
const metadataJson = mergeManagementMetadata({
patch: {
lifecycleStatus: payload.isActive ? 'active' : 'draft',
runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
templateVariant: payload.templateVariant ?? null,
brand: payload.brand ?? null,
},
});
const [created] = await db
.insert(crmDocumentTemplateVersions)
.values({
@@ -510,9 +484,10 @@ export async function createDocumentTemplateVersion(
templateId,
version: payload.version.trim(),
filePath: payload.filePath?.trim() || null,
schemaJson: payload.schemaJson,
schemaJson: sanitizedSchemaJson,
metadataJson,
previewImageUrl: payload.previewImageUrl?.trim() || null,
isActive: payload.isActive ?? true,
isActive: payload.isActive ?? false,
createdBy: userId
})
.returning();
@@ -526,12 +501,28 @@ export async function updateDocumentTemplateVersion(
payload: Partial<DocumentTemplateVersionMutationPayload>
) {
const current = await assertTemplateVersion(versionId, organizationId);
const currentMetadata = getManagementMetadata({
metadataJson: current.metadataJson,
schemaJson: current.schemaJson,
});
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
version: payload.version?.trim() ?? current.version,
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
schemaJson: payload.schemaJson ?? current.schemaJson,
schemaJson:
payload.schemaJson === undefined
? sanitizeTemplateSchemaJson(current.schemaJson)
: sanitizeTemplateSchemaJson(payload.schemaJson),
metadataJson: mergeManagementMetadata({
currentMetadataJson: current.metadataJson,
currentSchemaJson: current.schemaJson,
patch: {
runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null,
templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null,
brand: payload.brand ?? currentMetadata.brand ?? null,
},
}),
previewImageUrl:
payload.previewImageUrl === undefined
? current.previewImageUrl
@@ -545,6 +536,114 @@ export async function updateDocumentTemplateVersion(
return updated;
}
export function createDuplicateVersionLabel(existingVersions: string[], baseVersion: string) {
const normalizedBase = baseVersion.trim();
const draftCandidate = `${normalizedBase}-draft`;
if (!existingVersions.includes(draftCandidate)) {
return draftCandidate;
}
let suffix = 1;
while (existingVersions.includes(`${normalizedBase}-copy-${suffix}`)) {
suffix += 1;
}
return `${normalizedBase}-copy-${suffix}`;
}
export async function duplicateDocumentTemplateVersion(
versionId: string,
organizationId: string,
userId: string,
) {
const sourceVersion = await assertTemplateVersion(versionId, organizationId);
const siblingVersions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt),
),
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const metadata = getManagementMetadata({
metadataJson: sourceVersion.metadataJson,
schemaJson: sourceVersion.schemaJson,
});
const duplicateId = crypto.randomUUID();
const duplicateVersionLabel = createDuplicateVersionLabel(
siblingVersions.map((item) => item.version),
sourceVersion.version,
);
const [createdVersion] = await db
.insert(crmDocumentTemplateVersions)
.values({
id: duplicateId,
organizationId,
templateId: sourceVersion.templateId,
version: duplicateVersionLabel,
filePath: sourceVersion.filePath,
schemaJson: sanitizeTemplateSchemaJson(sourceVersion.schemaJson),
metadataJson: {
...metadata,
lifecycleStatus: 'draft',
publishedAt: null,
publishedBy: null,
activatedAt: null,
activatedBy: null,
archivedAt: null,
archivedBy: null,
previousVersionId: sourceVersion.id,
nextVersionId: null,
validationSummary: null,
auditSummary: null,
visualSummary: null,
},
previewImageUrl: sourceVersion.previewImageUrl,
isActive: false,
createdBy: userId,
})
.returning();
const mappings = await resolveTemplateMappings(sourceVersion.id, organizationId);
for (const mapping of mappings) {
const newMappingId = crypto.randomUUID();
await db.insert(crmDocumentTemplateMappings).values({
id: newMappingId,
organizationId,
templateVersionId: duplicateId,
placeholderKey: mapping.placeholderKey,
sourcePath: mapping.sourcePath,
dataType: mapping.dataType,
sheetName: mapping.sheetName,
defaultValue: mapping.defaultValue,
formatMask: mapping.formatMask,
sortOrder: mapping.sortOrder,
});
if (mapping.columns.length) {
await db.insert(crmDocumentTemplateTableColumns).values(
mapping.columns.map((column) => ({
id: crypto.randomUUID(),
organizationId,
mappingId: newMappingId,
columnName: column.columnName,
sourceField: column.sourceField,
columnLetter: column.columnLetter,
sortOrder: column.sortOrder,
formatMask: column.formatMask,
})),
);
}
}
return createdVersion;
}
export async function setDocumentTemplateVersionActive(
versionId: string,
organizationId: string,

View File

@@ -145,6 +145,12 @@ export async function archiveDocumentTemplateVersion(id: string) {
});
}
export async function duplicateDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/duplicate`, {
method: 'POST',
});
}
export async function previewDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionPreviewResponse>(`${BASE_PATH}/versions/${id}/preview`);
}

View File

@@ -5,6 +5,8 @@ export type DocumentTemplateLifecycleStatus =
| 'active'
| 'archived';
export type TemplateHealthStatus = 'pass' | 'warning' | 'fail';
export interface DocumentTemplateRecord {
id: string;
organizationId: string;
@@ -28,6 +30,19 @@ export interface DocumentTemplateVersionValidationSummary {
requiredFieldsMissing: string[];
duplicateFields: string[];
unmappedPlaceholders: string[];
orphanMappings: string[];
unsupportedSchemaTypes: string[];
unknownPlugins: string[];
sectionMarkerIssues: string[];
suggestedNextStep?: string | null;
issues: Array<{
code: string;
severity: 'warning' | 'error';
location: string;
message: string;
suggestedFix: string | null;
}>;
healthStatus: TemplateHealthStatus;
runtimeIssues: Array<{
code: string;
severity: 'warning' | 'error';
@@ -43,6 +58,13 @@ export interface DocumentTemplateVersionAuditSummary {
visualRegressionAt: string | null;
}
export interface DocumentTemplateVersionVisualSummary {
status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
generatedAt: string | null;
changedPages: number;
layoutDifferences: number;
}
export interface DocumentTemplateVersionRecord {
id: string;
organizationId: string;
@@ -50,6 +72,7 @@ export interface DocumentTemplateVersionRecord {
version: string;
filePath: string | null;
schemaJson: unknown;
metadataJson?: unknown;
previewImageUrl: string | null;
isActive: boolean;
lifecycleStatus?: DocumentTemplateLifecycleStatus;
@@ -130,6 +153,7 @@ export interface DocumentTemplateVersionDetail
mappings: DocumentTemplateMappingWithColumns[];
validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null;
visualSummary?: DocumentTemplateVersionVisualSummary | null;
}
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
@@ -228,11 +252,28 @@ export interface DocumentTemplateVersionCompareResponse
mappingDifferences: Array<{
placeholderKey: string;
difference: string;
leftValue?: string | null;
rightValue?: string | null;
}>;
fieldSchemaChanges: {
addedFields: string[];
removedFields: string[];
changedFields: Array<{
field: string;
difference: string;
}>;
};
jsonDiff: Array<{
path: string;
left: string | number | boolean | null;
right: string | number | boolean | null;
}>;
runtimeCompatibility: {
leftStatus: 'PASS' | 'WARNING' | 'FAIL';
rightStatus: 'PASS' | 'WARNING' | 'FAIL';
};
leftValidation: DocumentTemplateVersionValidationSummary;
rightValidation: DocumentTemplateVersionValidationSummary;
};
}

File diff suppressed because it is too large Load Diff