This commit is contained in:
phaichayon
2026-06-16 17:01:29 +07:00
parent 90ee59d388
commit 0a484e0b45
28 changed files with 6483 additions and 277 deletions

View File

@@ -44,6 +44,21 @@ export interface QuotationSalesmanLookup {
name: string;
}
export interface QuotationApprovedArtifactSummary {
id: string;
fileName: string;
contentType: string;
fileSize: number | null;
checksum: string | null;
status: string;
generatedAt: string;
generatedBy: string;
generatedByName: string | null;
lockedAt: string | null;
lockedBy: string | null;
lockedByName: string | null;
}
export interface QuotationRecord {
id: string;
organizationId: string;
@@ -80,9 +95,12 @@ export interface QuotationRecord {
sentAt: string | null;
sentVia: string | null;
approvedAt: string | null;
approvedArtifactId: string | null;
approvedPdfUrl: string | null;
approvedSnapshot: unknown | null;
approvedTemplateVersionId: string | null;
approvedArtifact: QuotationApprovedArtifactSummary | null;
hasLegacyApprovedPdf: boolean;
acceptedAt: string | null;
rejectedAt: string | null;
rejectionReason: string | null;

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,6 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
type SQL
} from 'drizzle-orm';
import {
crmDocumentArtifacts,
crmCustomerContacts,
crmCustomers,
crmEnquiries,
@@ -34,6 +24,7 @@ import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
QuotationAttachmentRecord,
QuotationApprovedArtifactSummary,
QuotationBranchOption,
QuotationContactLookup,
QuotationCustomerListItem,
@@ -95,7 +86,30 @@ function mapBranch(
};
}
function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRecord {
function mapApprovedArtifactSummary(
row: typeof crmDocumentArtifacts.$inferSelect,
options?: { generatedByName?: string | null; lockedByName?: string | null }
): QuotationApprovedArtifactSummary {
return {
id: row.id,
fileName: row.fileName,
contentType: row.contentType,
fileSize: row.fileSize,
checksum: row.checksum,
status: row.status,
generatedAt: row.generatedAt.toISOString(),
generatedBy: row.generatedBy,
generatedByName: options?.generatedByName ?? null,
lockedAt: row.lockedAt?.toISOString() ?? null,
lockedBy: row.lockedBy,
lockedByName: options?.lockedByName ?? null
};
}
function mapQuotationRecord(
row: typeof crmQuotations.$inferSelect,
options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null }
): QuotationRecord {
return {
id: row.id,
organizationId: row.organizationId,
@@ -132,9 +146,15 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe
sentAt: row.sentAt?.toISOString() ?? null,
sentVia: row.sentVia,
approvedAt: row.approvedAt?.toISOString() ?? null,
approvedArtifactId: row.approvedArtifactId,
approvedPdfUrl: row.approvedPdfUrl,
approvedSnapshot: row.approvedSnapshot ?? null,
approvedTemplateVersionId: row.approvedTemplateVersionId,
approvedArtifact: options?.approvedArtifact ?? null,
hasLegacyApprovedPdf:
!row.approvedArtifactId &&
typeof row.approvedPdfUrl === 'string' &&
row.approvedPdfUrl.startsWith('/generated/'),
acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason,
@@ -334,11 +354,7 @@ async function resolveOptionCodeById(
return options.find((option) => option.id === optionId)?.code ?? null;
}
async function resolveOptionIdByCode(
organizationId: string,
category: string,
code: string
) {
async function resolveOptionIdByCode(organizationId: string, category: string, code: string) {
const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.code === code)?.id ?? null;
}
@@ -724,7 +740,11 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
];
}
async function refreshQuotationTotals(quotationId: string, organizationId: string, userId?: string) {
async function refreshQuotationTotals(
quotationId: string,
organizationId: string,
userId?: string
) {
const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId);
const items = await listQuotationItems(quotationId, organizationId);
const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
@@ -903,7 +923,9 @@ export async function listQuotations(
contactIds.length
? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
: [],
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [],
enquiryIds.length
? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
: [],
quotationIds.length
? db
.select({ quotationId: crmQuotationItems.quotationId, value: count() })
@@ -942,7 +964,47 @@ export async function getQuotationDetail(
organizationId: string
): Promise<QuotationRecord> {
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
return mapQuotationRecord(quotation);
const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
? quotation.approvedPdfUrl.replace('artifact:', '')
: null);
if (!approvedArtifactId) {
return mapQuotationRecord(quotation);
}
const [artifact] = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.id, approvedArtifactId),
eq(crmDocumentArtifacts.organizationId, organizationId),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.limit(1);
if (!artifact) {
return mapQuotationRecord(quotation);
}
const relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[];
const actorRows = relatedUserIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, relatedUserIds))
: [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
return mapQuotationRecord(quotation, {
approvedArtifact: mapApprovedArtifactSummary(artifact, {
generatedByName: actorMap.get(artifact.generatedBy) ?? null,
lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null
})
});
}
export async function getQuotationActivity(
@@ -961,11 +1023,15 @@ export async function getQuotationActivity(
log.entityType === 'crm_quotation_customer' ||
log.entityType === 'crm_quotation_topic' ||
log.entityType === 'crm_quotation_followup' ||
log.entityType === 'crm_quotation_attachment'
log.entityType === 'crm_quotation_attachment' ||
log.entityType === 'crm_document_artifact'
);
const userIds = [...new Set(filtered.map((log) => log.userId))];
const actorRows = userIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, userIds))
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds))
: [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
@@ -1029,6 +1095,7 @@ export async function createQuotation(
isSent: false,
sentVia: payload.sentVia ?? null,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
@@ -1119,23 +1186,48 @@ export async function softDeleteQuotation(id: string, organizationId: string, us
db
.update(crmQuotationItems)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId))),
.where(
and(
eq(crmQuotationItems.quotationId, id),
eq(crmQuotationItems.organizationId, organizationId)
)
),
db
.update(crmQuotationCustomers)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId))),
.where(
and(
eq(crmQuotationCustomers.quotationId, id),
eq(crmQuotationCustomers.organizationId, organizationId)
)
),
db
.update(crmQuotationTopics)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationTopics.quotationId, id), eq(crmQuotationTopics.organizationId, organizationId))),
.where(
and(
eq(crmQuotationTopics.quotationId, id),
eq(crmQuotationTopics.organizationId, organizationId)
)
),
db
.update(crmQuotationFollowups)
.set({ deletedAt: now, updatedAt: now, updatedBy: userId })
.where(and(eq(crmQuotationFollowups.quotationId, id), eq(crmQuotationFollowups.organizationId, organizationId))),
.where(
and(
eq(crmQuotationFollowups.quotationId, id),
eq(crmQuotationFollowups.organizationId, organizationId)
)
),
db
.update(crmQuotationAttachments)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationAttachments.quotationId, id), eq(crmQuotationAttachments.organizationId, organizationId)))
.where(
and(
eq(crmQuotationAttachments.quotationId, id),
eq(crmQuotationAttachments.organizationId, organizationId)
)
)
]);
return updated;
@@ -1506,7 +1598,12 @@ export async function softDeleteQuotationTopic(
await db
.update(crmQuotationTopicItems)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId)));
.where(
and(
eq(crmQuotationTopicItems.topicId, topicId),
eq(crmQuotationTopicItems.organizationId, organizationId)
)
);
return updated;
}
@@ -1708,11 +1805,8 @@ export async function createQuotationRevision(
);
}
const revisedStatusId =
(await resolveOptionIdByCode(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
'revised'
)) ?? parent.status;
(await resolveOptionIdByCode(organizationId, QUOTATION_OPTION_CATEGORIES.status, 'revised')) ??
parent.status;
const familyRootId = parent.parentQuotationId ?? parent.id;
const familyRows = await db
@@ -1774,6 +1868,7 @@ export async function createQuotationRevision(
isSent: false,
sentVia: parent.sentVia,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,

View File

@@ -1,15 +1,4 @@
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
type SQL
} from 'drizzle-orm';
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
crmApprovalActions,
crmApprovalRequests,
@@ -159,11 +148,7 @@ async function resolveQuotationStatusCodeById(organizationId: string, id: string
return options.find((option) => option.id === id)?.code ?? null;
}
async function assertWorkflowByCode(
organizationId: string,
code: string,
entityType?: string
) {
async function assertWorkflowByCode(organizationId: string, code: string, entityType?: string) {
const [workflow] = await db
.select()
.from(crmApprovalWorkflows)
@@ -322,15 +307,13 @@ async function syncQuotationStatusFromApproval(
};
if (nextStatusCode !== 'approved') {
nextValues.approvedArtifactId = null;
nextValues.approvedPdfUrl = null;
nextValues.approvedSnapshot = null;
nextValues.approvedTemplateVersionId = null;
}
await db
.update(crmQuotations)
.set(nextValues)
.where(eq(crmQuotations.id, quotationId));
await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
}
async function syncEntityStatusFromApproval(
@@ -365,11 +348,7 @@ async function syncEntityStatusFromApproval(
}
}
async function assertActorCanHandleStep(
organizationId: string,
userId: string,
roleCode: string
) {
async function assertActorCanHandleStep(organizationId: string, userId: string, roleCode: string) {
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!userRow) {
@@ -473,12 +452,14 @@ export async function listApprovalRequests(
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
const [workflows, requesters, entitySummaries] = await Promise.all([
workflowIds.length
? db
.select()
.from(crmApprovalWorkflows)
.where(inArray(crmApprovalWorkflows.id, workflowIds))
? db.select().from(crmApprovalWorkflows).where(inArray(crmApprovalWorkflows.id, workflowIds))
: [],
requesterIds.length
? db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, requesterIds))
: [],
requesterIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, requesterIds)) : [],
resolveEntitySummaries(
organizationId,
rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId }))
@@ -544,7 +525,10 @@ export async function getApprovalTimeline(
.orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt));
const actorIds = [...new Set(rows.map((row) => row.actedBy))];
const actors = actorIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, actorIds))
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, actorIds))
: [];
const actorMap = new Map(actors.map((row) => [row.id, row.name]));

