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'
|
| 'request_gdpr_export'
|
||||||
| 'send_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 {
|
export interface AuditLogParams {
|
||||||
/** Null for system-generated events. */
|
/** Null for system-generated events. */
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { clients } from '@/lib/db/schema/clients';
|
|||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -22,13 +22,6 @@ type CreatePendingInput = z.input<typeof createPendingSchema>;
|
|||||||
|
|
||||||
export type { BerthReservation };
|
export type { BerthReservation };
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
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';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -31,13 +31,6 @@ interface RuleConfig {
|
|||||||
targetStatus: string;
|
targetStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
|
const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
|
||||||
@@ -52,14 +45,9 @@ const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
|
|||||||
|
|
||||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getRulesConfig(
|
async function getRulesConfig(portId: string): Promise<Record<BerthRuleTrigger, RuleConfig>> {
|
||||||
portId: string,
|
|
||||||
): Promise<Record<BerthRuleTrigger, RuleConfig>> {
|
|
||||||
const setting = await db.query.systemSettings.findFirst({
|
const setting = await db.query.systemSettings.findFirst({
|
||||||
where: and(
|
where: and(eq(systemSettings.key, 'berth_rules'), eq(systemSettings.portId, portId)),
|
||||||
eq(systemSettings.key, 'berth_rules'),
|
|
||||||
eq(systemSettings.portId, portId),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!setting?.value) {
|
if (!setting?.value) {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||||
import { tags } from '@/lib/db/schema/system';
|
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 { diffEntity } from '@/lib/entity-diff';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { ConflictError } from '@/lib/errors';
|
import { ConflictError } from '@/lib/errors';
|
||||||
import type {
|
import type {
|
||||||
CreateBerthInput,
|
CreateBerthInput,
|
||||||
@@ -18,13 +19,6 @@ import type {
|
|||||||
UpdateWaitingListInput,
|
UpdateWaitingListInput,
|
||||||
} from '@/lib/validators/berths';
|
} from '@/lib/validators/berths';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listBerths(portId: string, query: ListBerthsQuery) {
|
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');
|
if (!existing) throw new NotFoundError('Berth');
|
||||||
|
|
||||||
// Delete existing tags then insert new ones
|
const result = await setEntityTags({
|
||||||
await db.delete(berthTags).where(eq(berthTags.berthId, id));
|
joinTable: berthTags,
|
||||||
|
entityColumn: berthTags.berthId,
|
||||||
if (tagIds.length > 0) {
|
tagColumn: berthTags.tagId,
|
||||||
await db.insert(berthTags).values(tagIds.map((tagId) => ({ berthId: id, tagId })));
|
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditLog({
|
|
||||||
userId: meta.userId,
|
|
||||||
portId,
|
|
||||||
action: 'update',
|
|
||||||
entityType: 'berth',
|
|
||||||
entityId: id,
|
entityId: id,
|
||||||
metadata: { type: 'tags_updated', tagIds },
|
portId,
|
||||||
ipAddress: meta.ipAddress,
|
tagIds,
|
||||||
userAgent: meta.userAgent,
|
meta,
|
||||||
|
entityType: 'berth',
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'berth:updated', {
|
return { berthId: result.entityId, tagIds: result.tagIds };
|
||||||
berthId: id,
|
|
||||||
changedFields: ['tags'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return { berthId: id, tagIds };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
import { tags } from '@/lib/db/schema/system';
|
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 { NotFoundError } from '@/lib/errors';
|
||||||
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
||||||
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -27,13 +28,6 @@ import type {
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listClients(portId: string, query: ListClientsInput) {
|
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');
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||||
|
|
||||||
await db.delete(clientTags).where(eq(clientTags.clientId, clientId));
|
await setEntityTags({
|
||||||
|
joinTable: clientTags,
|
||||||
if (tagIds.length > 0) {
|
entityColumn: clientTags.clientId,
|
||||||
await db.insert(clientTags).values(tagIds.map((tagId) => ({ clientId, tagId })));
|
tagColumn: clientTags.tagId,
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditLog({
|
|
||||||
userId: meta.userId,
|
|
||||||
portId,
|
|
||||||
action: 'update',
|
|
||||||
entityType: 'client',
|
|
||||||
entityId: clientId,
|
entityId: clientId,
|
||||||
newValue: { tagIds },
|
portId,
|
||||||
ipAddress: meta.ipAddress,
|
tagIds,
|
||||||
userAgent: meta.userAgent,
|
meta,
|
||||||
|
entityType: 'client',
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['tags'] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Relationships ────────────────────────────────────────────────────────────
|
// ─── Relationships ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import type { Company } from '@/lib/db/schema/companies';
|
|||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { withTransaction } from '@/lib/db/utils';
|
import { withTransaction } from '@/lib/db/utils';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { NotFoundError, ConflictError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type {
|
import type {
|
||||||
@@ -23,13 +24,6 @@ import type {
|
|||||||
|
|
||||||
type CreateCompanyInput = z.input<typeof createCompanySchema>;
|
type CreateCompanyInput = z.input<typeof createCompanySchema>;
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Company };
|
export type { Company };
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -364,24 +358,16 @@ export async function setCompanyTags(
|
|||||||
const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) });
|
const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) });
|
||||||
if (!company || company.portId !== portId) throw new NotFoundError('Company');
|
if (!company || company.portId !== portId) throw new NotFoundError('Company');
|
||||||
|
|
||||||
await db.delete(companyTags).where(eq(companyTags.companyId, companyId));
|
await setEntityTags({
|
||||||
|
joinTable: companyTags,
|
||||||
if (tagIds.length > 0) {
|
entityColumn: companyTags.companyId,
|
||||||
await db.insert(companyTags).values(tagIds.map((tagId) => ({ companyId, tagId })));
|
tagColumn: companyTags.tagId,
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditLog({
|
|
||||||
userId: meta.userId,
|
|
||||||
portId,
|
|
||||||
action: 'update',
|
|
||||||
entityType: 'company',
|
|
||||||
entityId: companyId,
|
entityId: companyId,
|
||||||
newValue: { tagIds },
|
portId,
|
||||||
ipAddress: meta.ipAddress,
|
tagIds,
|
||||||
userAgent: meta.userAgent,
|
meta,
|
||||||
|
entityType: 'company',
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Addresses ────────────────────────────────────────────────────────────────
|
// ─── Addresses ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|||||||
import type { CompanyMembership } from '@/lib/db/schema/companies';
|
import type { CompanyMembership } from '@/lib/db/schema/companies';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { withTransaction } from '@/lib/db/utils';
|
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 { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -14,13 +14,6 @@ import type {
|
|||||||
EndMembershipInput,
|
EndMembershipInput,
|
||||||
} from '@/lib/validators/company-memberships';
|
} from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { CompanyMembership };
|
export type { CompanyMembership };
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, desc, eq, gt, isNull } from 'drizzle-orm';
|
|||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
|
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
|
||||||
import { userProfiles } from '@/lib/db/schema/users';
|
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 { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { hashToken, mintToken } from '@/lib/portal/passwords';
|
import { hashToken, mintToken } from '@/lib/portal/passwords';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVITE_TTL_HOURS = 72;
|
const INVITE_TTL_HOURS = 72;
|
||||||
const MIN_PASSWORD_LENGTH = 9;
|
const MIN_PASSWORD_LENGTH = 9;
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,13 @@ import { and, eq, count } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { customFieldDefinitions, customFieldValues } from '@/lib/db/schema/system';
|
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 { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
|
||||||
import type { CreateFieldInput, UpdateFieldInput } from '@/lib/validators/custom-fields';
|
import type { CreateFieldInput, UpdateFieldInput } from '@/lib/validators/custom-fields';
|
||||||
import type { CustomFieldDefinition } from '@/lib/db/schema/system';
|
import type { CustomFieldDefinition } from '@/lib/db/schema/system';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Value Validation ─────────────────────────────────────────────────────────
|
// ─── Value Validation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function validateCustomFieldValue(
|
function validateCustomFieldValue(
|
||||||
@@ -35,16 +28,12 @@ function validateCustomFieldValue(
|
|||||||
case 'number':
|
case 'number':
|
||||||
return typeof value !== 'number' || isNaN(value) ? 'Must be a number' : null;
|
return typeof value !== 'number' || isNaN(value) ? 'Must be a number' : null;
|
||||||
case 'date':
|
case 'date':
|
||||||
return typeof value !== 'string' || isNaN(Date.parse(value))
|
return typeof value !== 'string' || isNaN(Date.parse(value)) ? 'Must be a valid date' : null;
|
||||||
? 'Must be a valid date'
|
|
||||||
: null;
|
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return typeof value !== 'boolean' ? 'Must be true or false' : null;
|
return typeof value !== 'boolean' ? 'Must be true or false' : null;
|
||||||
case 'select': {
|
case 'select': {
|
||||||
const options = (definition.selectOptions as string[] | null) ?? [];
|
const options = (definition.selectOptions as string[] | null) ?? [];
|
||||||
return !options.includes(value as string)
|
return !options.includes(value as string) ? `Must be one of: ${options.join(', ')}` : null;
|
||||||
? `Must be one of: ${options.join(', ')}`
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return 'Unknown field type';
|
return 'Unknown field type';
|
||||||
@@ -134,10 +123,7 @@ export async function updateDefinition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.customFieldDefinitions.findFirst({
|
const existing = await db.query.customFieldDefinitions.findFirst({
|
||||||
where: and(
|
where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)),
|
||||||
eq(customFieldDefinitions.id, fieldId),
|
|
||||||
eq(customFieldDefinitions.portId, portId),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundError('Custom field definition');
|
throw new NotFoundError('Custom field definition');
|
||||||
@@ -189,10 +175,7 @@ export async function deleteDefinition(
|
|||||||
meta: AuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
const existing = await db.query.customFieldDefinitions.findFirst({
|
const existing = await db.query.customFieldDefinitions.findFirst({
|
||||||
where: and(
|
where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)),
|
||||||
eq(customFieldDefinitions.id, fieldId),
|
|
||||||
eq(customFieldDefinitions.portId, portId),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundError('Custom field definition');
|
throw new NotFoundError('Custom field definition');
|
||||||
@@ -206,9 +189,7 @@ export async function deleteDefinition(
|
|||||||
const valueCount = countResult[0]?.count ?? 0;
|
const valueCount = countResult[0]?.count ?? 0;
|
||||||
|
|
||||||
// Delete definition — CASCADE handles values
|
// Delete definition — CASCADE handles values
|
||||||
await db
|
await db.delete(customFieldDefinitions).where(eq(customFieldDefinitions.id, fieldId));
|
||||||
.delete(customFieldDefinitions)
|
|
||||||
.where(eq(customFieldDefinitions.id, fieldId));
|
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { and, eq, desc } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { documentTemplates } from '@/lib/db/schema/documents';
|
import { documentTemplates } from '@/lib/db/schema/documents';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
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 { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { validateTipTapDocument } from '@/lib/pdf/tiptap-to-pdfme';
|
import { validateTipTapDocument } from '@/lib/pdf/tiptap-to-pdfme';
|
||||||
import type {
|
import type {
|
||||||
@@ -26,13 +26,6 @@ import type {
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A version entry reconstructed from audit_log records.
|
* 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 { ports } from '@/lib/db/schema/ports';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { diffEntity } from '@/lib/entity-diff';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
@@ -38,13 +38,6 @@ import type {
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getMergeFields(): MergeFieldCatalog {
|
export function getMergeFields(): MergeFieldCatalog {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { interests } from '@/lib/db/schema/interests';
|
|||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { diffEntity } from '@/lib/entity-diff';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
|
import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
@@ -34,13 +34,6 @@ import type {
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents';
|
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 { db } from '@/lib/db';
|
||||||
import { emailAccounts } from '@/lib/db/schema/email';
|
import { emailAccounts } from '@/lib/db/schema/email';
|
||||||
import { encrypt, decrypt } from '@/lib/utils/encryption';
|
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 { NotFoundError, ForbiddenError } from '@/lib/errors';
|
||||||
import type { ConnectAccountInput, ToggleAccountInput } from '@/lib/validators/email';
|
import type { ConnectAccountInput, ToggleAccountInput } from '@/lib/validators/email';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountWithoutCredentials = Omit<typeof emailAccounts.$inferSelect, 'credentialsEnc'>;
|
type AccountWithoutCredentials = Omit<typeof emailAccounts.$inferSelect, 'credentialsEnc'>;
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function stripCredentials(
|
function stripCredentials(account: typeof emailAccounts.$inferSelect): AccountWithoutCredentials {
|
||||||
account: typeof emailAccounts.$inferSelect,
|
|
||||||
): AccountWithoutCredentials {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { credentialsEnc: _, ...safe } = account;
|
const { credentialsEnc: _, ...safe } = account;
|
||||||
return safe;
|
return safe;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
|
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
|
||||||
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
|
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 { NotFoundError, ForbiddenError } from '@/lib/errors';
|
||||||
import { getDecryptedCredentials } from '@/lib/services/email-accounts.service';
|
import { getDecryptedCredentials } from '@/lib/services/email-accounts.service';
|
||||||
import { getPortEmailConfig } from '@/lib/services/port-config';
|
import { getPortEmailConfig } from '@/lib/services/port-config';
|
||||||
@@ -13,13 +13,6 @@ import type { ComposeEmailInput } from '@/lib/validators/email';
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function assertAttachmentsForPort(
|
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 { db } from '@/lib/db';
|
||||||
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
|
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { diffEntity } from '@/lib/entity-diff';
|
||||||
import { softDelete, restore } from '@/lib/db/utils';
|
import { softDelete, restore } from '@/lib/db/utils';
|
||||||
import { NotFoundError, ConflictError } from '@/lib/errors';
|
import { NotFoundError, ConflictError } from '@/lib/errors';
|
||||||
@@ -19,14 +19,6 @@ import type {
|
|||||||
|
|
||||||
export type { ListExpensesInput };
|
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) {
|
export async function listExpenses(portId: string, query: ListExpensesInput) {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
@@ -79,11 +71,7 @@ export async function getExpenseById(id: string, portId: string) {
|
|||||||
return expense;
|
return expense;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createExpense(
|
export async function createExpense(portId: string, data: CreateExpenseInput, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
data: CreateExpenseInput,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
let amountUsd: string | null = null;
|
let amountUsd: string | null = null;
|
||||||
let exchangeRate: string | null = null;
|
let exchangeRate: string | null = null;
|
||||||
|
|
||||||
@@ -163,7 +151,7 @@ export async function updateExpense(
|
|||||||
id: string,
|
id: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
data: UpdateExpenseInput,
|
data: UpdateExpenseInput,
|
||||||
meta: ServiceAuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
const existing = await getExpenseById(id, portId);
|
const existing = await getExpenseById(id, portId);
|
||||||
|
|
||||||
@@ -226,7 +214,7 @@ export async function updateExpense(
|
|||||||
return updated;
|
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);
|
const existing = await getExpenseById(id, portId);
|
||||||
|
|
||||||
// BR-045: Check if linked to non-draft invoice
|
// 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 });
|
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 getExpenseById(id, portId);
|
||||||
|
|
||||||
await restore(expenses, expenses.id, id);
|
await restore(expenses, expenses.id, id);
|
||||||
@@ -287,7 +275,7 @@ export async function addReceiptFiles(
|
|||||||
id: string,
|
id: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
fileIds: string[],
|
fileIds: string[],
|
||||||
meta: ServiceAuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
await getExpenseById(id, portId);
|
await getExpenseById(id, portId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
|||||||
import { files, documents } from '@/lib/db/schema/documents';
|
import { files, documents } from '@/lib/db/schema/documents';
|
||||||
import { expenses } from '@/lib/db/schema/financial';
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
|
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 { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { minioClient, getPresignedUrl } from '@/lib/minio';
|
import { minioClient, getPresignedUrl } from '@/lib/minio';
|
||||||
@@ -20,13 +20,6 @@ import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/val
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadFileParams {
|
interface UploadFileParams {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
@@ -57,13 +50,9 @@ export async function uploadFile(
|
|||||||
const sanitizedOriginal = sanitizeFilename(file.originalName);
|
const sanitizedOriginal = sanitizeFilename(file.originalName);
|
||||||
const sanitizedFilename = sanitizeFilename(data.filename);
|
const sanitizedFilename = sanitizeFilename(data.filename);
|
||||||
|
|
||||||
await minioClient.putObject(
|
await minioClient.putObject(env.MINIO_BUCKET, storagePath, file.buffer, file.size, {
|
||||||
env.MINIO_BUCKET,
|
'Content-Type': file.mimeType,
|
||||||
storagePath,
|
});
|
||||||
file.buffer,
|
|
||||||
file.size,
|
|
||||||
{ 'Content-Type': file.mimeType },
|
|
||||||
);
|
|
||||||
|
|
||||||
const [record] = await db
|
const [record] = await db
|
||||||
.insert(files)
|
.insert(files)
|
||||||
@@ -176,12 +165,7 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
|
|||||||
db
|
db
|
||||||
.select({ id: expenses.id })
|
.select({ id: expenses.id })
|
||||||
.from(expenses)
|
.from(expenses)
|
||||||
.where(
|
.where(and(eq(expenses.portId, portId), arrayContains(expenses.receiptFileIds, [id])))
|
||||||
and(
|
|
||||||
eq(expenses.portId, portId),
|
|
||||||
arrayContains(expenses.receiptFileIds, [id]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1),
|
.limit(1),
|
||||||
db
|
db
|
||||||
.select({ id: berthMaintenanceLog.id })
|
.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) {
|
if (docRefs.length > 0 || expenseRefs.length > 0 || maintenanceRefs.length > 0) {
|
||||||
throw new ConflictError(
|
throw new ConflictError('File cannot be deleted because it is referenced by other records');
|
||||||
'File cannot be deleted because it is referenced by other records',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from MinIO first, then DB
|
// Delete from MinIO first, then DB
|
||||||
@@ -235,9 +217,7 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sortColumn =
|
const sortColumn =
|
||||||
sort === 'filename' ? files.filename :
|
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
||||||
sort === 'sizeBytes' ? files.sizeBytes :
|
|
||||||
files.createdAt;
|
|
||||||
|
|
||||||
return buildListQuery({
|
return buildListQuery({
|
||||||
table: files,
|
table: files,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { berths } from '@/lib/db/schema/berths';
|
|||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { tags } from '@/lib/db/schema/system';
|
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 { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
||||||
@@ -22,13 +23,6 @@ import type {
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Yacht ownership validator ───────────────────────────────────────────────
|
// ─── Yacht ownership validator ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function assertYachtBelongsToClient(
|
async function assertYachtBelongsToClient(
|
||||||
@@ -552,26 +546,18 @@ export async function setInterestTags(
|
|||||||
throw new NotFoundError('Interest');
|
throw new NotFoundError('Interest');
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(interestTags).where(eq(interestTags.interestId, id));
|
const result = await setEntityTags({
|
||||||
|
joinTable: interestTags,
|
||||||
if (tagIds.length > 0) {
|
entityColumn: interestTags.interestId,
|
||||||
await db.insert(interestTags).values(tagIds.map((tagId) => ({ interestId: id, tagId })));
|
tagColumn: interestTags.tagId,
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditLog({
|
|
||||||
userId: meta.userId,
|
|
||||||
portId,
|
|
||||||
action: 'update',
|
|
||||||
entityType: 'interest',
|
|
||||||
entityId: id,
|
entityId: id,
|
||||||
metadata: { type: 'tags_updated', tagIds },
|
portId,
|
||||||
ipAddress: meta.ipAddress,
|
tagIds,
|
||||||
userAgent: meta.userAgent,
|
meta,
|
||||||
|
entityType: 'interest',
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: ['tags'] });
|
return { interestId: result.entityId, tagIds: result.tagIds };
|
||||||
|
|
||||||
return { interestId: id, tagIds };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────
|
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { systemSettings } from '@/lib/db/schema/system';
|
|||||||
import { clients, clientAddresses } from '@/lib/db/schema/clients';
|
import { clients, clientAddresses } from '@/lib/db/schema/clients';
|
||||||
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
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 { diffEntity } from '@/lib/entity-diff';
|
||||||
import { withTransaction } from '@/lib/db/utils';
|
import { withTransaction } from '@/lib/db/utils';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||||
@@ -29,14 +29,6 @@ import type {
|
|||||||
ListInvoicesInput,
|
ListInvoicesInput,
|
||||||
} from '@/lib/validators/invoices';
|
} 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) ───────────────────────────────────────────────
|
// ─── Auto-numbering (BR-041) ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<string> {
|
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) ─────────────────────────────────────
|
// ─── Create (BR-041, BR-042, BR-045) ─────────────────────────────────────
|
||||||
|
|
||||||
export async function createInvoice(
|
export async function createInvoice(portId: string, data: CreateInvoiceInput, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
data: CreateInvoiceInput,
|
|
||||||
meta: ServiceAuditMeta,
|
|
||||||
) {
|
|
||||||
const invoice = await withTransaction(async (tx) => {
|
const invoice = await withTransaction(async (tx) => {
|
||||||
// Resolve the polymorphic billing entity (client | company). Throws
|
// Resolve the polymorphic billing entity (client | company). Throws
|
||||||
// ValidationError if the entity is missing or belongs to another tenant.
|
// ValidationError if the entity is missing or belongs to another tenant.
|
||||||
@@ -361,7 +349,7 @@ export async function updateInvoice(
|
|||||||
id: string,
|
id: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
data: UpdateInvoiceInput,
|
data: UpdateInvoiceInput,
|
||||||
meta: ServiceAuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
const existing = await getInvoiceById(id, portId);
|
const existing = await getInvoiceById(id, portId);
|
||||||
if (existing.status !== 'draft') {
|
if (existing.status !== 'draft') {
|
||||||
@@ -496,7 +484,7 @@ export async function updateInvoice(
|
|||||||
|
|
||||||
// ─── Delete (draft only) ──────────────────────────────────────────────────
|
// ─── 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);
|
const existing = await getInvoiceById(id, portId);
|
||||||
if (existing.status !== 'draft') {
|
if (existing.status !== 'draft') {
|
||||||
throw new ConflictError('Only draft invoices can be deleted');
|
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 ─────────────────────────────────────────────────────────
|
// ─── 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 invoice = await getInvoiceById(id, portId);
|
||||||
|
|
||||||
const [port] = await db
|
const [port] = await db
|
||||||
@@ -589,7 +577,7 @@ export async function generateInvoicePdf(id: string, portId: string, meta: Servi
|
|||||||
|
|
||||||
// ─── Send invoice ─────────────────────────────────────────────────────────
|
// ─── 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);
|
const invoice = await getInvoiceById(id, portId);
|
||||||
|
|
||||||
// Generate PDF if not exists
|
// Generate PDF if not exists
|
||||||
@@ -637,7 +625,7 @@ export async function recordPayment(
|
|||||||
id: string,
|
id: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
data: RecordPaymentInput,
|
data: RecordPaymentInput,
|
||||||
meta: ServiceAuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
const existing = await getInvoiceById(id, portId);
|
const existing = await getInvoiceById(id, portId);
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,11 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports } from '@/lib/db/schema';
|
import { ports } from '@/lib/db/schema';
|
||||||
import type { PortSettings } from '@/lib/db/schema/ports';
|
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 { ConflictError, NotFoundError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
|
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPorts() {
|
export async function listPorts() {
|
||||||
return db.select().from(ports).orderBy(ports.name);
|
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 { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
|
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Score a single berth ─────────────────────────────────────────────────────
|
// ─── 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 { db } from '@/lib/db';
|
||||||
import { reminders, interests, clients } from '@/lib/db/schema';
|
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 { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { createNotification } from '@/lib/services/notifications.service';
|
import { createNotification } from '@/lib/services/notifications.service';
|
||||||
@@ -14,13 +14,6 @@ import type {
|
|||||||
ReminderListQuery,
|
ReminderListQuery,
|
||||||
} from '@/lib/validators/reminders';
|
} from '@/lib/validators/reminders';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── List ────────────────────────────────────────────────────────────────────
|
// ─── List ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listReminders(portId: string, query: ReminderListQuery) {
|
export async function listReminders(portId: string, query: ReminderListQuery) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
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 { NotFoundError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
@@ -17,13 +17,6 @@ import type {
|
|||||||
UpdateResidentialInterestInput,
|
UpdateResidentialInterestInput,
|
||||||
} from '@/lib/validators/residential';
|
} from '@/lib/validators/residential';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Residential clients ─────────────────────────────────────────────────────
|
// ─── Residential clients ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
|
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
|
||||||
|
|||||||
@@ -3,18 +3,11 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { roles, userPortRoles } from '@/lib/db/schema';
|
import { roles, userPortRoles } from '@/lib/db/schema';
|
||||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
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 { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
|
import type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRoles() {
|
export async function listRoles() {
|
||||||
return db.select().from(roles).orderBy(roles.name);
|
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 { db } from '@/lib/db';
|
||||||
import { systemSettings } from '@/lib/db/schema';
|
import { systemSettings } from '@/lib/db/schema';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSettings(portId: string) {
|
export async function listSettings(portId: string) {
|
||||||
// Get port-specific settings
|
// Get port-specific settings
|
||||||
const portSettings = await db
|
const portSettings = await db
|
||||||
|
|||||||
@@ -2,31 +2,16 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { tags } from '@/lib/db/schema';
|
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 { ConflictError, NotFoundError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import type { CreateTagInput, UpdateTagInput } from '@/lib/validators/tags';
|
import type { CreateTagInput, UpdateTagInput } from '@/lib/validators/tags';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTags(portId: string) {
|
export async function listTags(portId: string) {
|
||||||
return db
|
return db.select().from(tags).where(eq(tags.portId, portId)).orderBy(tags.name);
|
||||||
.select()
|
|
||||||
.from(tags)
|
|
||||||
.where(eq(tags.portId, portId))
|
|
||||||
.orderBy(tags.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTag(
|
export async function createTag(portId: string, data: CreateTagInput, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
data: CreateTagInput,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
// Enforce unique (portId, name)
|
// Enforce unique (portId, name)
|
||||||
const existing = await db.query.tags.findFirst({
|
const existing = await db.query.tags.findFirst({
|
||||||
where: and(eq(tags.portId, portId), eq(tags.name, data.name)),
|
where: and(eq(tags.portId, portId), eq(tags.name, data.name)),
|
||||||
@@ -60,12 +45,7 @@ export async function createTag(
|
|||||||
return tag!;
|
return tag!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTag(
|
export async function updateTag(id: string, portId: string, data: UpdateTagInput, meta: AuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
data: UpdateTagInput,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const tag = await db.query.tags.findFirst({
|
const tag = await db.query.tags.findFirst({
|
||||||
where: and(eq(tags.id, id), eq(tags.portId, portId)),
|
where: and(eq(tags.id, id), eq(tags.portId, portId)),
|
||||||
});
|
});
|
||||||
@@ -83,7 +63,10 @@ export async function updateTag(
|
|||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(tags)
|
.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)))
|
.where(and(eq(tags.id, id), eq(tags.portId, portId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -108,11 +91,7 @@ export async function updateTag(
|
|||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTag(
|
export async function deleteTag(id: string, portId: string, meta: AuditMeta) {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const tag = await db.query.tags.findFirst({
|
const tag = await db.query.tags.findFirst({
|
||||||
where: and(eq(tags.id, id), eq(tags.portId, portId)),
|
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 { db } from '@/lib/db';
|
||||||
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
|
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
|
||||||
import { auth } from '@/lib/auth';
|
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 { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
|
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listUsers(portId: string) {
|
export async function listUsers(portId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { and, desc, eq, count } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { webhooks, webhookDeliveries } from '@/lib/db/schema/system';
|
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 { encrypt, decrypt } from '@/lib/utils/encryption';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
import { getQueue } from '@/lib/queue';
|
import { getQueue } from '@/lib/queue';
|
||||||
@@ -16,13 +16,6 @@ import type { WebhookEvent } from '@/lib/services/webhook-event-map';
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Generates a 32-byte hex secret for signing webhook payloads. */
|
/** Generates a 32-byte hex secret for signing webhook payloads. */
|
||||||
@@ -173,11 +166,7 @@ export async function updateWebhook(
|
|||||||
|
|
||||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function deleteWebhook(
|
export async function deleteWebhook(portId: string, webhookId: string, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
webhookId: string,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const existing = await db.query.webhooks.findFirst({
|
const existing = await db.query.webhooks.findFirst({
|
||||||
where: eq(webhooks.id, webhookId),
|
where: eq(webhooks.id, webhookId),
|
||||||
});
|
});
|
||||||
@@ -187,9 +176,7 @@ export async function deleteWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CASCADE deletes webhook_deliveries
|
// CASCADE deletes webhook_deliveries
|
||||||
await db
|
await db.delete(webhooks).where(and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)));
|
||||||
.delete(webhooks)
|
|
||||||
.where(and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)));
|
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
@@ -205,11 +192,7 @@ export async function deleteWebhook(
|
|||||||
|
|
||||||
// ─── Regenerate Secret ────────────────────────────────────────────────────────
|
// ─── Regenerate Secret ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function regenerateSecret(
|
export async function regenerateSecret(portId: string, webhookId: string, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
webhookId: string,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const existing = await db.query.webhooks.findFirst({
|
const existing = await db.query.webhooks.findFirst({
|
||||||
where: eq(webhooks.id, webhookId),
|
where: eq(webhooks.id, webhookId),
|
||||||
});
|
});
|
||||||
@@ -288,11 +271,7 @@ export async function listDeliveries(
|
|||||||
|
|
||||||
// ─── Send Test Webhook ────────────────────────────────────────────────────────
|
// ─── Send Test Webhook ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendTestWebhook(
|
export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {
|
||||||
portId: string,
|
|
||||||
webhookId: string,
|
|
||||||
eventType: WebhookEvent,
|
|
||||||
) {
|
|
||||||
const webhook = await db.query.webhooks.findFirst({
|
const webhook = await db.query.webhooks.findFirst({
|
||||||
where: eq(webhooks.id, webhookId),
|
where: eq(webhooks.id, webhookId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { db } from '@/lib/db';
|
|||||||
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
||||||
import type { Yacht } from '@/lib/db/schema/yachts';
|
import type { Yacht } from '@/lib/db/schema/yachts';
|
||||||
import { companies } from '@/lib/db/schema/companies';
|
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 { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { withTransaction } from '@/lib/db/utils';
|
import { withTransaction } from '@/lib/db/utils';
|
||||||
@@ -19,13 +20,6 @@ import type {
|
|||||||
|
|
||||||
type CreateYachtInput = z.input<typeof createYachtSchema>;
|
type CreateYachtInput = z.input<typeof createYachtSchema>;
|
||||||
|
|
||||||
interface AuditMeta {
|
|
||||||
userId: string;
|
|
||||||
portId: string;
|
|
||||||
ipAddress: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertOwnerExists(
|
async function assertOwnerExists(
|
||||||
portId: string,
|
portId: string,
|
||||||
owner: { type: 'client' | 'company'; id: 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) });
|
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
|
||||||
if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht');
|
if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht');
|
||||||
|
|
||||||
await db.delete(yachtTags).where(eq(yachtTags.yachtId, yachtId));
|
await setEntityTags({
|
||||||
|
joinTable: yachtTags,
|
||||||
if (tagIds.length > 0) {
|
entityColumn: yachtTags.yachtId,
|
||||||
await db.insert(yachtTags).values(tagIds.map((tagId) => ({ yachtId, tagId })));
|
tagColumn: yachtTags.tagId,
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditLog({
|
|
||||||
userId: meta.userId,
|
|
||||||
portId,
|
|
||||||
action: 'update',
|
|
||||||
entityType: 'yacht',
|
|
||||||
entityId: yachtId,
|
entityId: yachtId,
|
||||||
newValue: { tagIds },
|
portId,
|
||||||
ipAddress: meta.ipAddress,
|
tagIds,
|
||||||
userAgent: meta.userAgent,
|
meta,
|
||||||
|
entityType: 'yacht',
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'yacht:updated', { yachtId, changedFields: ['tags'] });
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user