2026-03-26 12:06:18 +01:00
|
|
|
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { tags } from '@/lib/db/schema/system';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
|
|
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
import { ConflictError } from '@/lib/errors';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import type {
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
CreateBerthInput,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
UpdateBerthInput,
|
|
|
|
|
UpdateBerthStatusInput,
|
|
|
|
|
ListBerthsQuery,
|
|
|
|
|
AddMaintenanceLogInput,
|
|
|
|
|
UpdateWaitingListInput,
|
|
|
|
|
} from '@/lib/validators/berths';
|
|
|
|
|
|
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|
|
|
|
const filters = [];
|
|
|
|
|
|
|
|
|
|
if (query.status) {
|
|
|
|
|
filters.push(eq(berths.status, query.status));
|
|
|
|
|
}
|
|
|
|
|
if (query.area) {
|
|
|
|
|
filters.push(eq(berths.area, query.area));
|
|
|
|
|
}
|
|
|
|
|
if (query.minLength !== undefined) {
|
|
|
|
|
filters.push(gte(berths.lengthM, String(query.minLength)));
|
|
|
|
|
}
|
|
|
|
|
if (query.maxLength !== undefined) {
|
|
|
|
|
filters.push(lte(berths.lengthM, String(query.maxLength)));
|
|
|
|
|
}
|
|
|
|
|
if (query.minPrice !== undefined) {
|
|
|
|
|
filters.push(gte(berths.price, String(query.minPrice)));
|
|
|
|
|
}
|
|
|
|
|
if (query.maxPrice !== undefined) {
|
|
|
|
|
filters.push(lte(berths.price, String(query.maxPrice)));
|
|
|
|
|
}
|
|
|
|
|
if (query.tenureType) {
|
|
|
|
|
filters.push(eq(berths.tenureType, query.tenureType));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tag filter: join against berthTags
|
|
|
|
|
if (query.tagIds && query.tagIds.length > 0) {
|
|
|
|
|
const tagIds = query.tagIds;
|
|
|
|
|
const berthsWithTags = await db
|
|
|
|
|
.selectDistinct({ berthId: berthTags.berthId })
|
|
|
|
|
.from(berthTags)
|
|
|
|
|
.where(inArray(berthTags.tagId, tagIds));
|
|
|
|
|
const matchingIds = berthsWithTags.map((r) => r.berthId);
|
|
|
|
|
if (matchingIds.length === 0) {
|
|
|
|
|
return { data: [], total: 0 };
|
|
|
|
|
}
|
|
|
|
|
filters.push(inArray(berths.id, matchingIds));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortColumn = (() => {
|
|
|
|
|
switch (query.sort) {
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
case 'mooringNumber':
|
|
|
|
|
return berths.mooringNumber;
|
|
|
|
|
case 'area':
|
|
|
|
|
return berths.area;
|
|
|
|
|
case 'price':
|
|
|
|
|
return berths.price;
|
|
|
|
|
case 'status':
|
|
|
|
|
return berths.status;
|
|
|
|
|
case 'lengthM':
|
|
|
|
|
return berths.lengthM;
|
|
|
|
|
default:
|
|
|
|
|
return berths.updatedAt;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const result = await buildListQuery({
|
|
|
|
|
table: berths,
|
|
|
|
|
portIdColumn: berths.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: berths.id,
|
|
|
|
|
updatedAtColumn: berths.updatedAt,
|
|
|
|
|
filters,
|
|
|
|
|
sort: { column: sortColumn, direction: query.order },
|
|
|
|
|
page: query.page,
|
|
|
|
|
pageSize: query.limit,
|
|
|
|
|
searchColumns: [berths.mooringNumber, berths.area],
|
|
|
|
|
searchTerm: query.search,
|
|
|
|
|
// No archivedAt column on berths
|
|
|
|
|
includeArchived: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Attach tags for list items
|
|
|
|
|
const berthIds = (result.data as Array<{ id: string }>).map((b) => b.id);
|
|
|
|
|
const tagsByBerthId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
|
|
|
|
|
|
|
|
|
if (berthIds.length > 0) {
|
|
|
|
|
const tagRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
berthId: berthTags.berthId,
|
|
|
|
|
id: tags.id,
|
|
|
|
|
name: tags.name,
|
|
|
|
|
color: tags.color,
|
|
|
|
|
})
|
|
|
|
|
.from(berthTags)
|
|
|
|
|
.innerJoin(tags, eq(berthTags.tagId, tags.id))
|
|
|
|
|
.where(inArray(berthTags.berthId, berthIds));
|
|
|
|
|
|
|
|
|
|
for (const row of tagRows) {
|
|
|
|
|
if (!tagsByBerthId[row.berthId]) tagsByBerthId[row.berthId] = [];
|
|
|
|
|
tagsByBerthId[row.berthId]!.push({ id: row.id, name: row.name, color: row.color });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
|
|
|
|
...b,
|
|
|
|
|
tags: tagsByBerthId[b.id as string] ?? [],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return { data, total: result.total };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Get By ID ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getBerthById(id: string, portId: string) {
|
|
|
|
|
const berth = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
with: {
|
|
|
|
|
mapData: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!berth) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
// Fetch tags
|
|
|
|
|
const tagRows = await db
|
|
|
|
|
.select({ id: tags.id, name: tags.name, color: tags.color })
|
|
|
|
|
.from(berthTags)
|
|
|
|
|
.innerJoin(tags, eq(berthTags.tagId, tags.id))
|
|
|
|
|
.where(eq(berthTags.berthId, id));
|
|
|
|
|
|
|
|
|
|
return { ...berth, tags: tagRows };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateBerth(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateBerthInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
const { changed, diff } = diffEntity(
|
|
|
|
|
existing as Record<string, unknown>,
|
|
|
|
|
data as Record<string, unknown>,
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
if (!changed) return existing;
|
|
|
|
|
|
|
|
|
|
// Drizzle numeric columns expect string | null — coerce numbers to strings
|
|
|
|
|
const n = (v: number | undefined) => (v !== undefined ? String(v) : undefined);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(berths)
|
|
|
|
|
.set({
|
|
|
|
|
area: data.area,
|
|
|
|
|
lengthFt: n(data.lengthFt),
|
|
|
|
|
lengthM: n(data.lengthM),
|
|
|
|
|
widthFt: n(data.widthFt),
|
|
|
|
|
widthM: n(data.widthM),
|
|
|
|
|
draftFt: n(data.draftFt),
|
|
|
|
|
draftM: n(data.draftM),
|
|
|
|
|
widthIsMinimum: data.widthIsMinimum,
|
|
|
|
|
nominalBoatSize: data.nominalBoatSize,
|
|
|
|
|
nominalBoatSizeM: data.nominalBoatSizeM,
|
|
|
|
|
waterDepth: n(data.waterDepth),
|
|
|
|
|
waterDepthM: n(data.waterDepthM),
|
|
|
|
|
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
|
|
|
|
sidePontoon: data.sidePontoon,
|
|
|
|
|
powerCapacity: data.powerCapacity,
|
|
|
|
|
voltage: data.voltage,
|
|
|
|
|
mooringType: data.mooringType,
|
|
|
|
|
cleatType: data.cleatType,
|
|
|
|
|
cleatCapacity: data.cleatCapacity,
|
|
|
|
|
bollardType: data.bollardType,
|
|
|
|
|
bollardCapacity: data.bollardCapacity,
|
|
|
|
|
access: data.access,
|
|
|
|
|
price: n(data.price),
|
|
|
|
|
priceCurrency: data.priceCurrency,
|
|
|
|
|
bowFacing: data.bowFacing,
|
|
|
|
|
berthApproved: data.berthApproved,
|
|
|
|
|
tenureType: data.tenureType,
|
|
|
|
|
tenureYears: data.tenureYears,
|
|
|
|
|
tenureStartDate: data.tenureStartDate,
|
|
|
|
|
tenureEndDate: data.tenureEndDate,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: diff as unknown as Record<string, unknown>,
|
|
|
|
|
newValue: data as unknown as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:updated', {
|
|
|
|
|
berthId: id,
|
|
|
|
|
changedFields: Object.keys(diff),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
|
|
|
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update Status ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateBerthStatus(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateBerthStatusInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(berths)
|
|
|
|
|
.set({
|
|
|
|
|
status: data.status,
|
|
|
|
|
statusLastChangedBy: meta.userId,
|
|
|
|
|
statusLastChangedReason: data.reason,
|
|
|
|
|
statusLastModified: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { status: existing.status },
|
|
|
|
|
newValue: { status: data.status, reason: data.reason },
|
|
|
|
|
metadata: { type: 'status_change', reason: data.reason },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
|
|
|
|
berthId: id,
|
|
|
|
|
oldStatus: existing.status,
|
|
|
|
|
newStatus: data.status,
|
|
|
|
|
triggeredBy: meta.userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
|
|
|
dispatchWebhookEvent(portId, 'berth:statusChanged', {
|
|
|
|
|
berthId: id,
|
|
|
|
|
oldStatus: existing.status,
|
|
|
|
|
newStatus: data.status,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
const result = await setEntityTags({
|
|
|
|
|
joinTable: berthTags,
|
|
|
|
|
entityColumn: berthTags.berthId,
|
|
|
|
|
tagColumn: berthTags.tagId,
|
|
|
|
|
entityId: id,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
portId,
|
2026-04-29 01:58:42 +02:00
|
|
|
tagIds,
|
|
|
|
|
meta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
entityType: 'berth',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
return { berthId: result.entityId, tagIds: result.tagIds };
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function addMaintenanceLog(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: AddMaintenanceLogInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
const rows = await db
|
|
|
|
|
.insert(berthMaintenanceLog)
|
|
|
|
|
.values({
|
|
|
|
|
berthId: id,
|
|
|
|
|
portId,
|
|
|
|
|
category: data.category,
|
|
|
|
|
description: data.description,
|
|
|
|
|
cost: data.cost !== undefined ? String(data.cost) : undefined,
|
|
|
|
|
costCurrency: data.costCurrency,
|
|
|
|
|
responsibleParty: data.responsibleParty,
|
|
|
|
|
performedDate: data.performedDate,
|
|
|
|
|
photoFileIds: data.photoFileIds,
|
|
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
const log = rows[0]!;
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'berth_maintenance_log',
|
|
|
|
|
entityId: log.id,
|
|
|
|
|
metadata: { berthId: id, category: data.category },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:maintenanceAdded', {
|
|
|
|
|
berthId: id,
|
|
|
|
|
logEntry: log,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return log;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Get Maintenance Logs ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getMaintenanceLogs(id: string, portId: string) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
return db
|
|
|
|
|
.select()
|
|
|
|
|
.from(berthMaintenanceLog)
|
|
|
|
|
.where(and(eq(berthMaintenanceLog.berthId, id), eq(berthMaintenanceLog.portId, portId)))
|
|
|
|
|
.orderBy(berthMaintenanceLog.performedDate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Get Waiting List ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getWaitingList(id: string, portId: string) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
return db
|
|
|
|
|
.select()
|
|
|
|
|
.from(berthWaitingList)
|
|
|
|
|
.where(eq(berthWaitingList.berthId, id))
|
|
|
|
|
.orderBy(berthWaitingList.position);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update Waiting List ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateWaitingList(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateWaitingListInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
berthId from the body and persisted them with no port check; getReminder
then hydrated the row via Drizzle relations (no port filter on the
join), so a port-A user with reminders:create could exfiltrate any
port-B client/interest/berth row by guessing its UUID. New
assertReminderFksInPort gates create + update.
2. HIGH — listRecommendations(interestId, _portId) discarded portId
entirely; the route GET /api/v1/interests/[id]/recommendations
forwarded the URL id straight through. A port-A user with
interests:view could read any other tenant's recommended berths
(mooring numbers, dimensions, status). Service now verifies the
interest belongs to portId and joins berths filtered by port.
3. HIGH — Berth waiting list. The PATCH route did not pre-check that
the berth belonged to ctx.portId — a port-A user with
manage_waiting_list could reorder a port-B berth's queue. Separately,
updateWaitingList accepted arbitrary entries[].clientId and inserted
them without verifying tenancy, polluting the table with foreign-port
FKs. Both gaps closed.
4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
accepted any tagId and inserted into the join table. The tags table
is per-port but the join only carries a single-column FK. The
downstream getById join `tags ON join.tag_id = tags.id` has no port
filter, so a foreign tag's name + color render in the requesting port.
Helper now batch-validates tagIds belong to portId before insert.
5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
gate (any role, including viewer, could write) and didn't validate
that the URL entityId pointed at a port-scoped entity of the field
definition's entityType. Route now uses
withPermission('clients','view'/'edit',…); service validates the
entityId per resolved entityType (client/interest/berth/yacht/company)
against portId.
Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
|
|
|
// Validate every supplied clientId belongs to portId. Without this
|
|
|
|
|
// check, a port-A admin could insert port-B clientIds into the
|
|
|
|
|
// waiting list — corrupting reportable data and creating a join
|
|
|
|
|
// surface that hydrates foreign-tenant client rows.
|
|
|
|
|
if (data.entries.length > 0) {
|
|
|
|
|
const clientIds = [...new Set(data.entries.map((e) => e.clientId))];
|
|
|
|
|
const validClients = await db
|
|
|
|
|
.select({ id: clients.id })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(inArray(clients.id, clientIds), eq(clients.portId, portId)));
|
|
|
|
|
if (validClients.length !== clientIds.length) {
|
|
|
|
|
throw new ValidationError('One or more clients are not in this port');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// Replace entire waiting list
|
|
|
|
|
await db.delete(berthWaitingList).where(eq(berthWaitingList.berthId, id));
|
|
|
|
|
|
|
|
|
|
if (data.entries.length > 0) {
|
|
|
|
|
await db.insert(berthWaitingList).values(
|
|
|
|
|
data.entries.map((entry) => ({
|
|
|
|
|
berthId: id,
|
|
|
|
|
clientId: entry.clientId,
|
|
|
|
|
position: entry.position,
|
|
|
|
|
priority: entry.priority ?? 'normal',
|
|
|
|
|
notifyPref: entry.notifyPref ?? 'email',
|
|
|
|
|
notes: entry.notes,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: id,
|
|
|
|
|
metadata: { type: 'waiting_list_updated', count: data.entries.length },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:waitingListChanged', {
|
|
|
|
|
berthId: id,
|
|
|
|
|
action: 'replaced',
|
|
|
|
|
entry: data.entries,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return data.entries;
|
|
|
|
|
}
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
|
|
|
|
|
// Check mooring number uniqueness within port
|
|
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
|
|
|
|
|
});
|
|
|
|
|
if (existing) {
|
|
|
|
|
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [berth] = await db
|
|
|
|
|
.insert(berths)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
mooringNumber: data.mooringNumber,
|
|
|
|
|
area: data.area,
|
|
|
|
|
status: data.status ?? 'available',
|
|
|
|
|
lengthFt: data.lengthFt?.toString(),
|
|
|
|
|
lengthM: data.lengthM?.toString(),
|
|
|
|
|
widthFt: data.widthFt?.toString(),
|
|
|
|
|
widthM: data.widthM?.toString(),
|
|
|
|
|
draftFt: data.draftFt?.toString(),
|
|
|
|
|
draftM: data.draftM?.toString(),
|
|
|
|
|
price: data.price?.toString(),
|
|
|
|
|
priceCurrency: data.priceCurrency ?? 'USD',
|
|
|
|
|
tenureType: data.tenureType ?? 'permanent',
|
|
|
|
|
mooringType: data.mooringType,
|
|
|
|
|
powerCapacity: data.powerCapacity,
|
|
|
|
|
voltage: data.voltage,
|
|
|
|
|
access: data.access,
|
|
|
|
|
bowFacing: data.bowFacing,
|
|
|
|
|
sidePontoon: data.sidePontoon,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: berth!.id,
|
|
|
|
|
newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'system:alert', {
|
|
|
|
|
alertType: 'berth:created',
|
|
|
|
|
message: `Berth "${berth!.mooringNumber}" created`,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return berth!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Delete ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const berth = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!berth) throw new NotFoundError('Berth');
|
|
|
|
|
|
|
|
|
|
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'system:alert', {
|
|
|
|
|
alertType: 'berth:deleted',
|
|
|
|
|
message: `Berth "${berth.mooringNumber}" deleted`,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getBerthOptions(portId: string) {
|
|
|
|
|
return db
|
|
|
|
|
.select({
|
|
|
|
|
id: berths.id,
|
|
|
|
|
mooringNumber: berths.mooringNumber,
|
|
|
|
|
area: berths.area,
|
|
|
|
|
status: berths.status,
|
|
|
|
|
})
|
|
|
|
|
.from(berths)
|
|
|
|
|
.where(eq(berths.portId, portId))
|
|
|
|
|
.orderBy(berths.mooringNumber);
|
|
|
|
|
}
|