diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 9ccf3fc..df45ead 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -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; diff --git a/src/lib/services/berth-reservations.service.ts b/src/lib/services/berth-reservations.service.ts index 1516ed0..6b26683 100644 --- a/src/lib/services/berth-reservations.service.ts +++ b/src/lib/services/berth-reservations.service.ts @@ -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; export type { BerthReservation }; -interface AuditMeta { - userId: string; - portId: string; - ipAddress: string; - userAgent: string; -} - // ─── Helpers ───────────────────────────────────────────────────────────────── /** diff --git a/src/lib/services/berth-rules-engine.ts b/src/lib/services/berth-rules-engine.ts index d9fa1ae..69fd972 100644 --- a/src/lib/services/berth-rules-engine.ts +++ b/src/lib/services/berth-rules-engine.ts @@ -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 = { @@ -52,14 +45,9 @@ const DEFAULT_RULES: Record = { // ─── Config ─────────────────────────────────────────────────────────────────── -async function getRulesConfig( - portId: string, -): Promise> { +async function getRulesConfig(portId: string): Promise> { 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) { diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index d21e01d..9015d50 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index a40c02d..fb9b57f 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -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 ──────────────────────────────────────────────────────────── diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts index dbd1ea6..2c74d5a 100644 --- a/src/lib/services/companies.service.ts +++ b/src/lib/services/companies.service.ts @@ -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; -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 ──────────────────────────────────────────────────────────────── diff --git a/src/lib/services/company-memberships.service.ts b/src/lib/services/company-memberships.service.ts index dc9a3e8..dda798f 100644 --- a/src/lib/services/company-memberships.service.ts +++ b/src/lib/services/company-memberships.service.ts @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/src/lib/services/crm-invite.service.ts b/src/lib/services/crm-invite.service.ts index 86203d4..c13bc20 100644 --- a/src/lib/services/crm-invite.service.ts +++ b/src/lib/services/crm-invite.service.ts @@ -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; diff --git a/src/lib/services/custom-fields.service.ts b/src/lib/services/custom-fields.service.ts index a9d1d6b..605c31d 100644 --- a/src/lib/services/custom-fields.service.ts +++ b/src/lib/services/custom-fields.service.ts @@ -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, diff --git a/src/lib/services/document-templates.service.ts b/src/lib/services/document-templates.service.ts index edeb883..658c9f5 100644 --- a/src/lib/services/document-templates.service.ts +++ b/src/lib/services/document-templates.service.ts @@ -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. */ diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 3b18a9a..bd6087f 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -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 { diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index f58ca22..c0780d0 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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'; diff --git a/src/lib/services/email-accounts.service.ts b/src/lib/services/email-accounts.service.ts index d1cf2f6..349d880 100644 --- a/src/lib/services/email-accounts.service.ts +++ b/src/lib/services/email-accounts.service.ts @@ -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; // ─── 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; diff --git a/src/lib/services/email-compose.service.ts b/src/lib/services/email-compose.service.ts index 606a435..c163c06 100644 --- a/src/lib/services/email-compose.service.ts +++ b/src/lib/services/email-compose.service.ts @@ -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( diff --git a/src/lib/services/entity-tags.helper.ts b/src/lib/services/entity-tags.helper.ts new file mode 100644 index 0000000..acf5279 --- /dev/null +++ b/src/lib/services/entity-tags.helper.ts @@ -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 { + /** 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 (`:updated`). + */ + entityType: 'client' | 'company' | 'yacht' | 'interest' | 'berth'; +} + +export async function setEntityTags( + args: SetEntityTagsArgs, +): 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 }; +} diff --git a/src/lib/services/expenses.ts b/src/lib/services/expenses.ts index 95c92dd..20b82d3 100644 --- a/src/lib/services/expenses.ts +++ b/src/lib/services/expenses.ts @@ -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); diff --git a/src/lib/services/files.ts b/src/lib/services/files.ts index 0933759..a5fc551 100644 --- a/src/lib/services/files.ts +++ b/src/lib/services/files.ts @@ -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, diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 4c855ff..11bc926 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/src/lib/services/invoices.ts b/src/lib/services/invoices.ts index ddca0ab..309c61a 100644 --- a/src/lib/services/invoices.ts +++ b/src/lib/services/invoices.ts @@ -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 { @@ -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); diff --git a/src/lib/services/ports.service.ts b/src/lib/services/ports.service.ts index a06ed0c..843fde4 100644 --- a/src/lib/services/ports.service.ts +++ b/src/lib/services/ports.service.ts @@ -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); } diff --git a/src/lib/services/recommendations.ts b/src/lib/services/recommendations.ts index 2f398e7..f6bb94e 100644 --- a/src/lib/services/recommendations.ts +++ b/src/lib/services/recommendations.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/src/lib/services/reminders.service.ts b/src/lib/services/reminders.service.ts index fb9563a..e40cecf 100644 --- a/src/lib/services/reminders.service.ts +++ b/src/lib/services/reminders.service.ts @@ -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) { diff --git a/src/lib/services/residential.service.ts b/src/lib/services/residential.service.ts index a5c9056..fb8e9b8 100644 --- a/src/lib/services/residential.service.ts +++ b/src/lib/services/residential.service.ts @@ -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) { diff --git a/src/lib/services/roles.service.ts b/src/lib/services/roles.service.ts index 32fe3d7..69a224b 100644 --- a/src/lib/services/roles.service.ts +++ b/src/lib/services/roles.service.ts @@ -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); } diff --git a/src/lib/services/settings.service.ts b/src/lib/services/settings.service.ts index 704cd59..bfbd60b 100644 --- a/src/lib/services/settings.service.ts +++ b/src/lib/services/settings.service.ts @@ -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 diff --git a/src/lib/services/tags.service.ts b/src/lib/services/tags.service.ts index 4b0755e..7bd3ecd 100644 --- a/src/lib/services/tags.service.ts +++ b/src/lib/services/tags.service.ts @@ -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)), }); diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts index f73367f..c3c4f31 100644 --- a/src/lib/services/users.service.ts +++ b/src/lib/services/users.service.ts @@ -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({ diff --git a/src/lib/services/webhooks.service.ts b/src/lib/services/webhooks.service.ts index 0914cbd..5b77ba0 100644 --- a/src/lib/services/webhooks.service.ts +++ b/src/lib/services/webhooks.service.ts @@ -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), }); diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index df84f77..d0bf80b 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -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; -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'] }); }