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:
@@ -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;
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
110
src/lib/services/entity-tags.helper.ts
Normal file
110
src/lib/services/entity-tags.helper.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user