This commit is contained in:
phaichayon
2026-06-29 23:05:01 +07:00
parent bdc2d9f1b4
commit 4570495a77
24 changed files with 2944 additions and 114 deletions

View File

@@ -19,6 +19,7 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { TemplateJsonSheet } from './template-json-sheet';
import { TemplateVersionManagementPanel } from './template-version-management-panel';
import {
createDocumentTemplateMappingMutation,
createDocumentTemplateMutation,
@@ -527,7 +528,10 @@ function MappingDialog({
function TemplateDetailCard({ templateId }: { templateId: string }) {
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
const template = data.template;
const [versionDialogOpen, setVersionDialogOpen] = React.useState(false);
const [versionDialogState, setVersionDialogState] = React.useState<{
open: boolean;
version?: DocumentTemplateVersionDetail;
}>({ open: false });
const [mappingDialogState, setMappingDialogState] = React.useState<{
open: boolean;
version?: DocumentTemplateVersionDetail;
@@ -569,7 +573,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</TabsTrigger>
))}
</TabsList>
<Button onClick={() => setVersionDialogOpen(true)}>
<Button onClick={() => setVersionDialogState({ open: true, version: undefined })}>
<Icons.add className='mr-2 h-4 w-4' />
Add Version
</Button>
@@ -617,7 +621,15 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
filePath={version.filePath}
schemaJson={version.schemaJson}
/>
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
<Button
variant='outline'
onClick={() =>
setVersionDialogState({
open: true,
version,
})
}
>
Edit Version
</Button>
<Button
@@ -634,6 +646,11 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</div>
</div>
</div>
<TemplateVersionManagementPanel
templateId={template.id}
version={version}
versions={template.versions}
/>
<div className='space-y-3'>
{version.mappings.map((mapping) => (
<div key={mapping.id} className='rounded-lg border p-4'>
@@ -699,10 +716,12 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</Tabs>
<VersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
open={versionDialogState.open}
onOpenChange={(open) =>
setVersionDialogState((current) => ({ ...current, open }))
}
templateId={template.id}
version={undefined}
version={versionDialogState.version}
/>
{mappingDialogState.open && mappingDialogState.version ? (
<MappingDialog

View File

@@ -0,0 +1,472 @@
'use client';
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
activateDocumentTemplateVersionMutation,
archiveDocumentTemplateVersionMutation,
compareDocumentTemplateVersionsMutation,
previewDocumentTemplateVersionMutation,
publishDocumentTemplateVersionMutation,
rollbackDocumentTemplateVersionMutation,
validateDocumentTemplateVersionMutation,
} from '../mutations';
import { documentTemplateVersionManagementQueryOptions } from '../queries';
import type {
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionDetail,
DocumentTemplateVersionValidationSummary,
} from '../types';
function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') {
if (status === 'PASS') {
return 'secondary' as const;
}
if (status === 'FAIL') {
return 'destructive' as const;
}
return 'outline' as const;
}
function getLifecycleBadgeVariant(
status: DocumentTemplateVersionDetail['lifecycleStatus'],
) {
if (status === 'active') {
return 'secondary' as const;
}
if (status === 'archived') {
return 'outline' as const;
}
return 'default' as const;
}
function SummaryCard({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
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>
);
}
function IssueList({
title,
items,
}: {
title: string;
items: string[];
}) {
if (!items.length) {
return null;
}
return (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>{title}</div>
<div className='space-y-1 text-sm'>
{items.map((item) => (
<div key={item} className='text-muted-foreground break-all'>
{item}
</div>
))}
</div>
</div>
);
}
function RuntimeIssueList({
validation,
}: {
validation: DocumentTemplateVersionValidationSummary | null | undefined;
}) {
if (!validation?.runtimeIssues.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='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'>
<Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}>
{issue.severity}
</Badge>
<span className='font-medium'>{issue.code}</span>
</div>
<div className='text-muted-foreground mt-1'>{issue.message}</div>
</div>
))}
</div>
</div>
);
}
function CompareDialog({
open,
onOpenChange,
templateId,
currentVersion,
versions,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
templateId: string;
currentVersion: DocumentTemplateVersionDetail;
versions: DocumentTemplateVersionDetail[];
}) {
const [rightVersionId, setRightVersionId] = React.useState(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
const compareMutation = useMutation({
...compareDocumentTemplateVersionsMutation,
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Compare failed'),
});
React.useEffect(() => {
if (open) {
setRightVersionId(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
}
}, [currentVersion.id, open, versions]);
async function handleCompare() {
const result = await compareMutation.mutateAsync({
templateId,
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);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Compare Versions</DialogTitle>
<DialogDescription>
Compare current version against another version in the same template family.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<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>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Compare With</div>
<select
className='flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm'
value={rightVersionId}
onChange={(event) => setRightVersionId(event.target.value)}
>
{versions
.filter((item) => item.id !== currentVersion.id)
.map((version) => (
<option key={version.id} value={version.id}>
{version.version}
</option>
))}
</select>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
onClick={handleCompare}
isLoading={compareMutation.isPending}
disabled={!rightVersionId || rightVersionId === currentVersion.id}
>
Compare
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TemplateVersionManagementPanel({
templateId,
version,
versions,
}: {
templateId: string;
version: DocumentTemplateVersionDetail;
versions: DocumentTemplateVersionDetail[];
}) {
const { data } = useSuspenseQuery(
documentTemplateVersionManagementQueryOptions(version.id),
);
const managedVersion = data.version;
const [compareOpen, setCompareOpen] = React.useState(false);
const validateMutation = useMutation({
...validateDocumentTemplateVersionMutation,
onSuccess: (result) => toast.success(`Validation: ${result.validation.status}`),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Validate failed'),
});
const publishMutation = useMutation({
...publishDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version published'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Publish failed'),
});
const activateMutation = useMutation({
...activateDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version activated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Activate failed'),
});
const rollbackMutation = useMutation({
...rollbackDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Rollback completed'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Rollback failed'),
});
const archiveMutation = useMutation({
...archiveDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version archived'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Archive failed'),
});
const previewMutation = useMutation({
...previewDocumentTemplateVersionMutation,
onSuccess: (result) => {
const url = `data:${result.preview.contentType};base64,${result.preview.base64}`;
window.open(url, '_blank', 'noopener,noreferrer');
toast.success(`Preview ready (${result.preview.pageCount} pages)`);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Preview failed'),
});
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>
);
}
return (
<>
<div className='space-y-4 rounded-lg border p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='space-y-2'>
<div className='font-medium'>Version Lifecycle Management</div>
<div className='flex flex-wrap gap-2'>
<Badge variant={getLifecycleBadgeVariant(managedVersion.lifecycleStatus)}>
{managedVersion.lifecycleStatus ?? 'draft'}
</Badge>
<Badge variant={managedVersion.isActive ? 'secondary' : 'outline'}>
{managedVersion.isActive ? 'Active Runtime' : 'Inactive Runtime'}
</Badge>
{validation ? (
<Badge variant={getStatusBadgeVariant(validation.status)}>
Validation {validation.status}
</Badge>
) : null}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => previewMutation.mutate(version.id)}
isLoading={previewMutation.isPending}
>
<Icons.post className='mr-2 h-4 w-4' />
Preview
</Button>
<Button variant='outline' onClick={() => setCompareOpen(true)}>
<Icons.share className='mr-2 h-4 w-4' />
Compare
</Button>
<Button
variant='outline'
onClick={() => validateMutation.mutate(version.id)}
isLoading={validateMutation.isPending}
>
Validate
</Button>
<Button
variant='outline'
onClick={() => publishMutation.mutate({ templateId, versionId: version.id })}
isLoading={publishMutation.isPending}
>
Publish
</Button>
<Button
onClick={() => activateMutation.mutate({ templateId, versionId: version.id })}
isLoading={activateMutation.isPending}
>
Activate
</Button>
<Button
variant='outline'
onClick={() => rollbackMutation.mutate({ templateId, versionId: version.id })}
isLoading={rollbackMutation.isPending}
>
Rollback
</Button>
<Button
variant='outline'
onClick={() => archiveMutation.mutate({ templateId, versionId: version.id })}
isLoading={archiveMutation.isPending}
>
Archive
</Button>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Runtime Version'
value={managedVersion.runtimeVersion ?? 'Not set'}
/>
<SummaryCard
label='Template Variant'
value={managedVersion.templateVariant ?? 'Not set'}
/>
<SummaryCard label='Brand' value={managedVersion.brand ?? 'Not set'} />
<SummaryCard
label='Published At'
value={managedVersion.publishedAt ?? 'Not published'}
/>
<SummaryCard
label='Activated At'
value={managedVersion.activatedAt ?? 'Not activated'}
/>
<SummaryCard
label='Archived At'
value={managedVersion.archivedAt ?? 'Not archived'}
/>
<SummaryCard
label='Previous Version'
value={managedVersion.previousVersionId ?? 'None'}
/>
<SummaryCard
label='Next Version'
value={managedVersion.nextVersionId ?? 'None'}
/>
</div>
<div className='space-y-3'>
<div className='font-medium'>Validation Summary</div>
{validation ? (
<div className='space-y-3'>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Validation Status'
value={<Badge variant={getStatusBadgeVariant(validation.status)}>{validation.status}</Badge>}
/>
<SummaryCard
label='Validated At'
value={validation.validatedAt ?? 'Not validated'}
/>
<SummaryCard
label='Required Missing'
value={validation.requiredFieldsMissing.length}
/>
<SummaryCard
label='Unmapped Placeholders'
value={validation.unmappedPlaceholders.length}
/>
</div>
<IssueList
title='Required Fields Missing'
items={validation.requiredFieldsMissing}
/>
<IssueList title='Duplicate Fields' items={validation.duplicateFields} />
<IssueList
title='Unmapped Placeholders'
items={validation.unmappedPlaceholders}
/>
<RuntimeIssueList validation={validation} />
</div>
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Validation has not been run for this version yet.
</div>
)}
</div>
<div className='space-y-3'>
<div className='font-medium'>Audit Summary</div>
{renderAuditSummary(audit)}
</div>
</div>
<CompareDialog
open={compareOpen}
onOpenChange={setCompareOpen}
templateId={templateId}
currentVersion={version}
versions={versions}
/>
</>
);
}

View File

@@ -1,21 +1,28 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
activateDocumentTemplateVersion,
archiveDocumentTemplateVersion,
compareDocumentTemplateVersions,
createDocumentTemplate,
createDocumentTemplateMapping,
createDocumentTemplateVersion,
deleteDocumentTemplate,
deleteDocumentTemplateMapping,
previewDocumentTemplateVersion,
publishDocumentTemplateVersion,
rollbackDocumentTemplateVersion,
setDocumentTemplateVersionActive,
updateDocumentTemplate,
updateDocumentTemplateMapping,
updateDocumentTemplateVersion
updateDocumentTemplateVersion,
validateDocumentTemplateVersion,
} from './service';
import { documentTemplateKeys } from './queries';
import type {
DocumentTemplateMappingMutationPayload,
DocumentTemplateMutationPayload,
DocumentTemplateVersionMutationPayload
DocumentTemplateVersionMutationPayload,
} from './types';
async function invalidateTemplateLists() {
@@ -25,10 +32,16 @@ async function invalidateTemplateLists() {
async function invalidateTemplateDetail(id: string) {
await Promise.all([
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }),
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) })
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) }),
]);
}
async function invalidateVersionManagement(versionId: string) {
await getQueryClient().invalidateQueries({
queryKey: documentTemplateKeys.management(versionId),
});
}
export const createDocumentTemplateMutation = mutationOptions({
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
onSettled: async (_data, error) => {
@@ -120,8 +133,7 @@ export const createDocumentTemplateMappingMutation = mutationOptions({
versionId: string;
values: DocumentTemplateMappingMutationPayload;
}) => {
void templateId;
return createDocumentTemplateMapping(versionId, values);
return createDocumentTemplateMapping(templateId, versionId, values);
},
onSettled: async (_data, error, variables) => {
if (!error) {
@@ -159,5 +171,101 @@ export const deleteDocumentTemplateMappingMutation = mutationOptions({
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
}
}
},
});
export const validateDocumentTemplateVersionMutation = mutationOptions({
mutationFn: (versionId: string) => validateDocumentTemplateVersion(versionId),
});
export const publishDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => publishDocumentTemplateVersion(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateTemplateLists(),
invalidateTemplateDetail(variables.templateId),
invalidateVersionManagement(variables.versionId),
]);
}
},
});
export const activateDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => activateDocumentTemplateVersion(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateTemplateLists(),
invalidateTemplateDetail(variables.templateId),
invalidateVersionManagement(variables.versionId),
]);
}
},
});
export const rollbackDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => rollbackDocumentTemplateVersion(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateTemplateLists(),
invalidateTemplateDetail(variables.templateId),
invalidateVersionManagement(variables.versionId),
]);
}
},
});
export const archiveDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
}: {
templateId: string;
versionId: string;
}) => archiveDocumentTemplateVersion(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),
});
export const compareDocumentTemplateVersionsMutation = mutationOptions({
mutationFn: ({
templateId,
leftVersionId,
rightVersionId,
}: {
templateId: string;
leftVersionId: string;
rightVersionId: string;
}) => compareDocumentTemplateVersions(templateId, leftVersionId, rightVersionId),
});

