task-p.5
This commit is contained in:
@@ -4,7 +4,7 @@ import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/servic
|
||||
import {
|
||||
getDocumentTemplate,
|
||||
softDeleteDocumentTemplate,
|
||||
updateDocumentTemplate
|
||||
updateDocumentTemplate,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -20,14 +20,14 @@ const documentTemplateSchema = z.object({
|
||||
templateName: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const template = await getDocumentTemplate(id, organization.id);
|
||||
|
||||
@@ -35,16 +35,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document template loaded successfully',
|
||||
template
|
||||
template,
|
||||
});
|
||||
} 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 to load document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +57,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = documentTemplateSchema.parse(await request.json());
|
||||
const before = await getDocumentTemplate(id, organization.id);
|
||||
const updated = await updateDocumentTemplate(id, organization.id, session.user.id, payload);
|
||||
const updated = await updateDocumentTemplate(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -64,24 +74,30 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template',
|
||||
entityId: id,
|
||||
beforeData: before,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template updated successfully'
|
||||
message: 'Document template updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to update document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable update document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +105,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateDelete
|
||||
permission: PERMISSIONS.crmDocumentTemplateDelete,
|
||||
});
|
||||
const before = await getDocumentTemplate(id, organization.id);
|
||||
const updated = await softDeleteDocumentTemplate(id, organization.id, session.user.id);
|
||||
const updated = await softDeleteDocumentTemplate(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
);
|
||||
|
||||
await auditDelete({
|
||||
organizationId: organization.id,
|
||||
@@ -100,20 +120,25 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template',
|
||||
entityId: id,
|
||||
beforeData: before,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template deleted successfully'
|
||||
message: 'Document template deleted successfully',
|
||||
});
|
||||
} 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 to delete document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable delete document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
createDocumentTemplateVersion,
|
||||
listDocumentTemplateVersions
|
||||
listDocumentTemplateVersions,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -17,14 +17,14 @@ const versionSchema = z.object({
|
||||
filePath: z.string().optional().nullable(),
|
||||
schemaJson: z.unknown(),
|
||||
previewImageUrl: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const items = await listDocumentTemplateVersions(id, organization.id);
|
||||
|
||||
@@ -32,16 +32,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document template versions loaded successfully',
|
||||
items
|
||||
items,
|
||||
});
|
||||
} 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 to load document template versions' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document template versions' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,33 +54,44 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = versionSchema.parse(await request.json());
|
||||
const created = await createDocumentTemplateVersion(id, organization.id, session.user.id, payload);
|
||||
const created = await createDocumentTemplateVersion(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: created.id,
|
||||
afterData: created
|
||||
afterData: created,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version created successfully'
|
||||
message: 'Document template version created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to create document template version' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable create document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
createDocumentTemplate,
|
||||
listDocumentTemplates
|
||||
listDocumentTemplates,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -15,68 +15,87 @@ const documentTemplateSchema = z.object({
|
||||
templateName: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
const isActiveParam = searchParams.get('isActive');
|
||||
const items = await listDocumentTemplates(organization.id, {
|
||||
documentType: searchParams.get('documentType') ?? undefined,
|
||||
fileType: searchParams.get('fileType') ?? undefined,
|
||||
isActive: searchParams.get('isActive') ?? undefined
|
||||
isActive:
|
||||
isActiveParam === 'true' || isActiveParam === 'false'
|
||||
? isActiveParam
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Document templates loaded successfully',
|
||||
items
|
||||
items,
|
||||
});
|
||||
} 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 to load document templates' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable load document templates' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateCreate
|
||||
permission: PERMISSIONS.crmDocumentTemplateCreate,
|
||||
});
|
||||
const payload = documentTemplateSchema.parse(await request.json());
|
||||
const created = await createDocumentTemplate(organization.id, session.user.id, payload);
|
||||
const created = await createDocumentTemplate(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template',
|
||||
entityId: created.id,
|
||||
afterData: created
|
||||
afterData: created,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template created successfully'
|
||||
message: 'Document template created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to create document template' }, { status: 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable create document template' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { compareDocumentTemplateVersions } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const compareSchema = z.object({
|
||||
leftVersionId: z.string().min(1),
|
||||
rightVersionId: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const payload = compareSchema.parse(await request.json());
|
||||
const comparison = await compareDocumentTemplateVersions({
|
||||
templateId: id,
|
||||
leftVersionId: payload.leftVersionId,
|
||||
rightVersionId: payload.rightVersionId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template versions compared successfully',
|
||||
comparison,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable compare document template versions' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { activateDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateActivate,
|
||||
});
|
||||
const version = await activateDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'activate',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version activated 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 activate document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { archiveDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateArchive,
|
||||
});
|
||||
const version = await archiveDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'archive',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version archived 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 archive document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getManagedDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const version = await getManagedDocumentTemplateVersion(id, organization.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version management data loaded 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 load document template version management data' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { previewDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateRead,
|
||||
});
|
||||
const preview = await previewDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template preview generated successfully',
|
||||
preview,
|
||||
});
|
||||
} 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 generate document template preview' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { publishDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplatePublish,
|
||||
});
|
||||
const version = await publishDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'publish',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version published 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 publish document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { rollbackDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateRollback,
|
||||
});
|
||||
const version = await rollbackDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'rollback',
|
||||
afterData: version,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version rolled back 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 rollback document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { auditUpdate } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
setDocumentTemplateVersionActive,
|
||||
updateDocumentTemplateVersion
|
||||
updateDocumentTemplateVersion,
|
||||
} from '@/features/foundation/document-template/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
@@ -17,19 +17,27 @@ const versionSchema = z.object({
|
||||
filePath: z.string().optional().nullable(),
|
||||
schemaJson: z.unknown().optional(),
|
||||
previewImageUrl: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
runtimeVersion: z.string().optional().nullable(),
|
||||
templateVariant: z.string().optional().nullable(),
|
||||
brand: z.string().optional().nullable(),
|
||||
cloneFromVersionId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||
permission: PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const payload = versionSchema.parse(await request.json());
|
||||
|
||||
if (Object.keys(payload).length === 1 && typeof payload.isActive === 'boolean') {
|
||||
const result = await setDocumentTemplateVersionActive(id, organization.id, payload.isActive);
|
||||
const result = await setDocumentTemplateVersionActive(
|
||||
id,
|
||||
organization.id,
|
||||
payload.isActive,
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -37,12 +45,12 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
beforeData: result.before,
|
||||
afterData: result.after
|
||||
afterData: result.after,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version status updated successfully'
|
||||
message: 'Document template version status updated successfully',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,29 +61,29 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
afterData: updated
|
||||
afterData: updated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version updated successfully'
|
||||
message: 'Document template version updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ message: error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable to update document template version' },
|
||||
{ status: 500 }
|
||||
{ message: 'Unable update document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { validateAndPersistDocumentTemplateVersion } from '@/features/foundation/document-template/server/management-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.crmDocumentTemplateUpdate,
|
||||
});
|
||||
const validation = await validateAndPersistDocumentTemplateVersion({
|
||||
versionId: id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_document_template_version',
|
||||
entityId: id,
|
||||
action: 'validate',
|
||||
afterData: validation,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Document template version validated successfully',
|
||||
validation,
|
||||
});
|
||||
} 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 validate document template version' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ export const PERMISSIONS = {
|
||||
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
||||
crmDocumentTemplatePublish: 'crm.document_template.publish',
|
||||
crmDocumentTemplateActivate: 'crm.document_template.activate',
|
||||
crmDocumentTemplateRollback: 'crm.document_template.rollback',
|
||||
crmDocumentTemplateArchive: 'crm.document_template.archive',
|
||||
crmDocumentSequenceRead: 'crm.document_sequence.read',
|
||||
crmDocumentSequenceCreate: 'crm.document_sequence.create',
|
||||
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
|
||||
@@ -653,6 +657,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplatePublish, label: 'Publish document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateActivate, label: 'Activate document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateRollback, label: 'Rollback document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentTemplateArchive, label: 'Archive document templates' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
|
||||
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
|
||||
|
||||
Reference in New Issue
Block a user