import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; import { crmDocumentLibraries, crmDocumentLibraryVersions } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import { auditAction } from '@/features/foundation/audit-log/service'; import { createArtifact, downloadArtifact, getActiveArtifactForEntity, voidArtifact, } from '@/features/foundation/document-artifact/server/service'; import { type DocumentLibraryBrand, type DocumentLibraryProductType, type DocumentLibraryType, } from '@/features/foundation/document-library/types'; import { buildOrganizationStorageKey, getStorageProvider, } from '@/features/foundation/storage/service'; import { sha256 } from '@/features/foundation/storage/server/checksum'; import { buildQuotationDocumentData, getQuotationDocumentPreviewData, } from '@/features/crm/quotations/document/server/service'; import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service'; import type { AssemblyPolicy, AssemblySourcePolicy, AssemblyWarning, DocumentAssemblyResult, ResolvedAssemblySource, } from '../types'; import { getPdfPageCount, mergePdfBuffers, normalizeAssemblyBrand, normalizeAssemblyProductType, validateAssemblySourceOrdering, } from './helpers'; type ResolvedDocumentLibraryCandidate = { library: typeof crmDocumentLibraries.$inferSelect; version: typeof crmDocumentLibraryVersions.$inferSelect; }; type AssembleDocumentInput = { organizationId: string; userId: string; entityType: string; entityId: string; fileName: string; artifactType: 'assembled_pdf'; policy: AssemblyPolicy; sourceResolver: ( source: AssemblySourcePolicy, ) => Promise; auditEntityType?: string; }; type QuotationAssemblyMetadata = { documentType: string; sourceDocumentType: string; sourceDocumentId: string; sourceDocumentCode: string; assemblyPolicy: AssemblyPolicy; sourceArtifacts: Array>; warnings: AssemblyWarning[]; pageCount: number; generatedAt: string; generatedBy: string; }; const ASSEMBLED_PDF_ARTIFACT_TYPE = 'assembled_pdf'; const DEFAULT_ASSEMBLY_LANGUAGE = 'th'; function sanitizeFileName(fileName: string) { return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-'); } function toLibraryDocumentLabel(documentType: DocumentLibraryType) { return documentType.replace(/_/g, ' ').toUpperCase(); } function toRoleLabel(role: string) { return role.replace(/_/g, ' ').toUpperCase(); } function buildDocumentLibraryMissingMessage(input: { role: string; documentType: DocumentLibraryType; brand: DocumentLibraryBrand; language: string; productType: DocumentLibraryProductType; }) { return `Missing ${toRoleLabel(input.role)} document (${toLibraryDocumentLabel( input.documentType, )}, brand ${input.brand}, language ${input.language}, product type ${input.productType}).`; } function getCandidateScore(input: { candidate: typeof crmDocumentLibraries.$inferSelect; brand: DocumentLibraryBrand; productType: DocumentLibraryProductType; }) { let score = 0; if (input.candidate.brand === input.brand) { score += 2; } if (input.candidate.productType === input.productType) { score += 1; } return score; } export async function resolveActiveDocumentLibraryCandidate(input: { organizationId: string; documentType: DocumentLibraryType; brand: DocumentLibraryBrand; language: string; productType: DocumentLibraryProductType; }): Promise { const brandScope = input.brand === 'generic' ? ['generic'] : [input.brand, 'generic']; const productTypeScope = input.productType === 'all' ? ['all'] : [input.productType, 'all']; const rows = await db .select({ library: crmDocumentLibraries, version: crmDocumentLibraryVersions, }) .from(crmDocumentLibraryVersions) .innerJoin( crmDocumentLibraries, eq(crmDocumentLibraryVersions.libraryId, crmDocumentLibraries.id), ) .where( and( eq(crmDocumentLibraries.organizationId, input.organizationId), eq(crmDocumentLibraryVersions.organizationId, input.organizationId), eq(crmDocumentLibraries.documentType, input.documentType), eq(crmDocumentLibraries.language, input.language), inArray(crmDocumentLibraries.brand, brandScope), inArray(crmDocumentLibraries.productType, productTypeScope), eq(crmDocumentLibraries.isActive, true), eq(crmDocumentLibraries.status, 'active'), eq(crmDocumentLibraryVersions.isActive, true), eq(crmDocumentLibraryVersions.status, 'active'), isNull(crmDocumentLibraries.deletedAt), isNull(crmDocumentLibraryVersions.deletedAt), ), ) .orderBy( desc(crmDocumentLibraryVersions.createdAt), crmDocumentLibraries.code, ); if (!rows.length) { return null; } const [best] = [...rows].sort((left, right) => { const scoreDelta = getCandidateScore({ candidate: right.library, brand: input.brand, productType: input.productType, }) - getCandidateScore({ candidate: left.library, brand: input.brand, productType: input.productType, }); if (scoreDelta !== 0) { return scoreDelta; } const codeDelta = left.library.code.localeCompare(right.library.code); if (codeDelta !== 0) { return codeDelta; } return right.version.createdAt.getTime() - left.version.createdAt.getTime(); }); return best; } async function resolveDocumentLibrarySource(input: { organizationId: string; role: string; required: boolean; order: number; documentType: DocumentLibraryType; brand: DocumentLibraryBrand; language: string; productType: DocumentLibraryProductType; }) { const candidate = await resolveActiveDocumentLibraryCandidate({ organizationId: input.organizationId, documentType: input.documentType, brand: input.brand, language: input.language, productType: input.productType, }); if (!candidate) { return null; } const object = await getStorageProvider().getObject({ key: candidate.version.storageKey, }); const buffer = Buffer.from(await new Response(object.body).arrayBuffer()); const pageCount = await getPdfPageCount( buffer, `${toRoleLabel(input.role)} source`, ); return { role: input.role, sourceType: 'document_library' as const, required: input.required, order: input.order, fileName: candidate.version.fileName, contentType: candidate.version.mimeType, checksum: candidate.version.checksum, pageCount, fileSize: candidate.version.fileSize, metadata: { documentType: candidate.library.documentType, libraryId: candidate.library.id, libraryCode: candidate.library.code, libraryName: candidate.library.name, brand: candidate.library.brand, language: candidate.library.language, productType: candidate.library.productType, versionId: candidate.version.id, version: candidate.version.version, storageKey: candidate.version.storageKey, }, buffer, }; } async function persistAssembledArtifact(input: { organizationId: string; userId: string; entityType: string; entityId: string; artifactType: 'assembled_pdf'; fileName: string; buffer: Buffer; checksum: string; fileSize: number; metadata: QuotationAssemblyMetadata; }) { const existingArtifact = await getActiveArtifactForEntity({ organizationId: input.organizationId, entityType: input.entityType, entityId: input.entityId, artifactType: input.artifactType, }); if (existingArtifact?.status === 'active') { await voidArtifact({ artifactId: existingArtifact.id, organizationId: input.organizationId, userId: input.userId, reason: 'Superseded by newer assembled PDF artifact.', }); } const safeFileName = sanitizeFileName(input.fileName); const storageKey = buildOrganizationStorageKey(input.organizationId, [ 'quotations', 'assembled', `${input.entityId}-${safeFileName}`, ]); const stored = await getStorageProvider().putObject({ key: storageKey, body: input.buffer, contentType: 'application/pdf', fileName: safeFileName, }); const artifact = await createArtifact({ organizationId: input.organizationId, userId: input.userId, entityType: input.entityType, entityId: input.entityId, documentType: input.metadata.sourceDocumentType, artifactType: input.artifactType, templateVersionId: null, storageProvider: stored.provider, storageKey: stored.key, fileName: safeFileName, contentType: 'application/pdf', fileSize: input.fileSize, checksum: input.checksum, metadata: input.metadata, }); return { artifactId: artifact.id, storageKey: artifact.storageKey, fileName: artifact.fileName, checksum: artifact.checksum ?? input.checksum, fileSize: artifact.fileSize ?? input.fileSize, pageCount: input.metadata.pageCount, }; } export async function assembleDocument( input: AssembleDocumentInput, ): Promise { validateAssemblySourceOrdering(input.policy.sources); const auditEntityType = input.auditEntityType ?? input.entityType; const warnings: AssemblyWarning[] = []; await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_started', afterData: { policy: input.policy }, }); try { const sources: ResolvedAssemblySource[] = []; for (const sourcePolicy of [...input.policy.sources].sort( (left, right) => left.order - right.order, )) { const resolvedSource = await input.sourceResolver(sourcePolicy); if (!resolvedSource) { if (sourcePolicy.sourceType === 'document_library') { const message = buildDocumentLibraryMissingMessage({ role: sourcePolicy.role, documentType: sourcePolicy.documentType!, brand: input.policy.brand ?? 'generic', language: input.policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE, productType: input.policy.productType ?? 'all', }); if (sourcePolicy.required) { await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_failed', afterData: { role: sourcePolicy.role, message }, }); throw new AuthError(message, 409); } warnings.push({ code: 'missing_optional_source', role: sourcePolicy.role, message, }); await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_source_missing', afterData: { role: sourcePolicy.role, message }, }); continue; } throw new AuthError( `Required generated source missing for role ${sourcePolicy.role}.`, 409, ); } sources.push(resolvedSource); await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_source_resolved', afterData: { role: resolvedSource.role, sourceType: resolvedSource.sourceType, fileName: resolvedSource.fileName, checksum: resolvedSource.checksum, pageCount: resolvedSource.pageCount, metadata: resolvedSource.metadata, }, }); } if (!sources.length) { throw new AuthError('No assembly sources available.', 409); } const buffer = await mergePdfBuffers(sources); const checksum = sha256(buffer); const fileSize = buffer.byteLength; const pageCount = await getPdfPageCount(buffer, 'Merged assembly output'); await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_merge_completed', afterData: { fileName: input.fileName, checksum, fileSize, pageCount, sourceCount: sources.length, }, }); return { buffer, fileName: input.fileName, checksum, fileSize, pageCount, policy: input.policy, warnings, sources, artifact: null, }; } catch (error) { if (!(error instanceof AuthError)) { await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: auditEntityType, entityId: input.entityId, action: 'assembly_failed', afterData: { message: error instanceof Error ? error.message : 'Unknown assembly failure', }, }); } throw error; } } export async function buildDefaultQuotationAssemblyPolicy(input: { quotationId: string; organizationId: string; }): Promise { const documentData = await buildQuotationDocumentData( input.quotationId, input.organizationId, ); const productTypeCode = documentData.items.find((item) => item.productTypeCode)?.productTypeCode ?? documentData.quotation.quotationTypeCode; return { documentType: 'quotation_pdf', organizationId: input.organizationId, brand: normalizeAssemblyBrand(documentData.company.slug), language: DEFAULT_ASSEMBLY_LANGUAGE, productType: normalizeAssemblyProductType(productTypeCode), sources: [ { sourceType: 'generated', role: 'main', required: true, order: 1, }, { sourceType: 'document_library', role: 'sla', required: true, order: 2, documentType: 'sla', }, { sourceType: 'document_library', role: 'warranty', required: false, order: 3, documentType: 'warranty', }, { sourceType: 'document_library', role: 'datasheet', required: false, order: 4, documentType: 'datasheet', }, { sourceType: 'document_library', role: 'drawing', required: false, order: 5, documentType: 'drawing', }, ], }; } export async function generateAssembledQuotationPdf( quotationId: string, organizationId: string, userId: string, ) { const [policy, preview] = await Promise.all([ buildDefaultQuotationAssemblyPolicy({ quotationId, organizationId }), getQuotationDocumentPreviewData(quotationId, organizationId), ]); const assembled = await assembleDocument({ organizationId, userId, entityType: 'crm_quotation', entityId: quotationId, auditEntityType: 'crm_quotation', fileName: `${preview.documentData.quotation.code}-assembled.pdf`, artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, policy, sourceResolver: async (sourcePolicy) => { if (sourcePolicy.sourceType === 'generated') { const generated = await generateQuotationPdf(quotationId, organizationId); const pageCount = await getPdfPageCount( generated.buffer, 'Generated quotation PDF', ); return { role: sourcePolicy.role, sourceType: 'generated', required: sourcePolicy.required, order: sourcePolicy.order, fileName: generated.fileName, contentType: 'application/pdf', checksum: sha256(generated.buffer), pageCount, fileSize: generated.buffer.byteLength, metadata: { templateVersionId: generated.templateVersionId, sourceDocumentType: 'quotation_pdf', }, buffer: generated.buffer, }; } return resolveDocumentLibrarySource({ organizationId, role: sourcePolicy.role, required: sourcePolicy.required, order: sourcePolicy.order, documentType: sourcePolicy.documentType!, brand: policy.brand ?? 'generic', language: policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE, productType: policy.productType ?? 'all', }); }, }); const metadata: QuotationAssemblyMetadata = { documentType: 'assembled_pdf', sourceDocumentType: 'quotation_pdf', sourceDocumentId: quotationId, sourceDocumentCode: preview.documentData.quotation.code, assemblyPolicy: assembled.policy, sourceArtifacts: assembled.sources.map((source) => ({ role: source.role, sourceType: source.sourceType, fileName: source.fileName, checksum: source.checksum, fileSize: source.fileSize, pageCount: source.pageCount, metadata: source.metadata, })), warnings: assembled.warnings, pageCount: assembled.pageCount, generatedAt: new Date().toISOString(), generatedBy: userId, }; const artifact = await persistAssembledArtifact({ organizationId, userId, entityType: 'crm_quotation', entityId: quotationId, artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, fileName: assembled.fileName, buffer: assembled.buffer, checksum: assembled.checksum, fileSize: assembled.fileSize, metadata, }); await auditAction({ organizationId, userId, entityType: 'crm_quotation', entityId: quotationId, action: 'assembly_artifact_stored', afterData: artifact, }); return { ...assembled, artifact, }; } export async function getStoredAssembledQuotationPdf( quotationId: string, organizationId: string, options?: { userId?: string; auditDownload?: boolean; }, ) { const existingArtifact = await getActiveArtifactForEntity({ organizationId, entityType: 'crm_quotation', entityId: quotationId, artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, }); if (!existingArtifact) { throw new AuthError('Assembled quotation PDF has not been generated yet.', 404); } const { object, artifact } = await downloadArtifact({ artifactId: existingArtifact.id, organizationId, userId: options?.userId, auditDownload: options?.auditDownload, }); const buffer = Buffer.from(await new Response(object.body).arrayBuffer()); return { artifact, buffer, fileName: object.fileName || `${quotationId}-assembled.pdf`, }; }