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 type { PgColumn } from 'drizzle-orm/pg-core';
import { db } from '@/lib/db';
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import { NotFoundError, ConflictError } from '@/lib/errors';
@@ -19,14 +19,6 @@ import type {
export type { ListExpensesInput };
// AuditMeta type expected by service functions
export interface ServiceAuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listExpenses(portId: string, query: ListExpensesInput) {
const filters = [];
@@ -79,11 +71,7 @@ export async function getExpenseById(id: string, portId: string) {
return expense;
}
export async function createExpense(
portId: string,
data: CreateExpenseInput,
meta: ServiceAuditMeta,
) {
export async function createExpense(portId: string, data: CreateExpenseInput, meta: AuditMeta) {
let amountUsd: string | null = null;
let exchangeRate: string | null = null;
@@ -163,7 +151,7 @@ export async function updateExpense(
id: string,
portId: string,
data: UpdateExpenseInput,
meta: ServiceAuditMeta,
meta: AuditMeta,
) {
const existing = await getExpenseById(id, portId);
@@ -226,7 +214,7 @@ export async function updateExpense(
return updated;
}
export async function archiveExpense(id: string, portId: string, meta: ServiceAuditMeta) {
export async function archiveExpense(id: string, portId: string, meta: AuditMeta) {
const existing = await getExpenseById(id, portId);
// BR-045: Check if linked to non-draft invoice
@@ -257,7 +245,7 @@ export async function archiveExpense(id: string, portId: string, meta: ServiceAu
emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id });
}
export async function restoreExpense(id: string, portId: string, meta: ServiceAuditMeta) {
export async function restoreExpense(id: string, portId: string, meta: AuditMeta) {
await getExpenseById(id, portId);
await restore(expenses, expenses.id, id);
@@ -287,7 +275,7 @@ export async function addReceiptFiles(
id: string,
portId: string,
fileIds: string[],
meta: ServiceAuditMeta,
meta: AuditMeta,
) {
await getExpenseById(id, portId);