View File

@@ -0,0 +1,313 @@
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { crmDocumentArtifacts, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
export type DocumentArtifactRecord = {
id: string;
organizationId: string;
entityType: string;
entityId: string;
documentType: string;
artifactType: string;
templateVersionId: string | null;
storageProvider: string;
storageKey: string;
fileName: string;
contentType: string;
fileSize: number | null;
checksum: string | null;
status: string;
generatedBy: string;
generatedByName: string | null;
generatedAt: string;
lockedAt: string | null;
lockedBy: string | null;
lockedByName: string | null;
voidedAt: string | null;
voidedBy: string | null;
voidReason: string | null;
metadata: unknown | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
function mapDocumentArtifactRecord(
row: typeof crmDocumentArtifacts.$inferSelect,
names?: { generatedByName?: string | null; lockedByName?: string | null }
): DocumentArtifactRecord {
return {
id: row.id,
organizationId: row.organizationId,
entityType: row.entityType,
entityId: row.entityId,
documentType: row.documentType,
artifactType: row.artifactType,
templateVersionId: row.templateVersionId,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
fileName: row.fileName,
contentType: row.contentType,
fileSize: row.fileSize,
checksum: row.checksum,
status: row.status,
generatedBy: row.generatedBy,
generatedByName: names?.generatedByName ?? null,
generatedAt: row.generatedAt.toISOString(),
lockedAt: row.lockedAt?.toISOString() ?? null,
lockedBy: row.lockedBy,
lockedByName: names?.lockedByName ?? null,
voidedAt: row.voidedAt?.toISOString() ?? null,
voidedBy: row.voidedBy,
voidReason: row.voidReason,
metadata: row.metadata ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function enrichArtifact(row: typeof crmDocumentArtifacts.$inferSelect) {
const userIds = [row.generatedBy, row.lockedBy].filter(Boolean) as string[];
const userRows = userIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds))
: [];
const userMap = new Map(userRows.map((user) => [user.id, user.name]));
return mapDocumentArtifactRecord(row, {
generatedByName: userMap.get(row.generatedBy) ?? null,
lockedByName: row.lockedBy ? (userMap.get(row.lockedBy) ?? null) : null
});
}
export async function createArtifact(input: {
organizationId: string;
userId: string;
entityType: string;
entityId: string;
documentType: string;
artifactType: 'preview' | 'approved_pdf' | 'export';
templateVersionId?: string | null;
storageProvider: string;
storageKey: string;
fileName: string;
contentType: string;
fileSize?: number | null;
checksum?: string | null;
metadata?: unknown;
}) {
const [created] = await db
.insert(crmDocumentArtifacts)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
entityType: input.entityType,
entityId: input.entityId,
documentType: input.documentType,
artifactType: input.artifactType,
templateVersionId: input.templateVersionId ?? null,
storageProvider: input.storageProvider,
storageKey: input.storageKey,
fileName: input.fileName,
contentType: input.contentType,
fileSize: input.fileSize ?? null,
checksum: input.checksum ?? null,
status: 'active',
generatedBy: input.userId,
generatedAt: new Date(),
metadata: input.metadata ?? null
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: created.id,
action: 'create',
afterData: created
});
return enrichArtifact(created);
}
async function getArtifactRow(id: string, organizationId: string) {
const [artifact] = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.id, id),
eq(crmDocumentArtifacts.organizationId, organizationId),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.limit(1);
if (!artifact) {
throw new AuthError('Document artifact not found', 404);
}
return artifact;
}
export async function getArtifact(id: string, organizationId: string) {
return enrichArtifact(await getArtifactRow(id, organizationId));
}
export async function getActiveArtifactForEntity(input: {
organizationId: string;
entityType: string;
entityId: string;
artifactType: string;
}) {
const artifacts = await db
.select()
.from(crmDocumentArtifacts)
.where(
and(
eq(crmDocumentArtifacts.organizationId, input.organizationId),
eq(crmDocumentArtifacts.entityType, input.entityType),
eq(crmDocumentArtifacts.entityId, input.entityId),
eq(crmDocumentArtifacts.artifactType, input.artifactType),
isNull(crmDocumentArtifacts.deletedAt)
)
)
.orderBy(desc(crmDocumentArtifacts.createdAt));
const artifact = artifacts.find((row) => row.status === 'active' || row.status === 'locked');
if (!artifact || artifact.status === 'voided' || artifact.status === 'deleted') {
return null;
}
return enrichArtifact(artifact);
}
export async function lockArtifact(input: {
artifactId: string;
organizationId: string;
userId: string;
}) {
const row = await getArtifactRow(input.artifactId, input.organizationId);
const [updated] = await db
.update(crmDocumentArtifacts)
.set({
status: 'locked',
lockedAt: row.lockedAt ?? new Date(),
lockedBy: row.lockedBy ?? input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentArtifacts.id, input.artifactId))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: updated.id,
action: 'lock',
beforeData: row,
afterData: updated
});
return enrichArtifact(updated);
}
export async function voidArtifact(input: {
artifactId: string;
organizationId: string;
userId: string;
reason: string;
}) {
const row = await getArtifactRow(input.artifactId, input.organizationId);
const [updated] = await db
.update(crmDocumentArtifacts)
.set({
status: 'voided',
voidedAt: new Date(),
voidedBy: input.userId,
voidReason: input.reason.trim(),
updatedAt: new Date()
})
.where(eq(crmDocumentArtifacts.id, input.artifactId))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: updated.id,
action: 'void',
beforeData: row,
afterData: updated
});
return enrichArtifact(updated);
}
export async function deleteArtifactMetadataOnly(input: {
artifactId: string;
organizationId: string;
}) {
await db
.update(crmDocumentArtifacts)
.set({
status: 'deleted',
deletedAt: new Date(),
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentArtifacts.id, input.artifactId),
eq(crmDocumentArtifacts.organizationId, input.organizationId)
)
);
}
export async function downloadArtifact(input: {
artifactId: string;
organizationId: string;
userId?: string;
auditDownload?: boolean;
}) {
const artifact = await getArtifactRow(input.artifactId, input.organizationId);
if (artifact.status === 'voided' || artifact.status === 'deleted') {
throw new AuthError('Document artifact is not available for download', 409);
}
const object = await getStorageProvider().getObject({
key: artifact.storageKey
});
if (input.auditDownload && input.userId) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_artifact',
entityId: artifact.id,
action: 'download',
afterData: {
storageKey: artifact.storageKey,
fileName: artifact.fileName
}
});
}
return {
artifact: await enrichArtifact(artifact),
object: {
...object,
contentType: artifact.contentType,
fileName: artifact.fileName,
size: artifact.fileSize ?? object.size,
checksum: artifact.checksum
}
};
}

