refactor(services): centralize AuditMeta + transactional setEntityTags helper

The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.

Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).

Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.

The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 01:58:42 +02:00
parent 43f68ca093
commit 5d29bfc153
29 changed files with 234 additions and 409 deletions

View File

@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { files, documents } from '@/lib/db/schema/documents';
import { expenses } from '@/lib/db/schema/financial';
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { minioClient, getPresignedUrl } from '@/lib/minio';
@@ -20,13 +20,6 @@ import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/val
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
interface UploadFileParams {
buffer: Buffer;
originalName: string;
@@ -57,13 +50,9 @@ export async function uploadFile(
const sanitizedOriginal = sanitizeFilename(file.originalName);
const sanitizedFilename = sanitizeFilename(data.filename);
await minioClient.putObject(
env.MINIO_BUCKET,
storagePath,
file.buffer,
file.size,
{ 'Content-Type': file.mimeType },
);
await minioClient.putObject(env.MINIO_BUCKET, storagePath, file.buffer, file.size, {
'Content-Type': file.mimeType,
});
const [record] = await db
.insert(files)
@@ -176,12 +165,7 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
db
.select({ id: expenses.id })
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
arrayContains(expenses.receiptFileIds, [id]),
),
)
.where(and(eq(expenses.portId, portId), arrayContains(expenses.receiptFileIds, [id])))
.limit(1),
db
.select({ id: berthMaintenanceLog.id })
@@ -196,9 +180,7 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
]);
if (docRefs.length > 0 || expenseRefs.length > 0 || maintenanceRefs.length > 0) {
throw new ConflictError(
'File cannot be deleted because it is referenced by other records',
);
throw new ConflictError('File cannot be deleted because it is referenced by other records');
}
// Delete from MinIO first, then DB
@@ -235,9 +217,7 @@ export async function listFiles(portId: string, query: ListFilesInput) {
}
const sortColumn =
sort === 'filename' ? files.filename :
sort === 'sizeBytes' ? files.sizeBytes :
files.createdAt;
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
return buildListQuery({
table: files,