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,
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
nominalBoatSize: n(data.nominalBoatSize),
|
|
|
|
|
nominalBoatSizeM: n(data.nominalBoatSizeM),
|
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
|
|
|
waterDepth: n(data.waterDepth),
|
|
|
|
|
waterDepthM: n(data.waterDepthM),
|
|
|
|
|
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
|
|
|
|
sidePontoon: data.sidePontoon,
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
powerCapacity: n(data.powerCapacity),
|
|
|
|
|
voltage: n(data.voltage),
|
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
|
|
|
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,
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
powerCapacity: data.powerCapacity?.toString(),
|
|
|
|
|
voltage: data.voltage?.toString(),
|
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
|
|
|
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);
|
|
|
|
|
}
|