View File

@@ -1,16 +1,23 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { Font, Template } from '@pdfme/common';
import { generate } from '@pdfme/generator';
import { image, line, table, text } from '@pdfme/schemas';
import { and, eq, isNull } from 'drizzle-orm';
import {
crmQuotationAttachments,
crmQuotations,
organizations
} from '@/db/schema';
import { eq } from 'drizzle-orm';
import { crmQuotationAttachments, crmQuotations, organizations } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
createArtifact,
downloadArtifact,
getActiveArtifactForEntity,
lockArtifact
} from '@/features/foundation/document-artifact/server/service';
import {
buildOrganizationStorageKey,
getStorageProvider
} from '@/features/foundation/storage/service';
import { sha256 } from '@/features/foundation/storage/server/checksum';
import {
getQuotationDocumentPreviewData,
prepareApprovedQuotationSnapshot
@@ -27,6 +34,8 @@ type GeneratedQuotationPdf = {
fileName: string;
templateVersionId: string;
approvedPdfUrl?: string | null;
approvedArtifactId?: string | null;
isLegacy?: boolean;
};
const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']);
@@ -72,11 +81,7 @@ function normalizeTemplateFonts<T>(value: T): T {
if (value && typeof value === 'object') {
const nextObject = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => {
if (
key === 'fontName' &&
typeof child === 'string' &&
TEMPLATE_FONT_FALLBACKS.has(child)
) {
if (key === 'fontName' && typeof child === 'string' && TEMPLATE_FONT_FALLBACKS.has(child)) {
return [key, 'Roboto'];
}
@@ -128,18 +133,6 @@ function toSafeFileName(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, '-');
}
async function ensureGeneratedDirectory(organizationId: string) {
const directory = path.join(
process.cwd(),
'public',
'generated',
'quotations',
organizationId
);
await mkdir(directory, { recursive: true });
return directory;
}
async function persistApprovedQuotationArtifact(args: {
quotationId: string;
organizationId: string;
@@ -149,31 +142,87 @@ async function persistApprovedQuotationArtifact(args: {
templateVersionId: string;
}) {
const quotation = await getQuotationDetail(args.quotationId, args.organizationId);
if (quotation.approvedArtifactId && quotation.approvedArtifact?.status === 'locked') {
throw new AuthError(
'This quotation already has a locked approved PDF artifact and cannot be overwritten',
409
);
}
const existingArtifact = await getActiveArtifactForEntity({
organizationId: args.organizationId,
entityType: 'crm_quotation',
entityId: args.quotationId,
artifactType: 'approved_pdf'
});
if (existingArtifact?.status === 'locked') {
throw new AuthError(
'This quotation already has a locked approved PDF artifact and cannot be overwritten',
409
);
}
if (existingArtifact) {
throw new AuthError(
'An approved PDF artifact already exists for this quotation. Void it first before regenerating.',
409
);
}
const snapshot = await prepareApprovedQuotationSnapshot(
args.quotationId,
args.organizationId,
args.userId
);
const directory = await ensureGeneratedDirectory(args.organizationId);
const storageProvider = getStorageProvider();
const safeFileName = toSafeFileName(args.fileName);
const absolutePath = path.join(directory, safeFileName);
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
const checksum = sha256(args.buffer);
const storageKey = buildOrganizationStorageKey(args.organizationId, [
'quotations',
'approved',
`${args.quotationId}-${safeFileName}`
]);
try {
await writeFile(absolutePath, args.buffer);
} catch {
throw new AuthError('Unable to persist approved PDF to local storage', 500);
}
const fileStats = await stat(absolutePath).catch(() => {
throw new AuthError('Approved PDF was written but could not be verified on disk', 500);
const stored = await storageProvider.putObject({
key: storageKey,
body: args.buffer,
contentType: 'application/pdf',
fileName: safeFileName
});
const artifact = await createArtifact({
organizationId: args.organizationId,
userId: args.userId,
entityType: 'crm_quotation',
entityId: args.quotationId,
documentType: 'quotation_pdf',
artifactType: 'approved_pdf',
templateVersionId: args.templateVersionId,
storageProvider: stored.provider,
storageKey: stored.key,
fileName: safeFileName,
contentType: 'application/pdf',
fileSize: stored.size,
checksum,
metadata: {
quotationCode: quotation.code,
source: 'task-i-storage-provider'
}
});
const lockedArtifact = await lockArtifact({
artifactId: artifact.id,
organizationId: args.organizationId,
userId: args.userId
});
const artifactReference = `artifact:${lockedArtifact.id}`;
await db
.update(crmQuotations)
.set({
approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(),
approvedPdfUrl: publicPath,
approvedArtifactId: lockedArtifact.id,
approvedPdfUrl: artifactReference,
approvedSnapshot: snapshot,
approvedTemplateVersionId: args.templateVersionId,
updatedAt: new Date(),
@@ -181,18 +230,9 @@ async function persistApprovedQuotationArtifact(args: {
})
.where(eq(crmQuotations.id, args.quotationId));
const existingAttachment = await db
.select()
.from(crmQuotationAttachments)
.where(
and(
eq(crmQuotationAttachments.quotationId, args.quotationId),
eq(crmQuotationAttachments.organizationId, args.organizationId),
eq(crmQuotationAttachments.filePath, publicPath),
isNull(crmQuotationAttachments.deletedAt)
)
)
.then((rows) => rows[0] ?? null);
const existingAttachment = await db.query.crmQuotationAttachments.findFirst({
where: eq(crmQuotationAttachments.quotationId, args.quotationId)
});
if (existingAttachment) {
await db
@@ -200,10 +240,10 @@ async function persistApprovedQuotationArtifact(args: {
.set({
fileName: safeFileName,
originalFileName: safeFileName,
filePath: publicPath,
fileSize: fileStats.size,
filePath: artifactReference,
fileSize: stored.size,
fileType: 'application/pdf',
description: 'Approved quotation PDF',
description: `Approved quotation PDF artifact ${lockedArtifact.id}`,
uploadedAt: new Date(),
uploadedBy: args.userId,
updatedAt: new Date()
@@ -216,21 +256,46 @@ async function persistApprovedQuotationArtifact(args: {
quotationId: args.quotationId,
fileName: safeFileName,
originalFileName: safeFileName,
filePath: publicPath,
fileSize: fileStats.size,
filePath: artifactReference,
fileSize: stored.size,
fileType: 'application/pdf',
description: 'Approved quotation PDF',
description: `Approved quotation PDF artifact ${lockedArtifact.id}`,
uploadedBy: args.userId
});
}
return {
publicPath,
fileSize: fileStats.size,
artifactId: lockedArtifact.id,
artifactReference,
snapshot
};
}
async function loadLegacyApprovedQuotationPdf(
quotationId: string,
organizationId: string
): Promise<GeneratedQuotationPdf> {
const quotation = await getQuotationDetail(quotationId, organizationId);
if (!quotation.approvedPdfUrl || !quotation.approvedPdfUrl.startsWith('/generated/')) {
throw new AuthError('Approved PDF has not been generated yet', 404);
}
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath).catch(() => {
throw new AuthError('Approved PDF file is missing from legacy local storage', 404);
});
return {
buffer,
fileName: `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? '',
approvedPdfUrl: quotation.approvedPdfUrl,
isLegacy: true
};
}
export async function generateQuotationPdf(
quotationId: string,
organizationId: string
@@ -248,10 +313,7 @@ export async function generateQuotationPdf(
};
}
export async function generateQuotationPreviewPdf(
quotationId: string,
organizationId: string
) {
export async function generateQuotationPreviewPdf(quotationId: string, organizationId: string) {
return generateQuotationPdf(quotationId, organizationId);
}
@@ -284,31 +346,46 @@ export async function generateApprovedQuotationPdf(
buffer,
fileName,
templateVersionId: preview.template.version.id,
approvedPdfUrl: artifact.publicPath
approvedPdfUrl: artifact.artifactReference,
approvedArtifactId: artifact.artifactId
};
}
export async function getStoredApprovedQuotationPdf(
quotationId: string,
organizationId: string
organizationId: string,
options?: {
userId?: string;
auditDownload?: boolean;
}
): Promise<GeneratedQuotationPdf> {
const quotation = await getQuotationDetail(quotationId, organizationId);
const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
? quotation.approvedPdfUrl.replace('artifact:', '')
: null);
if (!quotation.approvedPdfUrl) {
throw new AuthError('Approved PDF has not been generated yet', 404);
if (approvedArtifactId) {
const { object } = await downloadArtifact({
artifactId: approvedArtifactId,
organizationId,
userId: options?.userId,
auditDownload: options?.auditDownload
});
const response = new Response(object.body);
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
fileName: object.fileName || `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? '',
approvedPdfUrl: quotation.approvedPdfUrl,
approvedArtifactId
};
}
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath).catch(() => {
throw new AuthError('Approved PDF file is missing from local storage', 404);
});
return {
buffer,
fileName: `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? ''
};
return loadLegacyApprovedQuotationPdf(quotationId, organizationId);
}
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
@@ -322,5 +399,5 @@ export async function getOrganizationGeneratedPdfDirectory(organizationId: strin
throw new AuthError('Organization not found', 404);
}
return ensureGeneratedDirectory(organizationId);
return buildOrganizationStorageKey(organizationId, ['quotations']);
}

View File

@@ -0,0 +1,5 @@
import { createHash } from 'node:crypto';
export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('hex');
}

View File

@@ -0,0 +1,77 @@
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import type {
DeleteObjectInput,
GetObjectInput,
ObjectExistsInput,
PutObjectInput,
StorageProvider
} from '../types';
function resolveLocalRoot() {
return path.resolve(process.cwd(), process.env.LOCAL_STORAGE_ROOT?.trim() || 'storage');
}
function resolveAbsolutePath(key: string) {
return path.resolve(resolveLocalRoot(), key);
}
function assertRelativeKey(key: string) {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.includes('..')) {
throw new Error('Storage key must be a safe relative path');
}
return normalized;
}
export function createLocalStorageProvider(): StorageProvider {
return {
async putObject(input: PutObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, input.body);
return {
provider: 'local',
key,
contentType: input.contentType,
fileName: input.fileName,
size: input.body.byteLength
};
},
async getObject(input: GetObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
const [buffer, fileStats] = await Promise.all([readFile(absolutePath), stat(absolutePath)]);
return {
provider: 'local',
key,
contentType: 'application/octet-stream',
size: fileStats.size,
body: Readable.toWeb(Readable.from(buffer)) as ReadableStream<Uint8Array>
};
},
async deleteObject(input: DeleteObjectInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
await rm(absolutePath, { force: true });
},
async objectExists(input: ObjectExistsInput) {
const key = assertRelativeKey(input.key);
const absolutePath = resolveAbsolutePath(key);
try {
await stat(absolutePath);
return true;
} catch {
return false;
}
}
};
}

View File

@@ -0,0 +1,24 @@
import type { StorageProvider } from '../types';
import { createLocalStorageProvider } from './local-provider';
import { createS3StorageProvider } from './s3-provider';
let cachedProvider: StorageProvider | null = null;
export function resolveStorageProvider(): StorageProvider {
if (cachedProvider) {
return cachedProvider;
}
const provider = process.env.STORAGE_PROVIDER?.trim() || 'local';
switch (provider) {
case 'local':
cachedProvider = createLocalStorageProvider();
return cachedProvider;
case 's3':
cachedProvider = createS3StorageProvider();
return cachedProvider;
default:
throw new Error(`Unsupported storage provider: ${provider}`);
}
}

View File

@@ -0,0 +1,126 @@
import { Readable } from 'node:stream';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client
} from '@aws-sdk/client-s3';
import { getSignedUrl as signS3Url } from '@aws-sdk/s3-request-presigner';
import type {
DeleteObjectInput,
GetObjectInput,
GetSignedUrlInput,
ObjectExistsInput,
PutObjectInput,
StorageProvider
} from '../types';
function requireS3Env(name: string) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required S3 storage config: ${name}`);
}
return value;
}
function getS3Client() {
return new S3Client({
region: requireS3Env('S3_REGION'),
endpoint: requireS3Env('S3_ENDPOINT'),
credentials: {
accessKeyId: requireS3Env('S3_ACCESS_KEY_ID'),
secretAccessKey: requireS3Env('S3_SECRET_ACCESS_KEY')
},
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false'
});
}
function getBucket() {
return requireS3Env('S3_BUCKET');
}
export function createS3StorageProvider(): StorageProvider {
const client = getS3Client();
const bucket = getBucket();
return {
async putObject(input: PutObjectInput) {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
Body: input.body,
ContentType: input.contentType
})
);
return {
provider: 's3',
key: input.key,
contentType: input.contentType,
fileName: input.fileName,
size: input.body.byteLength
};
},
async getObject(input: GetObjectInput) {
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: input.key
})
);
if (!response.Body) {
throw new Error('S3 object body is empty');
}
const nodeStream = response.Body.transformToWebStream
? response.Body.transformToWebStream()
: (Readable.toWeb(response.Body as Readable) as ReadableStream<Uint8Array>);
return {
provider: 's3',
key: input.key,
contentType: response.ContentType || 'application/octet-stream',
size: Number(response.ContentLength || 0),
body: nodeStream
};
},
async deleteObject(input: DeleteObjectInput) {
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: input.key
})
);
},
async objectExists(input: ObjectExistsInput) {
try {
await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: input.key
})
);
return true;
} catch {
return false;
}
},
async getSignedUrl(input: GetSignedUrlInput) {
return signS3Url(
client,
new GetObjectCommand({
Bucket: bucket,
Key: input.key
}),
{
expiresIn: input.expiresInSeconds ?? 300
}
);
}
};
}