View File

@@ -1,5 +1,10 @@
import { queryOptions } from '@tanstack/react-query';
import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service';
import {
getDocumentTemplateById,
getDocumentTemplates,
getDocumentTemplateVersionManagement,
getDocumentTemplateVersions,
} from './service';
import type { DocumentTemplateFilters } from './types';
export const documentTemplateKeys = {
@@ -10,7 +15,10 @@ export const documentTemplateKeys = {
details: () => [...documentTemplateKeys.all, 'detail'] as const,
detail: (id: string) => [...documentTemplateKeys.details(), id] as const,
versionsRoot: () => [...documentTemplateKeys.all, 'versions'] as const,
versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const
versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const,
managementRoot: () => [...documentTemplateKeys.all, 'management'] as const,
management: (versionId: string) =>
[...documentTemplateKeys.managementRoot(), versionId] as const,
};
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>
@@ -28,5 +36,11 @@ export const documentTemplateByIdOptions = (id: string) =>
export const documentTemplateVersionsQueryOptions = (id: string) =>
queryOptions({
queryKey: documentTemplateKeys.versions(id),
queryFn: () => getDocumentTemplateVersions(id)
queryFn: () => getDocumentTemplateVersions(id),
});
export const documentTemplateVersionManagementQueryOptions = (versionId: string) =>
queryOptions({
queryKey: documentTemplateKeys.management(versionId),
queryFn: () => getDocumentTemplateVersionManagement(versionId),
});

View File

@@ -0,0 +1,674 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { and, asc, eq, isNull, lt } from 'drizzle-orm';
import { PDFDocument } from '@pdfme/pdf-lib';
import {
crmDocumentTemplateVersions,
crmDocumentTemplates,
} from '@/db/schema';
import { db } from '@/lib/db';
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,
createSqlClient,
extractTemplateFields,
getEffectiveMappedKeys,
normalizeTemplateInputAliases,
summarizeIssues,
} from '../../../../../scripts/pdf-audit-utils';
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',
'quotation_date_data',
'app1',
'app2',
'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';
}
async function getVersionRow(versionId: string, organizationId: string) {
const [row] = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.id, versionId),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt),
),
);
if (!row) {
throw new AuthError('Document template version not found', 404);
}
return row;
}
async function listSiblingVersionRows(templateId: string, organizationId: string) {
return db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, templateId),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt),
),
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
}
function decorateVersionDetail(args: {
version: DocumentTemplateVersionDetail;
siblings: DocumentTemplateVersionDetail[];
auditSummary: DocumentTemplateVersionAuditSummary | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null;
}): DocumentTemplateVersionDetail {
const metadata = getManagementMetadata(args.version.schemaJson);
const siblingIds = args.siblings.map((item) => item.id);
const currentIndex = siblingIds.indexOf(args.version.id);
return {
...args.version,
lifecycleStatus: args.version.isActive
? 'active'
: metadata.lifecycleStatus ?? args.version.lifecycleStatus ?? 'draft',
runtimeVersion:
metadata.runtimeVersion ??
args.version.runtimeVersion ??
CURRENT_TEMPLATE_RUNTIME_VERSION,
templateVariant:
metadata.templateVariant ?? args.version.templateVariant ?? null,
brand: metadata.brand ?? args.version.brand ?? null,
publishedAt: metadata.publishedAt ?? args.version.publishedAt ?? null,
publishedBy: metadata.publishedBy ?? args.version.publishedBy ?? null,
activatedAt: metadata.activatedAt ?? args.version.activatedAt ?? null,
activatedBy: metadata.activatedBy ?? args.version.activatedBy ?? null,
archivedAt: metadata.archivedAt ?? args.version.archivedAt ?? null,
archivedBy: metadata.archivedBy ?? args.version.archivedBy ?? null,
previousVersionId:
metadata.previousVersionId ??
args.version.previousVersionId ??
(currentIndex > 0 ? siblingIds[currentIndex - 1] : null),
nextVersionId:
metadata.nextVersionId ??
args.version.nextVersionId ??
(currentIndex >= 0 && currentIndex < siblingIds.length - 1
? siblingIds[currentIndex + 1]
: null),
auditSummary: args.auditSummary,
validationSummary: args.validationSummary ?? null,
};
}
async function loadAuditSummary(
version: DocumentTemplateVersionDetail,
): Promise<DocumentTemplateVersionAuditSummary | null> {
const auditReportPath = path.resolve(
process.cwd(),
'docs/implementation/pdf-audit-report.json',
);
const visualReportPath = path.resolve(
process.cwd(),
'artifacts/pdf-visual/visual-regression-report.json',
);
const auditSummary: DocumentTemplateVersionAuditSummary = {
status: 'WARNING',
auditedAt: null,
runtimeVersion: version.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
visualRegressionStatus: 'NOT_RUN',
visualRegressionAt: null,
};
try {
const auditReport = JSON.parse(await readFile(auditReportPath, 'utf8')) as {
generatedAt?: string;
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
};
auditSummary.auditedAt = auditReport.generatedAt ?? null;
auditSummary.status = auditReport.overallStatus ?? 'WARNING';
} catch {}
try {
const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as {
generatedAt?: string;
overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
};
auditSummary.visualRegressionAt = visualReport.generatedAt ?? null;
auditSummary.visualRegressionStatus =
version.templateVariant === 'product-v1'
? visualReport.overallStatus ?? 'WARNING'
: 'NOT_RUN';
} catch {}
return auditSummary;
}
async function buildFixtureDocumentData(): Promise<QuotationDocumentData> {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
return payload.documentData as unknown as QuotationDocumentData;
} finally {
await sql.end({ timeout: 1 });
}
}
async function getTemplateContext(versionId: string, organizationId: string): Promise<{
template: DocumentTemplateDetail;
version: DocumentTemplateVersionDetail;
siblings: DocumentTemplateVersionDetail[];
}> {
const row = await getVersionRow(versionId, organizationId);
const template = await getDocumentTemplate(row.templateId, organizationId);
const siblings = template.versions;
const version = siblings.find((item) => item.id === versionId);
if (!version) {
throw new AuthError('Document template version not found', 404);
}
return {
template,
version,
siblings,
};
}
export async function validateDocumentTemplateVersion(
versionId: string,
organizationId: string,
): Promise<DocumentTemplateVersionValidationSummary> {
const { template, version, siblings } = 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));
const hasProductSection = fields.some(
(field) => field.name === '__section_role__product_items',
);
const requiredFieldsMissing = REQUIRED_TEMPLATE_PLACEHOLDERS.filter(
(field) => !schemaFieldNames.has(field),
);
if (hasProductSection && !schemaFieldNames.has('items_table')) {
requiredFieldsMissing.push('items_table');
}
const duplicateFieldCounts = new Map<string, number>();
for (const field of fields) {
if (!field.name) {
continue;
}
duplicateFieldCounts.set(
field.name,
(duplicateFieldCounts.get(field.name) ?? 0) + 1,
);
}
const duplicateFields = [...duplicateFieldCounts.entries()]
.filter(([, count]) => count > 1)
.map(([name]) => name)
.sort();
const unmappedPlaceholders = [...schemaFieldNames]
.filter(
(fieldName) =>
!fieldName.startsWith('__section_role__') &&
!PDFME_STATIC_LABEL_FIELD_KEYS.includes(
fieldName as (typeof PDFME_STATIC_LABEL_FIELD_KEYS)[number],
) &&
!mappedKeys.has(fieldName) &&
!fieldName.startsWith('topic') &&
!fieldName.startsWith('data_topic') &&
!fieldName.startsWith('item_topic'),
)
.sort();
const documentData = await buildFixtureDocumentData();
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
version.mappings,
);
const normalizedTemplateInput = normalizeTemplateInputAliases(fields, templateInput);
const runtime = await buildQuotationPdfRuntime({
documentData,
template: {
template,
version,
},
mappings: version.mappings,
templateInput: normalizedTemplateInput,
});
const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = [];
if (requiredFieldsMissing.length) {
statuses.push('FAIL');
}
if (duplicateFields.length) {
statuses.push('FAIL');
}
if (unmappedPlaceholders.length) {
statuses.push('WARNING');
}
statuses.push(
...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')),
);
return {
status: summarizeIssues(statuses.length ? statuses : ['PASS']),
validatedAt: new Date().toISOString(),
requiredFieldsMissing,
duplicateFields,
unmappedPlaceholders,
runtimeIssues: runtime.issues.map((issue) => ({
code: issue.code,
severity: issue.severity,
message: issue.message,
})),
};
}
async function persistVersionManagementMetadata(args: {
versionId: string;
organizationId: string;
schemaJson: unknown;
isActive?: boolean;
}) {
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
schemaJson: args.schemaJson,
...(args.isActive === undefined ? {} : { isActive: args.isActive }),
updatedAt: new Date(),
})
.where(
and(
eq(crmDocumentTemplateVersions.id, args.versionId),
eq(crmDocumentTemplateVersions.organizationId, args.organizationId),
),
)
.returning();
return updated;
}
export async function getManagedDocumentTemplateVersion(
versionId: string,
organizationId: string,
) {
const { version, siblings } = await getTemplateContext(versionId, organizationId);
const auditSummary = await loadAuditSummary(version);
return decorateVersionDetail({
version,
siblings,
auditSummary,
});
}
export async function validateAndPersistDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
}) {
const validation = await validateDocumentTemplateVersion(
args.versionId,
args.organizationId,
);
const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(row.schemaJson);
if (validation.status !== 'FAIL') {
await persistVersionManagementMetadata({
versionId: args.versionId,
organizationId: args.organizationId,
schemaJson: withManagementMetadata(row.schemaJson, {
...metadata,
lifecycleStatus:
inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row),
validatedAt: validation.validatedAt,
}),
});
}
return validation;
}
export async function publishDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
userId: string;
}) {
const validation = await validateAndPersistDocumentTemplateVersion(args);
if (validation.status === 'FAIL') {
throw new AuthError('Template version validation failed. Publish rejected.', 409);
}
const row = await getVersionRow(args.versionId, args.organizationId);
const metadata = getManagementMetadata(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,
}),
});
return getManagedDocumentTemplateVersion(args.versionId, args.organizationId);
}
export async function activateDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
userId: string;
}) {
const row = await getVersionRow(args.versionId, args.organizationId);
const currentStatus = inferLifecycleStatus(row);
if (!['validated', 'published', 'active'].includes(currentStatus)) {
throw new AuthError('Only validated or published versions can be activated.', 409);
}
const siblings = await listSiblingVersionRows(row.templateId, args.organizationId);
const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null;
if (currentActive) {
const previousMetadata = getManagementMetadata(currentActive.schemaJson);
await persistVersionManagementMetadata({
versionId: currentActive.id,
organizationId: args.organizationId,
isActive: false,
schemaJson: withManagementMetadata(currentActive.schemaJson, {
...previousMetadata,
lifecycleStatus: 'published',
nextVersionId: row.id,
}),
});
}
const metadata = getManagementMetadata(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,
}),
});
return getManagedDocumentTemplateVersion(row.id, args.organizationId);
}
export async function rollbackDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
userId: string;
}) {
const row = await getVersionRow(args.versionId, args.organizationId);
if (!row.isActive) {
throw new AuthError('Rollback is only allowed from the current active version.', 409);
}
const metadata = getManagementMetadata(row.schemaJson);
let targetVersionId = metadata.previousVersionId ?? null;
if (!targetVersionId) {
const [fallback] = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, row.templateId),
eq(crmDocumentTemplateVersions.organizationId, args.organizationId),
isNull(crmDocumentTemplateVersions.deletedAt),
lt(crmDocumentTemplateVersions.createdAt, row.createdAt),
),
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
targetVersionId = fallback?.id ?? null;
}
if (!targetVersionId) {
throw new AuthError('No previous version available for rollback.', 409);
}
return activateDocumentTemplateVersion({
versionId: targetVersionId,
organizationId: args.organizationId,
userId: args.userId,
});
}
export async function archiveDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
userId: string;
}) {
const row = await getVersionRow(args.versionId, args.organizationId);
if (row.isActive) {
throw new AuthError('Active versions must be rolled back before archive.', 409);
}
const metadata = getManagementMetadata(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,
}),
});
return getManagedDocumentTemplateVersion(row.id, args.organizationId);
}
export async function compareDocumentTemplateVersions(args: {
templateId: string;
leftVersionId: string;
rightVersionId: string;
organizationId: string;
}): Promise<DocumentTemplateVersionCompareResponse['comparison']> {
const template = await getDocumentTemplate(args.templateId, args.organizationId);
const left = template.versions.find((item) => item.id === args.leftVersionId);
const right = template.versions.find((item) => item.id === args.rightVersionId);
if (!left || !right) {
throw new AuthError('Unable to compare template versions.', 404);
}
const metadataFields: Array<keyof DocumentTemplateVersionDetail> = [
'version',
'filePath',
'isActive',
'lifecycleStatus',
'runtimeVersion',
'templateVariant',
'brand',
];
const metadataChanges = metadataFields
.map((field) => ({
field,
left: (left[field] as string | number | boolean | null | undefined) ?? null,
right: (right[field] as string | number | boolean | null | undefined) ?? null,
}))
.filter((item) => item.left !== item.right);
const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey));
const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey));
const mappingDifferences = [
...left.mappings.flatMap((mapping) => {
const other = right.mappings.find(
(candidate) => candidate.placeholderKey === mapping.placeholderKey,
);
if (!other) {
return [];
}
const currentSignature = JSON.stringify({
sourcePath: mapping.sourcePath,
dataType: mapping.dataType,
columns: mapping.columns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField,
formatMask: column.formatMask,
})),
});
const otherSignature = JSON.stringify({
sourcePath: other.sourcePath,
dataType: other.dataType,
columns: other.columns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField,
formatMask: column.formatMask,
})),
});
return currentSignature === otherSignature
? []
: [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }];
}),
];
const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId);
const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId);
return {
leftVersionId: left.id,
rightVersionId: right.id,
metadataChanges,
placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)),
placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)),
mappingDifferences,
runtimeCompatibility: {
leftStatus: leftValidation.status,
rightStatus: rightValidation.status,
},
};
}
export async function previewDocumentTemplateVersion(args: {
versionId: string;
organizationId: string;
}): Promise<DocumentTemplateVersionPreviewResponse['preview']> {
const { template, version } = await getTemplateContext(args.versionId, args.organizationId);
const documentData = await buildFixtureDocumentData();
const fields = extractTemplateFields(version.schemaJson);
const templateInput = normalizeTemplateInputAliases(
fields,
mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
version.mappings,
),
);
const runtime = await buildQuotationPdfRuntime({
documentData,
template: {
template,
version,
},
mappings: version.mappings,
templateInput,
});
const buffer = await generatePdfFromTemplate({
template: runtime.assembled.template,
inputs: [runtime.assembled.templateInput],
});
const pdfDocument = await PDFDocument.load(buffer);
return {
fileName: `${template.templateName}-${version.version}-preview.pdf`,
contentType: 'application/pdf',
base64: Buffer.from(buffer).toString('base64'),
pageCount: pdfDocument.getPageCount(),
source: 'audit-fixture',
};
}

