task-i
This commit is contained in:
36
src/app/api/crm/document-artifacts/[id]/download/route.ts
Normal file
36
src/app/api/crm/document-artifacts/[id]/download/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { downloadArtifact } from '@/features/foundation/document-artifact/server/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: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentArtifactDownload
|
||||
});
|
||||
const { object } = await downloadArtifact({
|
||||
artifactId: id,
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
auditDownload: true
|
||||
});
|
||||
|
||||
return new NextResponse(object.body, {
|
||||
headers: {
|
||||
'Content-Type': object.contentType,
|
||||
'Content-Disposition': `inline; filename="${object.fileName || 'artifact'}"`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to download document artifact' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,13 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationPdfDownload
|
||||
});
|
||||
const pdf = await getStoredApprovedQuotationPdf(id, organization.id);
|
||||
const pdf = await getStoredApprovedQuotationPdf(id, organization.id, {
|
||||
userId: session.user.id,
|
||||
auditDownload: true
|
||||
});
|
||||
|
||||
return new NextResponse(pdf.buffer, {
|
||||
headers: {
|
||||
@@ -52,6 +55,7 @@ export async function POST(_request: NextRequest, { params }: Params) {
|
||||
action: 'generate_approved',
|
||||
beforeData: before,
|
||||
afterData: {
|
||||
approvedArtifactId: after.approvedArtifactId,
|
||||
approvedPdfUrl: after.approvedPdfUrl,
|
||||
approvedTemplateVersionId: after.approvedTemplateVersionId
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ export const crmQuotations = pgTable(
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
sentVia: text('sent_via'),
|
||||
approvedAt: timestamp('approved_at', { withTimezone: true }),
|
||||
approvedArtifactId: text('approved_artifact_id'),
|
||||
approvedPdfUrl: text('approved_pdf_url'),
|
||||
approvedSnapshot: jsonb('approved_snapshot'),
|
||||
approvedTemplateVersionId: text('approved_template_version_id'),
|
||||
@@ -401,6 +402,34 @@ export const crmQuotationAttachments = pgTable('crm_quotation_attachments', {
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
export const crmDocumentArtifacts = pgTable('crm_document_artifacts', {
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
documentType: text('document_type').notNull(),
|
||||
artifactType: text('artifact_type').notNull(),
|
||||
templateVersionId: text('template_version_id'),
|
||||
storageProvider: text('storage_provider').notNull(),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
fileName: text('file_name').notNull(),
|
||||
contentType: text('content_type').notNull(),
|
||||
fileSize: integer('file_size'),
|
||||
checksum: text('checksum'),
|
||||
status: text('status').default('active').notNull(),
|
||||
generatedBy: text('generated_by').notNull(),
|
||||
generatedAt: timestamp('generated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lockedAt: timestamp('locked_at', { withTimezone: true }),
|
||||
lockedBy: text('locked_by'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidedBy: text('voided_by'),
|
||||
voidReason: text('void_reason'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
export const crmApprovalWorkflows = pgTable(
|
||||
'crm_approval_workflows',
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
313
src/features/foundation/document-artifact/server/service.ts
Normal file
313
src/features/foundation/document-artifact/server/service.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
5
src/features/foundation/storage/server/checksum.ts
Normal file
5
src/features/foundation/storage/server/checksum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function sha256(buffer: Buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
77
src/features/foundation/storage/server/local-provider.ts
Normal file
77
src/features/foundation/storage/server/local-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
24
src/features/foundation/storage/server/provider.ts
Normal file
24
src/features/foundation/storage/server/provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
126
src/features/foundation/storage/server/s3-provider.ts
Normal file
126
src/features/foundation/storage/server/s3-provider.ts
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
20
src/features/foundation/storage/service.ts
Normal file
20
src/features/foundation/storage/service.ts
Normal 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('/');
|
||||
}
|
||||
50
src/features/foundation/storage/types.ts
Normal file
50
src/features/foundation/storage/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export const PERMISSIONS = {
|
||||
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
||||
crmDocumentArtifactRead: 'crm.document_artifact.read',
|
||||
crmDocumentArtifactDownload: 'crm.document_artifact.download',
|
||||
crmDocumentArtifactVoid: 'crm.document_artifact.void',
|
||||
crmQuotationDocumentPreview: 'crm.quotation.document.preview',
|
||||
crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
|
||||
crmQuotationPdfDownload: 'crm.quotation.pdf.download',
|
||||
@@ -107,6 +110,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.crmDocumentTemplateCreate,
|
||||
PERMISSIONS.crmDocumentTemplateUpdate,
|
||||
PERMISSIONS.crmDocumentTemplateDelete,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmDocumentArtifactVoid,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload,
|
||||
@@ -123,6 +129,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
@@ -159,6 +167,9 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmDocumentArtifactVoid,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
@@ -185,6 +196,8 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
@@ -210,6 +223,8 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
|
||||
Reference in New Issue
Block a user