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

@@ -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
}
};
}