View File

@@ -15,6 +15,7 @@ import {
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
DocumentTemplateLifecycleStatus,
DocumentTemplateListItem,
DocumentTemplateMappingMutationPayload,
DocumentTemplateMappingRecord,
@@ -29,6 +30,62 @@ 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;
}): DocumentTemplateLifecycleStatus {
if (args.isActive) {
return 'active';
}
return args.metadata.lifecycleStatus ?? 'draft';
}
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
return {
id: row.id,
@@ -51,6 +108,8 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord {
const metadata = getManagementMetadata(row.schemaJson);
return {
id: row.id,
organizationId: row.organizationId,
@@ -60,6 +119,21 @@ function mapVersionRecord(
schemaJson: row.schemaJson,
previewImageUrl: row.previewImageUrl,
isActive: row.isActive,
lifecycleStatus: inferLifecycleStatus({
isActive: row.isActive,
metadata
}),
runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
templateVariant: metadata.templateVariant ?? null,
brand: metadata.brand ?? null,
publishedAt: metadata.publishedAt ?? null,
publishedBy: metadata.publishedBy ?? null,
activatedAt: metadata.activatedAt ?? null,
activatedBy: metadata.activatedBy ?? null,
archivedAt: metadata.archivedAt ?? null,
archivedBy: metadata.archivedBy ?? null,
previousVersionId: metadata.previousVersionId ?? null,
nextVersionId: metadata.nextVersionId ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,

View File

@@ -5,20 +5,22 @@ import type {
DocumentTemplateListResponse,
DocumentTemplateMappingMutationPayload,
DocumentTemplateMutationPayload,
DocumentTemplateVersionActionResponse,
DocumentTemplateVersionCompareResponse,
DocumentTemplateVersionMutationPayload,
DocumentTemplateVersionPreviewResponse,
DocumentTemplateVersionValidateResponse,
DocumentTemplateVersionsResponse,
MutationSuccessResponse
MutationSuccessResponse,
} from './types';
const BASE_PATH = '/crm/settings/document-templates';
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
const searchParams = new URLSearchParams();
if (filters.documentType) searchParams.set('documentType', filters.documentType);
if (filters.fileType) searchParams.set('fileType', filters.fileType);
if (filters.isActive) searchParams.set('isActive', filters.isActive);
const query = searchParams.toString();
return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
}
@@ -30,23 +32,23 @@ export async function getDocumentTemplateById(id: string) {
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
return apiClient<MutationSuccessResponse>(BASE_PATH, {
method: 'POST',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function updateDocumentTemplate(
id: string,
data: Partial<DocumentTemplateMutationPayload>
data: Partial<DocumentTemplateMutationPayload>,
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function deleteDocumentTemplate(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'DELETE'
method: 'DELETE',
});
}
@@ -56,53 +58,104 @@ export async function getDocumentTemplateVersions(id: string) {
export async function createDocumentTemplateVersion(
id: string,
data: DocumentTemplateVersionMutationPayload
data: DocumentTemplateVersionMutationPayload,
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
method: 'POST',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function updateDocumentTemplateVersion(
id: string,
data: Partial<DocumentTemplateVersionMutationPayload>
data: Partial<DocumentTemplateVersionMutationPayload>,
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive })
body: JSON.stringify({ isActive }),
});
}
export async function createDocumentTemplateMapping(
templateId: string,
versionId: string,
data: DocumentTemplateMappingMutationPayload
data: DocumentTemplateMappingMutationPayload,
) {
void templateId;
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
method: 'POST',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function updateDocumentTemplateMapping(
id: string,
data: Partial<DocumentTemplateMappingMutationPayload>
mappingId: string,
data: Partial<DocumentTemplateMappingMutationPayload>,
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
method: 'PATCH',
body: JSON.stringify(data)
body: JSON.stringify(data),
});
}
export async function deleteDocumentTemplateMapping(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
method: 'DELETE'
export async function deleteDocumentTemplateMapping(mappingId: string) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${mappingId}`, {
method: 'DELETE',
});
}
export async function getDocumentTemplateVersionManagement(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/management`);
}
export async function validateDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionValidateResponse>(`${BASE_PATH}/versions/${id}/validate`, {
method: 'POST',
});
}
export async function publishDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/publish`, {
method: 'POST',
});
}
export async function activateDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/activate`, {
method: 'POST',
});
}
export async function rollbackDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/rollback`, {
method: 'POST',
});
}
export async function archiveDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionActionResponse>(`${BASE_PATH}/versions/${id}/archive`, {
method: 'POST',
});
}
export async function previewDocumentTemplateVersion(id: string) {
return apiClient<DocumentTemplateVersionPreviewResponse>(`${BASE_PATH}/versions/${id}/preview`);
}
export async function compareDocumentTemplateVersions(
templateId: string,
leftVersionId: string,
rightVersionId: string,
) {
return apiClient<DocumentTemplateVersionCompareResponse>(`${BASE_PATH}/${templateId}/compare`, {
method: 'POST',
body: JSON.stringify({ leftVersionId, rightVersionId }),
});
}

View File

@@ -1,3 +1,10 @@
export type DocumentTemplateLifecycleStatus =
| 'draft'
| 'validated'
| 'published'
| 'active'
| 'archived';
export interface DocumentTemplateRecord {
id: string;
organizationId: string;
@@ -15,6 +22,27 @@ export interface DocumentTemplateRecord {
updatedBy: string;
}
export interface DocumentTemplateVersionValidationSummary {
status: 'PASS' | 'WARNING' | 'FAIL';
validatedAt: string | null;
requiredFieldsMissing: string[];
duplicateFields: string[];
unmappedPlaceholders: string[];
runtimeIssues: Array<{
code: string;
severity: 'warning' | 'error';
message: string;
}>;
}
export interface DocumentTemplateVersionAuditSummary {
status: 'PASS' | 'WARNING' | 'FAIL';
auditedAt: string | null;
runtimeVersion: string | null;
visualRegressionStatus: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
visualRegressionAt: string | null;
}
export interface DocumentTemplateVersionRecord {
id: string;
organizationId: string;
@@ -24,6 +52,18 @@ export interface DocumentTemplateVersionRecord {
schemaJson: unknown;
previewImageUrl: string | null;
isActive: boolean;
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;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -80,16 +120,21 @@ export interface DocumentTemplateMappingMutationPayload {
columns?: DocumentTemplateMappingColumnMutationPayload[];
}
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
export interface DocumentTemplateMappingWithColumns
extends DocumentTemplateMappingRecord {
columns: DocumentTemplateTableColumnRecord[];
}
export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
export interface DocumentTemplateVersionDetail
extends DocumentTemplateVersionRecord {
mappings: DocumentTemplateMappingWithColumns[];
validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null;
}
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
versions: DocumentTemplateVersionDetail[];
fileType: string;
mappingCount: number;
}
@@ -102,28 +147,7 @@ export interface DocumentTemplateListItem extends DocumentTemplateRecord {
export interface DocumentTemplateFilters {
documentType?: string;
fileType?: string;
isActive?: string;
}
export interface DocumentTemplateListResponse {
success: boolean;
time: string;
message: string;
items: DocumentTemplateListItem[];
}
export interface DocumentTemplateDetailResponse {
success: boolean;
time: string;
message: string;
template: DocumentTemplateDetail;
}
export interface DocumentTemplateVersionsResponse {
success: boolean;
time: string;
message: string;
items: DocumentTemplateVersionDetail[];
isActive?: 'true' | 'false';
}
export interface DocumentTemplateMutationPayload {
@@ -142,11 +166,15 @@ export interface DocumentTemplateVersionMutationPayload {
schemaJson: unknown;
previewImageUrl?: string | null;
isActive?: boolean;
runtimeVersion?: string | null;
templateVariant?: string | null;
brand?: string | null;
cloneFromVersionId?: string | null;
}
export interface ResolveTemplateParams {
documentType: string;
productType?: string | null;
productType: string;
fileType: string;
}
@@ -159,3 +187,62 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}
export interface DocumentTemplateListResponse extends MutationSuccessResponse {
time: string;
items: DocumentTemplateListItem[];
}
export interface DocumentTemplateDetailResponse extends MutationSuccessResponse {
time: string;
template: DocumentTemplateDetail;
}
export interface DocumentTemplateVersionsResponse extends MutationSuccessResponse {
time: string;
items: DocumentTemplateVersionDetail[];
}
export interface DocumentTemplateVersionActionResponse
extends MutationSuccessResponse {
version: DocumentTemplateVersionDetail;
}
export interface DocumentTemplateVersionValidateResponse
extends MutationSuccessResponse {
validation: DocumentTemplateVersionValidationSummary;
}
export interface DocumentTemplateVersionCompareResponse
extends MutationSuccessResponse {
comparison: {
leftVersionId: string;
rightVersionId: string;
metadataChanges: Array<{
field: string;
left: string | number | boolean | null;
right: string | number | boolean | null;
}>;
placeholderOnlyLeft: string[];
placeholderOnlyRight: string[];
mappingDifferences: Array<{
placeholderKey: string;
difference: string;
}>;
runtimeCompatibility: {
leftStatus: 'PASS' | 'WARNING' | 'FAIL';
rightStatus: 'PASS' | 'WARNING' | 'FAIL';
};
};
}
export interface DocumentTemplateVersionPreviewResponse
extends MutationSuccessResponse {
preview: {
fileName: string;
contentType: 'application/pdf';
base64: string;
pageCount: number;
source: 'audit-fixture';
};
}