View File

@@ -0,0 +1,20 @@
import 'server-only';
import type { StorageProvider } from './types';
import { resolveStorageProvider } from './server/provider';
export function getStorageProvider(): StorageProvider {
return resolveStorageProvider();
}
export function buildOrganizationStorageKey(organizationId: string, segments: string[]) {
const safeSegments = segments
.map((segment) =>
segment
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
)
.filter(Boolean);
return ['organizations', organizationId, ...safeSegments].join('/');
}

View File

@@ -0,0 +1,50 @@
export interface PutObjectInput {
key: string;
body: Buffer;
contentType: string;
fileName?: string;
}
export interface GetObjectInput {
key: string;
}
export interface DeleteObjectInput {
key: string;
}
export interface ObjectExistsInput {
key: string;
}
export interface GetSignedUrlInput {
key: string;
expiresInSeconds?: number;
}
export interface StoredObject {
provider: string;
key: string;
contentType: string;
fileName?: string;
size: number;
checksum?: string | null;
}
export interface StoredObjectStream {
provider: string;
key: string;
contentType: string;
fileName?: string;
size: number;
checksum?: string | null;
body: ReadableStream<Uint8Array>;
}
export interface StorageProvider {
putObject(input: PutObjectInput): Promise<StoredObject>;
getObject(input: GetObjectInput): Promise<StoredObjectStream>;
deleteObject(input: DeleteObjectInput): Promise<void>;
objectExists(input: ObjectExistsInput): Promise<boolean>;
getSignedUrl?(input: GetSignedUrlInput): Promise<string>;
}