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

@@ -18,6 +18,19 @@ export type AuditAction =
| 'request_gdpr_export'
| 'send_gdpr_export';
/**
* Common shape passed to service functions so they can stamp audit logs and
* propagate request context. Every authenticated route resolves these from
* the session + headers; services accept them rather than reaching into
* Next.js APIs themselves.
*/
export interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export interface AuditLogParams {
/** Null for system-generated events. */
userId: string | null;

View File

@@ -6,7 +6,7 @@ import { clients } from '@/lib/db/schema/clients';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { buildListQuery } from '@/lib/db/query-builder';
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 type { z } from 'zod';
@@ -22,13 +22,6 @@ type CreatePendingInput = z.input<typeof createPendingSchema>;
export type { BerthReservation };
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**

View File

@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { systemSettings } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { emitToRoom } from '@/lib/socket/server';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -31,13 +31,6 @@ interface RuleConfig {
targetStatus: string;
}
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
@@ -52,14 +45,9 @@ const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
// ─── Config ───────────────────────────────────────────────────────────────────
async function getRulesConfig(
portId: string,
): Promise<Record<BerthRuleTrigger, RuleConfig>> {
async function getRulesConfig(portId: string): Promise<Record<BerthRuleTrigger, RuleConfig>> {
const setting = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'berth_rules'),
eq(systemSettings.portId, portId),
),
where: and(eq(systemSettings.key, 'berth_rules'), eq(systemSettings.portId, portId)),
});
if (!setting?.value) {

View File

@@ -3,11 +3,12 @@ import { and, eq, gte, lte, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { ConflictError } from '@/lib/errors';
import type {
CreateBerthInput,
@@ -18,13 +19,6 @@ import type {
UpdateWaitingListInput,
} from '@/lib/validators/berths';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listBerths(portId: string, query: ListBerthsQuery) {
@@ -300,30 +294,18 @@ export async function setBerthTags(id: string, portId: string, tagIds: string[],
});
if (!existing) throw new NotFoundError('Berth');
// Delete existing tags then insert new ones
await db.delete(berthTags).where(eq(berthTags.berthId, id));
if (tagIds.length > 0) {
await db.insert(berthTags).values(tagIds.map((tagId) => ({ berthId: id, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
const result = await setEntityTags({
joinTable: berthTags,
entityColumn: berthTags.berthId,
tagColumn: berthTags.tagId,
entityId: id,
metadata: { type: 'tags_updated', tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
portId,
tagIds,
meta,
entityType: 'berth',
});
emitToRoom(`port:${portId}`, 'berth:updated', {
berthId: id,
changedFields: ['tags'],
});
return { berthId: id, tagIds };
return { berthId: result.entityId, tagIds: result.tagIds };
}
// ─── Add Maintenance Log ──────────────────────────────────────────────────────

View File

@@ -12,9 +12,10 @@ import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
@@ -27,13 +28,6 @@ import type {
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listClients(portId: string, query: ListClientsInput) {
@@ -637,24 +631,16 @@ export async function setClientTags(
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
await db.delete(clientTags).where(eq(clientTags.clientId, clientId));
if (tagIds.length > 0) {
await db.insert(clientTags).values(tagIds.map((tagId) => ({ clientId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'client',
await setEntityTags({
joinTable: clientTags,
entityColumn: clientTags.clientId,
tagColumn: clientTags.tagId,
entityId: clientId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
portId,
tagIds,
meta,
entityType: 'client',
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['tags'] });
}
// ─── Relationships ────────────────────────────────────────────────────────────

View File

@@ -10,9 +10,10 @@ import type { Company } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { withTransaction } from '@/lib/db/utils';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { diffEntity } from '@/lib/entity-diff';
import type { z } from 'zod';
import type {
@@ -23,13 +24,6 @@ import type {
type CreateCompanyInput = z.input<typeof createCompanySchema>;
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export type { Company };
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -364,24 +358,16 @@ export async function setCompanyTags(
const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) });
if (!company || company.portId !== portId) throw new NotFoundError('Company');
await db.delete(companyTags).where(eq(companyTags.companyId, companyId));
if (tagIds.length > 0) {
await db.insert(companyTags).values(tagIds.map((tagId) => ({ companyId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'company',
await setEntityTags({
joinTable: companyTags,
entityColumn: companyTags.companyId,
tagColumn: companyTags.tagId,
entityId: companyId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
portId,
tagIds,
meta,
entityType: 'company',
});
emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] });
}
// ─── Addresses ────────────────────────────────────────────────────────────────

View File

@@ -4,7 +4,7 @@ import { companies, companyMemberships } from '@/lib/db/schema/companies';
import type { CompanyMembership } from '@/lib/db/schema/companies';
import { clients } from '@/lib/db/schema/clients';
import { withTransaction } from '@/lib/db/utils';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { diffEntity } from '@/lib/entity-diff';
@@ -14,13 +14,6 @@ import type {
EndMembershipInput,
} from '@/lib/validators/company-memberships';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export type { CompanyMembership };
// ─── Helpers ─────────────────────────────────────────────────────────────────

View File

@@ -2,7 +2,7 @@ import { and, desc, eq, gt, isNull } from 'drizzle-orm';
import postgres from 'postgres';
import { auth } from '@/lib/auth';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { db } from '@/lib/db';
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
import { userProfiles } from '@/lib/db/schema/users';
@@ -12,13 +12,6 @@ import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
const INVITE_TTL_HOURS = 72;
const MIN_PASSWORD_LENGTH = 9;

View File

@@ -2,20 +2,13 @@ import { and, eq, count } from 'drizzle-orm';
import { db } from '@/lib/db';
import { customFieldDefinitions, customFieldValues } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
import type { CreateFieldInput, UpdateFieldInput } from '@/lib/validators/custom-fields';
import type { CustomFieldDefinition } from '@/lib/db/schema/system';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Value Validation ─────────────────────────────────────────────────────────
function validateCustomFieldValue(
@@ -35,16 +28,12 @@ function validateCustomFieldValue(
case 'number':
return typeof value !== 'number' || isNaN(value) ? 'Must be a number' : null;
case 'date':
return typeof value !== 'string' || isNaN(Date.parse(value))
? 'Must be a valid date'
: null;
return typeof value !== 'string' || isNaN(Date.parse(value)) ? 'Must be a valid date' : null;
case 'boolean':
return typeof value !== 'boolean' ? 'Must be true or false' : null;
case 'select': {
const options = (definition.selectOptions as string[] | null) ?? [];
return !options.includes(value as string)
? `Must be one of: ${options.join(', ')}`
: null;
return !options.includes(value as string) ? `Must be one of: ${options.join(', ')}` : null;
}
default:
return 'Unknown field type';
@@ -134,10 +123,7 @@ export async function updateDefinition(
}
const existing = await db.query.customFieldDefinitions.findFirst({
where: and(
eq(customFieldDefinitions.id, fieldId),
eq(customFieldDefinitions.portId, portId),
),
where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)),
});
if (!existing) {
throw new NotFoundError('Custom field definition');
@@ -189,10 +175,7 @@ export async function deleteDefinition(
meta: AuditMeta,
) {
const existing = await db.query.customFieldDefinitions.findFirst({
where: and(
eq(customFieldDefinitions.id, fieldId),
eq(customFieldDefinitions.portId, portId),
),
where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)),
});
if (!existing) {
throw new NotFoundError('Custom field definition');
@@ -206,9 +189,7 @@ export async function deleteDefinition(
const valueCount = countResult[0]?.count ?? 0;
// Delete definition — CASCADE handles values
await db
.delete(customFieldDefinitions)
.where(eq(customFieldDefinitions.id, fieldId));
await db.delete(customFieldDefinitions).where(eq(customFieldDefinitions.id, fieldId));
void createAuditLog({
userId,

View File

@@ -15,7 +15,7 @@ import { and, eq, desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema/documents';
import { auditLogs } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { validateTipTapDocument } from '@/lib/pdf/tiptap-to-pdfme';
import type {
@@ -26,13 +26,6 @@ import type {
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
/**
* A version entry reconstructed from audit_log records.
*/

View File

@@ -9,7 +9,7 @@ import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
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 { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
@@ -38,13 +38,6 @@ import type {
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Merge Field Definitions ──────────────────────────────────────────────────
export function getMergeFields(): MergeFieldCatalog {

View File

@@ -12,7 +12,7 @@ import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
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 { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
@@ -34,13 +34,6 @@ import type {
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ─────────────────────────────────────────────────────────────────────
import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents';

View File

@@ -3,26 +3,17 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { emailAccounts } from '@/lib/db/schema/email';
import { encrypt, decrypt } from '@/lib/utils/encryption';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ForbiddenError } from '@/lib/errors';
import type { ConnectAccountInput, ToggleAccountInput } from '@/lib/validators/email';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
type AccountWithoutCredentials = Omit<typeof emailAccounts.$inferSelect, 'credentialsEnc'>;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function stripCredentials(
account: typeof emailAccounts.$inferSelect,
): AccountWithoutCredentials {
function stripCredentials(account: typeof emailAccounts.$inferSelect): AccountWithoutCredentials {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credentialsEnc: _, ...safe } = account;
return safe;

View File

@@ -4,7 +4,7 @@ import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ForbiddenError } from '@/lib/errors';
import { getDecryptedCredentials } from '@/lib/services/email-accounts.service';
import { getPortEmailConfig } from '@/lib/services/port-config';
@@ -13,13 +13,6 @@ import type { ComposeEmailInput } from '@/lib/validators/email';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function assertAttachmentsForPort(

View File

@@ -0,0 +1,110 @@
/**
* Wipe-and-rewrite tag-assignment helper, shared by every entity that has
* a many-to-many tag join table (`client_tags`, `yacht_tags`, …).
*
* The recipe is the same for every entity:
* 1. wipe the join rows for this entity
* 2. insert the new set
* 3. write an audit log
* 4. emit a socket event
*
* This helper performs the wipe+insert inside a single transaction so a
* partial failure can't leave the entity with zero tags, and standardizes
* the audit-log payload to `newValue: { tagIds }` across all entity types.
*/
import { eq, type Column } from 'drizzle-orm';
import type { PgTable } from 'drizzle-orm/pg-core';
import { withTransaction } from '@/lib/db/utils';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { emitToRoom } from '@/lib/socket/server';
interface SetEntityTagsArgs<TJoin extends PgTable> {
/** Join table — e.g. `clientTags`, `yachtTags`. */
joinTable: TJoin;
/**
* Column on the join table that points back at the parent entity (e.g.
* `clientTags.clientId`). Used both for the WHERE on delete and as the
* insert payload key.
*/
entityColumn: Column;
/** Column on the join table that holds the tag ID (e.g. `clientTags.tagId`). */
tagColumn: Column;
/** Owning entity ID. */
entityId: string;
/** Tenant scope. */
portId: string;
/** Final desired set of tag IDs (replaces current set). */
tagIds: string[];
/** Audit log + socket fields. */
meta: AuditMeta;
/**
* Audit/socket entity discriminator. Drives both `entityType` on the
* audit row and the socket event name (`<entityType>:updated`).
*/
entityType: 'client' | 'company' | 'yacht' | 'interest' | 'berth';
}
export async function setEntityTags<TJoin extends PgTable>(
args: SetEntityTagsArgs<TJoin>,
): Promise<{ entityId: string; tagIds: string[] }> {
const { joinTable, entityColumn, tagColumn, entityId, portId, tagIds, meta, entityType } = args;
await withTransaction(async (tx) => {
await tx.delete(joinTable).where(eq(entityColumn, entityId));
if (tagIds.length > 0) {
await tx.insert(joinTable).values(
tagIds.map(
(tagId) =>
({
[entityColumn.name]: entityId,
[tagColumn.name]: tagId,
}) as TJoin['$inferInsert'],
),
);
}
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType,
entityId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
// Per-event payloads use entity-specific keys (e.g. `clientId` vs `yachtId`),
// so dispatch through a switch rather than a computed property to keep the
// ServerToClientEvents typing happy.
const room = `port:${portId}` as const;
const changedFields = ['tags'] as const;
switch (entityType) {
case 'client':
emitToRoom(room, 'client:updated', { clientId: entityId, changedFields: [...changedFields] });
break;
case 'company':
emitToRoom(room, 'company:updated', {
companyId: entityId,
changedFields: [...changedFields],
});
break;
case 'yacht':
emitToRoom(room, 'yacht:updated', { yachtId: entityId, changedFields: [...changedFields] });
break;
case 'interest':
emitToRoom(room, 'interest:updated', {
interestId: entityId,
changedFields: [...changedFields],
});
break;
case 'berth':
emitToRoom(room, 'berth:updated', { berthId: entityId, changedFields: [...changedFields] });
break;
}
return { entityId, tagIds };
}

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);

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,

View File

@@ -7,9 +7,10 @@ import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
@@ -22,13 +23,6 @@ import type {
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Yacht ownership validator ───────────────────────────────────────────────
async function assertYachtBelongsToClient(
@@ -552,26 +546,18 @@ export async function setInterestTags(
throw new NotFoundError('Interest');
}
await db.delete(interestTags).where(eq(interestTags.interestId, id));
if (tagIds.length > 0) {
await db.insert(interestTags).values(tagIds.map((tagId) => ({ interestId: id, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
const result = await setEntityTags({
joinTable: interestTags,
entityColumn: interestTags.interestId,
tagColumn: interestTags.tagId,
entityId: id,
metadata: { type: 'tags_updated', tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
portId,
tagIds,
meta,
entityType: 'interest',
});
emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: ['tags'] });
return { interestId: id, tagIds };
return { interestId: result.entityId, tagIds: result.tagIds };
}
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────

View File

@@ -9,7 +9,7 @@ import { systemSettings } from '@/lib/db/schema/system';
import { clients, clientAddresses } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
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 { withTransaction } from '@/lib/db/utils';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
@@ -29,14 +29,6 @@ import type {
ListInvoicesInput,
} from '@/lib/validators/invoices';
// AuditMeta type expected by service functions
export interface ServiceAuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Auto-numbering (BR-041) ───────────────────────────────────────────────
async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<string> {
@@ -218,11 +210,7 @@ export async function getInvoiceById(id: string, portId: string) {
// ─── Create (BR-041, BR-042, BR-045) ─────────────────────────────────────
export async function createInvoice(
portId: string,
data: CreateInvoiceInput,
meta: ServiceAuditMeta,
) {
export async function createInvoice(portId: string, data: CreateInvoiceInput, meta: AuditMeta) {
const invoice = await withTransaction(async (tx) => {
// Resolve the polymorphic billing entity (client | company). Throws
// ValidationError if the entity is missing or belongs to another tenant.
@@ -361,7 +349,7 @@ export async function updateInvoice(
id: string,
portId: string,
data: UpdateInvoiceInput,
meta: ServiceAuditMeta,
meta: AuditMeta,
) {
const existing = await getInvoiceById(id, portId);
if (existing.status !== 'draft') {
@@ -496,7 +484,7 @@ export async function updateInvoice(
// ─── Delete (draft only) ──────────────────────────────────────────────────
export async function deleteInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
export async function deleteInvoice(id: string, portId: string, meta: AuditMeta) {
const existing = await getInvoiceById(id, portId);
if (existing.status !== 'draft') {
throw new ConflictError('Only draft invoices can be deleted');
@@ -527,7 +515,7 @@ export async function deleteInvoice(id: string, portId: string, meta: ServiceAud
// ─── Generate PDF ─────────────────────────────────────────────────────────
export async function generateInvoicePdf(id: string, portId: string, meta: ServiceAuditMeta) {
export async function generateInvoicePdf(id: string, portId: string, meta: AuditMeta) {
const invoice = await getInvoiceById(id, portId);
const [port] = await db
@@ -589,7 +577,7 @@ export async function generateInvoicePdf(id: string, portId: string, meta: Servi
// ─── Send invoice ─────────────────────────────────────────────────────────
export async function sendInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
export async function sendInvoice(id: string, portId: string, meta: AuditMeta) {
const invoice = await getInvoiceById(id, portId);
// Generate PDF if not exists
@@ -637,7 +625,7 @@ export async function recordPayment(
id: string,
portId: string,
data: RecordPaymentInput,
meta: ServiceAuditMeta,
meta: AuditMeta,
) {
const existing = await getInvoiceById(id, portId);

View File

@@ -3,18 +3,11 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema';
import type { PortSettings } from '@/lib/db/schema/ports';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listPorts() {
return db.select().from(ports).orderBy(ports.name);
}

View File

@@ -5,14 +5,7 @@ import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
import { NotFoundError } from '@/lib/errors';
import { createAuditLog } from '@/lib/audit';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
import { createAuditLog, type AuditMeta } from '@/lib/audit';
// ─── Score a single berth ─────────────────────────────────────────────────────

View File

@@ -2,7 +2,7 @@ import { and, eq, lte, gte, desc, asc, inArray, sql, isNull } from 'drizzle-orm'
import { db } from '@/lib/db';
import { reminders, interests, clients } from '@/lib/db/schema';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { createNotification } from '@/lib/services/notifications.service';
@@ -14,13 +14,6 @@ import type {
ReminderListQuery,
} from '@/lib/validators/reminders';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ────────────────────────────────────────────────────────────────────
export async function listReminders(portId: string, query: ReminderListQuery) {

View File

@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
@@ -17,13 +17,6 @@ import type {
UpdateResidentialInterestInput,
} from '@/lib/validators/residential';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Residential clients ─────────────────────────────────────────────────────
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {

View File

@@ -3,18 +3,11 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { roles, userPortRoles } from '@/lib/db/schema';
import type { RolePermissions } from '@/lib/db/schema/users';
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 type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listRoles() {
return db.select().from(roles).orderBy(roles.name);
}

View File

@@ -2,17 +2,10 @@ import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listSettings(portId: string) {
// Get port-specific settings
const portSettings = await db

View File

@@ -2,31 +2,16 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { tags } from '@/lib/db/schema';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreateTagInput, UpdateTagInput } from '@/lib/validators/tags';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listTags(portId: string) {
return db
.select()
.from(tags)
.where(eq(tags.portId, portId))
.orderBy(tags.name);
return db.select().from(tags).where(eq(tags.portId, portId)).orderBy(tags.name);
}
export async function createTag(
portId: string,
data: CreateTagInput,
meta: AuditMeta,
) {
export async function createTag(portId: string, data: CreateTagInput, meta: AuditMeta) {
// Enforce unique (portId, name)
const existing = await db.query.tags.findFirst({
where: and(eq(tags.portId, portId), eq(tags.name, data.name)),
@@ -60,12 +45,7 @@ export async function createTag(
return tag!;
}
export async function updateTag(
id: string,
portId: string,
data: UpdateTagInput,
meta: AuditMeta,
) {
export async function updateTag(id: string, portId: string, data: UpdateTagInput, meta: AuditMeta) {
const tag = await db.query.tags.findFirst({
where: and(eq(tags.id, id), eq(tags.portId, portId)),
});
@@ -83,7 +63,10 @@ export async function updateTag(
const [updated] = await db
.update(tags)
.set({ ...(data.name ? { name: data.name } : {}), ...(data.color ? { color: data.color } : {}) })
.set({
...(data.name ? { name: data.name } : {}),
...(data.color ? { color: data.color } : {}),
})
.where(and(eq(tags.id, id), eq(tags.portId, portId)))
.returning();
@@ -108,11 +91,7 @@ export async function updateTag(
return updated!;
}
export async function deleteTag(
id: string,
portId: string,
meta: AuditMeta,
) {
export async function deleteTag(id: string, portId: string, meta: AuditMeta) {
const tag = await db.query.tags.findFirst({
where: and(eq(tags.id, id), eq(tags.portId, portId)),
});

View File

@@ -3,18 +3,11 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
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 type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listUsers(portId: string) {
const rows = await db
.select({

View File

@@ -3,7 +3,7 @@ import { and, desc, eq, count } from 'drizzle-orm';
import { db } from '@/lib/db';
import { webhooks, webhookDeliveries } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { encrypt, decrypt } from '@/lib/utils/encryption';
import { NotFoundError } from '@/lib/errors';
import { getQueue } from '@/lib/queue';
@@ -16,13 +16,6 @@ import type { WebhookEvent } from '@/lib/services/webhook-event-map';
// ─── Types ────────────────────────────────────────────────────────────────────
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Generates a 32-byte hex secret for signing webhook payloads. */
@@ -173,11 +166,7 @@ export async function updateWebhook(
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function deleteWebhook(
portId: string,
webhookId: string,
meta: AuditMeta,
) {
export async function deleteWebhook(portId: string, webhookId: string, meta: AuditMeta) {
const existing = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
});
@@ -187,9 +176,7 @@ export async function deleteWebhook(
}
// CASCADE deletes webhook_deliveries
await db
.delete(webhooks)
.where(and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)));
await db.delete(webhooks).where(and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)));
void createAuditLog({
userId: meta.userId,
@@ -205,11 +192,7 @@ export async function deleteWebhook(
// ─── Regenerate Secret ────────────────────────────────────────────────────────
export async function regenerateSecret(
portId: string,
webhookId: string,
meta: AuditMeta,
) {
export async function regenerateSecret(portId: string, webhookId: string, meta: AuditMeta) {
const existing = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
});
@@ -288,11 +271,7 @@ export async function listDeliveries(
// ─── Send Test Webhook ────────────────────────────────────────────────────────
export async function sendTestWebhook(
portId: string,
webhookId: string,
eventType: WebhookEvent,
) {
export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {
const webhook = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
});

View File

@@ -3,9 +3,10 @@ import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
import type { Yacht } from '@/lib/db/schema/yachts';
import { companies } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { diffEntity } from '@/lib/entity-diff';
import { buildListQuery } from '@/lib/db/query-builder';
import { withTransaction } from '@/lib/db/utils';
@@ -19,13 +20,6 @@ import type {
type CreateYachtInput = z.input<typeof createYachtSchema>;
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
async function assertOwnerExists(
portId: string,
owner: { type: 'client' | 'company'; id: string },
@@ -408,22 +402,14 @@ export async function setYachtTags(
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht');
await db.delete(yachtTags).where(eq(yachtTags.yachtId, yachtId));
if (tagIds.length > 0) {
await db.insert(yachtTags).values(tagIds.map((tagId) => ({ yachtId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'yacht',
await setEntityTags({
joinTable: yachtTags,
entityColumn: yachtTags.yachtId,
tagColumn: yachtTags.tagId,
entityId: yachtId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
portId,
tagIds,
meta,
entityType: 'yacht',
});
emitToRoom(`port:${portId}`, 'yacht:updated', { yachtId, changedFields: ['tags'] });
}