17 Commits

Author SHA1 Message Date
Matt Ciaccio
36b92eb827 docs(spec): client deduplication and NocoDB migration design
Captures the audit findings from a 2026-05-03 read-only NocoDB review
plus the algorithm and migration plan for porting the legacy data
into the new client / interest / contacts / addresses model.

Highlights:
- 252 NocoDB Interests rows ≈ ~190–200 unique humans (~20–25% dup
  rate). Six duplicate patterns documented from real data, including
  "same person, multiple yachts" — exactly the case the new
  client/interest split is designed to handle.
- Reuses the battle-tested `client-portal/server/utils/duplicate-
  detection.ts` algorithm (blocking + weighted rules) with additions:
  metaphone for non-English surnames, compounded confidence when
  multiple rules match, negative evidence for split-signal cases.
- Three runtime surfaces (at-create suggestion, interest-level
  same-berth guard, background scoring + admin review queue) plus a
  one-shot migration script with --dry-run / --apply / --rollback.
- Configurable thresholds via per-port system_settings so the merge
  policy can be tuned (defaults to "always confirm" — never
  auto-merges out of the box).
- Reversible: every merge writes a clientMergeLog row with the
  loser's full pre-state JSON, enabling 7-day undo without engineering.

Implementation decomposes into three plans (P1 library / P2 runtime /
P3 migration) sequenced after the mobile branch lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:10:08 +02:00
4552187b9f feat: add inquiry notification settings to admin settings UI
Some checks failed
Build & Push Docker Images / lint (push) Successful in 58s
Build & Push Docker Images / build-and-push (push) Failing after 3m57s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:16:20 -04:00
d0c12d74e4 feat: wire inquiry notifications into public interest endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:04:26 -04:00
7313d8b3d0 feat: add email worker handlers for inquiry confirmation and sales notification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:00:14 -04:00
c5c45accfc feat: add inquiry notification service for sales team targeting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:58:55 -04:00
9a0c28020d feat: add inquiry email templates for client confirmation and sales notification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:56:30 -04:00
44982a2878 feat: add optional plain-text fallback to sendEmail
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:54:25 -04:00
ae19170da8 feat: expand public interest schema with name split, address, berth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:53:13 -04:00
f90dba036f feat: add partial unique index for single primary address per client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:51:45 -04:00
59dd418542 feat: add client_addresses table for multi-address storage
Adds client_addresses table with cascade deletes, port scoping, primary address flag, and indexes. Updates clientsRelations and portsRelations, adds clientAddressesRelations. Generates migration 0000_narrow_longshot.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:44:11 -04:00
f659073b8f Add inquiry notifications implementation plan
9-task plan covering: DB schema, validator expansion, email
templates, notification service, worker handlers, route wiring,
and admin settings UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:38:25 -04:00
a8b93fd862 Add inquiry notifications system design spec
Covers migrating ActivePieces inquiry flow into the CRM:
client confirmation emails, sales team notifications,
client addresses table, and admin configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:21:45 -04:00
8df8ded46c 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
4fdd9e3207 Implement reminders system with full CRUD and background processors
- Reminders service: create, update, delete, complete, snooze, dismiss
- List with filters (status, priority, assignee, entity, date range)
- My/overdue/upcoming convenience endpoints
- BullMQ processors: auto-follow-up creation (BR-060) and overdue notifications
- Snooze with presets (1h, 4h, tomorrow, next week) and custom datetime
- Un-snooze logic: snoozed reminders auto-revert to pending when snooze expires
- UI: filterable list with my/all toggle, priority badges, overdue indicators
- Permission-gated: view_own, view_all, create, assign_others
- Entity linking: reminders can link to clients, interests, or berths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:27:34 -04:00
c8320023cc Implement admin ports and system settings management
- Port CRUD: list, create, update with branding, currency, timezone
- System settings: upsert key-value pairs per port with known settings UI
  (AI feature flags, invoice discount, pipeline weights, berth rules)
- Settings manager with toggle switches, number inputs, and JSON editors
- Replace both stub pages with real implementations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:53:33 -04:00
f60159e91a Implement admin users and roles management
- Add user CRUD: list, create (via Better Auth), update role/status, remove from port
- Add role CRUD: create, update permissions, delete with system role protection
- Full permissions matrix UI with accordion groups and per-action checkboxes
- Validators, services, API routes, and UI components following existing patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:47:11 -04:00
a13d7503cc Fix Docker build and production server infrastructure
- Add SKIP_ENV_VALIDATION to bypass Zod env check during next build
- Bundle custom server.ts with esbuild so production uses Socket.io
- Create worker entry point (src/worker.ts) with all BullMQ workers
- Add esbuild build scripts for server and worker bundles
- Fix Dockerfile.worker to include its own build stage
- Fix pre-commit hook to work without global pnpm
- Add CLAUDE.md with project conventions and quick reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:33 -04:00
80 changed files with 22926 additions and 292 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ drizzle/*.sql
coverage/
.turbo/
out/
dist/
test-results/
playwright-report/
nginx/certs/

View File

@@ -1,4 +1,4 @@
pnpm exec lint-staged
npx pnpm exec lint-staged
# Verify no .env files staged
if git diff --cached --name-only | grep -qE '\.env($|\.)'; then
echo "❌ .env files must not be committed"

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# Port Nimara CRM
Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.
## Quick reference
```bash
pnpm dev # Start dev server
pnpm build # Production build
pnpm lint # ESLint
pnpm format # Prettier
pnpm db:generate # Generate Drizzle migrations
pnpm db:push # Push schema to DB
pnpm db:studio # Drizzle Studio GUI
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
```
## Tech stack
- **Framework:** Next.js 15.1 App Router, `output: 'standalone'`, `experimental.typedRoutes`
- **Auth:** better-auth (session cookie: `pn-crm.session_token`)
- **Database:** PostgreSQL via `postgres` driver + Drizzle ORM
- **Queue:** BullMQ + Redis (ioredis)
- **Storage:** MinIO (S3-compatible)
- **Realtime:** Socket.IO with Redis adapter
- **UI:** Radix UI primitives, shadcn/ui components (`src/components/ui/`), Lucide icons, CVA + tailwind-merge + clsx
- **Forms:** react-hook-form + zod resolvers
- **Tables:** TanStack Table
- **State:** Zustand stores (`src/stores/`), TanStack React Query
- **PDF:** pdfme
- **Email:** nodemailer + imapflow + mailparser
- **AI:** OpenAI SDK (optional)
- **Testing:** Vitest (unit), Playwright (e2e)
- **Logging:** pino + pino-pretty
## Project structure
```
src/
app/
(auth)/ # Login/auth pages
(dashboard)/ # Main app - route: /[portSlug]/...
(portal)/ # Client portal
api/ # API routes
components/
ui/ # shadcn/ui base components
layout/ # Shell, sidebar, header
[domain]/ # Domain components (clients, invoices, berths, etc.)
shared/ # Cross-domain shared components
hooks/ # React hooks (use-auth, use-permissions, use-socket, etc.)
lib/
api/ # API client utilities
auth/ # better-auth config
db/
schema/ # Drizzle schema (one file per domain)
migrations/ # Generated Drizzle migrations
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
services/ # Business logic services
validators/ # Zod schemas for API input validation
utils/ # Shared utilities
middleware.ts # Auth middleware (cookie check, redirects)
providers/ # React context providers
stores/ # Zustand stores
types/ # Shared TypeScript types
```
## Conventions
- **TypeScript:** Strict mode with `noUncheckedIndexedAccess`. No `any` (ESLint error).
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
- **Imports:** Use `@/*` path alias (maps to `src/*`).
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`.
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`.
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files.
## Environment
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
## Docker
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
- `Dockerfile.dev` - Dev with bind-mounted source
- `Dockerfile.worker` - BullMQ worker process
- `docker-compose.yml` / `docker-compose.dev.yml` / `docker-compose.prod.yml`
## Architecture docs
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.

View File

@@ -12,6 +12,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV SKIP_ENV_VALIDATION=1
RUN pnpm build
# Stage 3: Production runner
@@ -23,6 +24,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["node", "server-custom.js"]

View File

@@ -1,15 +1,26 @@
# Stage 1: Install production dependencies
# Stage 1: Install dependencies (dev deps needed for esbuild)
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Production runner
FROM node:20-alpine AS runner
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
# Stage 2: Build the worker bundle
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY dist/worker.js ./worker.js
COPY . .
ENV SKIP_ENV_VALIDATION=1
RUN pnpm build:worker
# Stage 3: Production runner (prod deps only)
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
USER worker
CMD ["node", "worker.js"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
# Inquiry Notifications System Design
Migrates the ActivePieces-powered inquiry notification flow into the CRM. When a client registers interest via the Port Nimara website, the system sends a confirmation email to the client and notifies the sales team -- all using the CRM's own database and email infrastructure instead of NocoDB + ActivePieces.
## Scope
- Expand the public interest API to accept all website form fields
- Add client address storage (multi-address with primary flag)
- Send branded confirmation email to the client
- Send notification to sales team (CRM users + optional external recipients)
- Make notification recipients and contact email configurable by admins
## Database Changes
### New table: `client_addresses`
| Column | Type | Notes |
| ---------------- | ----------------- | ---------------------------------------------------------------- |
| `id` | uuid PK | `crypto.randomUUID()` |
| `client_id` | uuid FK → clients | cascade delete |
| `port_id` | uuid FK → ports | cascade delete |
| `label` | text | e.g., "Home", "Office", "Billing" |
| `street_address` | text | |
| `city` | text | |
| `state_province` | text | |
| `postal_code` | text | |
| `country` | text | |
| `is_primary` | boolean | default `true`, one-primary-per-client enforced in service layer |
| `created_at` | timestamp | default `now()` |
| `updated_at` | timestamp | default `now()` |
Schema file: `src/lib/db/schema/clients.ts` (alongside existing client tables).
Relations: added to `src/lib/db/schema/relations.ts` (client has many addresses).
### No changes to existing tables
- `clients.preferred_contact_method` already exists -- we populate it from the form.
- `interests.berth_id` already exists -- we resolve `mooringNumber` to a berth and link it.
- `notifications.type` already has `new_registration` -- we fire it.
## Public API Changes
### `POST /api/public/interests`
Expanded request schema:
```typescript
// Required
firstName: string; // max 100
lastName: string; // max 100
email: string; // email format
phone: string;
// Optional
preferredContactMethod: 'email' | 'phone' | 'sms';
mooringNumber: string; // e.g., "A3" -- resolved against berths.mooring_number
companyName: string;
yachtName: string;
yachtLengthFt: number;
yachtWidthFt: number;
yachtDraftFt: number;
preferredBerthSize: string;
notes: string; // max 2000
address: {
street: string;
city: string;
stateProvince: string;
postalCode: string;
country: string;
}
// Backward compatibility
fullName: string; // accepted if firstName/lastName not provided
```
Backward compatibility: if `fullName` is provided without `firstName`/`lastName`, it is used as-is for `clients.full_name`. If `firstName`+`lastName` are provided, they are concatenated.
### Behavior after record creation
1. Resolve `mooringNumber` against `berths.mooring_number` for the port. Link `interests.berth_id` if found; leave null if not.
2. Store `address` in `client_addresses` with `is_primary: true` and `label: 'Primary'`.
3. Set `clients.preferred_contact_method` from the form value.
4. Queue client confirmation email (see Email Templates below).
5. Fire `new_registration` notifications to sales team (see Notification Flow below).
6. Return `201 { data: { id, message } }` unchanged.
Rate limiting remains 5 requests/hour per IP.
## Email Templates
Located in `src/lib/email/templates/`. Each exports a function that accepts a typed data object and returns `{ subject: string, html: string, text: string }`.
### `inquiry-client-confirmation.ts`
Sent to the client who submitted the form.
**Input data:**
- `firstName` -- for the greeting
- `mooringNumber` -- berth identifier (nullable)
- `contactEmail` -- from `inquiry_contact_email` system setting
**Subject:** "Thank You for Your Interest in Berth {mooringNumber}" or "Thank You for Your Interest in a Port Nimara Berth" if no berth.
**Body:** Greeting with first name, confirmation their interest is registered, mention they'll be contacted by preferred method, link to the contact email address.
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces client confirmation template.
### `inquiry-sales-notification.ts`
Sent to CRM users and optional external recipients.
**Input data:**
- `fullName`
- `email`
- `phone`
- `mooringNumber` (nullable, defaults to "None")
- `crmUrl` -- link to the interest detail page in the CRM (built from port slug + interest ID)
**Subject:** "New Interest - Port Nimara"
**Body:** Notifies that a new interest has been registered, shows client details and berth selected, links to the CRM.
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces admin notification template.
Both templates include a plain-text fallback.
## Notification & Delivery Flow
### Client confirmation email
1. After record creation, queue a `send-inquiry-confirmation` job on the `email` BullMQ queue.
2. Email worker renders the `inquiry-client-confirmation` template with the interest data.
3. Sends via system SMTP (`src/lib/email/index.ts`).
4. No in-app notification (client is not a CRM user).
### Sales team notification
1. Query all users on the port who have `interests` read permission via their role.
2. For each user, call `createNotification()` with type `new_registration`.
- The existing notification service checks `user_notification_preferences` (in-app / email / both / neither).
- Creates in-app notification + Socket.IO push if `in_app: true`.
- Queues `send-notification-email` job if `email: true`.
3. Fetch `inquiry_notification_recipients` system setting for the port.
4. For each external email, queue a `send-inquiry-sales-notification` job on the `email` queue (bypasses notification preferences since these are not CRM users).
### Independence
Client confirmation and sales notifications are independent -- a failure in one does not block the other. The `201` response returns immediately after record creation, before any emails are sent.
## Admin Configuration
Two new system settings, managed via the existing admin settings UI:
### `inquiry_contact_email` (string, per-port)
The reply-to / contact email shown in client confirmation emails.
- Default: `sales@portnimara.com`
- Displayed as a mailto link in the client confirmation email.
### `inquiry_notification_recipients` (JSON array of strings, per-port)
Additional external email addresses that receive the sales team notification.
- Default: `[]` (empty)
- Only CRM users with interests permissions are notified by default.
- External recipients receive the sales notification email directly.
### Existing infrastructure (no changes needed)
- **Which CRM users get notified**: controlled by roles/permissions.
- **How each user receives notifications**: `user_notification_preferences` table.
- **Admin settings UI**: already supports custom key-value pairs in `system_settings`.
## Files to Create or Modify
### New files
- `src/lib/db/schema/client-addresses.ts` -- (or added to `clients.ts`)
- `src/lib/email/templates/inquiry-client-confirmation.ts`
- `src/lib/email/templates/inquiry-sales-notification.ts`
### Modified files
- `src/lib/db/schema/clients.ts` -- add `clientAddresses` table export
- `src/lib/db/schema/index.ts` -- re-export new table
- `src/lib/db/schema/relations.ts` -- add client addresses relations
- `src/lib/validators/public-interest.ts` (or wherever `publicInterestSchema` lives) -- expand schema
- `src/app/api/public/interests/route.ts` -- berth resolution, address storage, notification + email triggers
- `src/lib/queue/workers/email.ts` -- handle `send-inquiry-confirmation` and `send-inquiry-sales-notification` jobs
- `src/lib/services/interests.service.ts` -- helper to find users with interests permissions on a port
- `src/app/(dashboard)/[portSlug]/admin/settings/settings-manager.tsx` -- register the two new setting keys
## Out of Scope
- Editing email templates from the admin UI (templates are in code).
- Supplemental forms for collecting missing info (separate feature using existing `form_templates` / `form_submissions` infrastructure).
- Documenso EOI integration with address merge fields (separate feature).
- Changes to the Port Nimara website form itself (website team wires the form to our API).

View File

@@ -0,0 +1,564 @@
# Client Deduplication and NocoDB Migration Design
**Status**: Design draft 2026-05-03 — pending approval.
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
---
## 1. Background
### 1.1 Why this exists
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
- **252 Interests rows** in NocoDB, against an estimated ~190200 unique humans (~2025% duplication rate).
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
- **No Clients table.** The conflated structure is structural, not accidental.
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
### 1.2 Real duplicate patterns observed in the live data
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
| Pattern | Example rows | Signature |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
### 1.3 Dirty data inventory
The migration normalizer must survive these real values from production:
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
### 1.4 Existing battle-tested algorithm
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
### 1.5 Why the website is no longer the source of new dirty data
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
---
## 2. Approach
Three artifacts, layered:
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
---
## 3. Normalization library
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
### 3.1 `normalizeName(raw: string)`
```ts
export function normalizeName(raw: string): {
display: string; // human-readable, kept for UI
normalized: string; // for matching
surnameToken?: string; // for surname-based blocking
};
```
- Trim leading/trailing whitespace
- Replace `\r`, `\n`, tabs with single space
- Collapse consecutive whitespace to single space
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
- `display` preserves user's intent (slash-with-company stays intact)
- `normalized` is `display.toLowerCase()` for comparison
- `surnameToken` is the last non-particle token for blocking
### 3.2 `normalizeEmail(raw: string)`
```ts
export function normalizeEmail(raw: string): string | null;
```
- Trim + lowercase
- Validate via `zod.email()` schema
- Returns `null` for empty / invalid (caller decides what to do)
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
```ts
export function normalizePhone(
raw: string,
defaultCountry: string,
): {
e164: string | null; // canonical, e.g. '+15742740548'
country: string | null; // ISO-3166-1 alpha-2
display: string | null; // user-facing pretty
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
} | null;
```
Pipeline:
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
4. If starts with `00` → replace with `+`
5. If starts with `+` → parse as E.164
6. Else if `defaultCountry` provided → parse against that country
7. Else return null (caller's problem)
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
### 3.4 `resolveCountry(text: string)`
```ts
export function resolveCountry(text: string): {
iso: string | null; // ISO-3166-1 alpha-2
confidence: 'exact' | 'fuzzy' | 'city' | null;
};
```
Reuses `src/lib/i18n/countries.ts`. Pipeline:
1. Lowercase + strip diacritics
2. Exact match against country names (any locale we ship)
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
---
## 4. Dedup algorithm
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
### 4.1 Public API
```ts
export interface MatchCandidate {
id: string;
fullName: string | null;
emails: string[]; // already normalized
phonesE164: string[]; // already normalized E.164
countryIso: string | null;
}
export interface MatchResult {
candidate: MatchCandidate;
score: number; // 0100
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
confidence: 'high' | 'medium' | 'low';
}
export function findClientMatches(
input: MatchCandidate,
pool: MatchCandidate[],
thresholds: DedupThresholds,
): MatchResult[];
```
### 4.2 Scoring rules (compound)
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
| Rule | Score | Notes |
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
| **Negative**: Same email but different country code on phone | 15 | Suggests spouse / coworker / shared inbox |
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | 20 | Two distinct people with the same name |
### 4.3 Confidence tiers (post-compound)
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
- **score 5089 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
### 4.4 Blocking strategy
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 05 candidates per query, regardless of N.
### 4.5 Performance budget
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
---
## 5. Configurable thresholds (admin settings)
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
| Key | Default | Effect |
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
---
## 6. Merge service contract
### 6.1 Data flow
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
- `interests.clientId`
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
- `clientAddresses.clientId` — same conflict handling
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
- `clientTags.clientId`
- `clientYachtMembership.clientId` (or whatever the table is called)
- `auditLogs.entityId` — annotate, don't move (audit truth)
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
4. **Soft-archive loser**`loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
5. **Write `clientMergeLog`**`{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
### 6.2 Schema additions (migration)
`clients` table gets a new column:
```ts
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
```
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
```sql
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
```
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
### 6.3 Undo
`unmergeClients(mergeLogId, ctx)`:
1. Within the undo window, look up the snapshot
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
3. Restore loser's contacts/addresses/notes/tags from snapshot
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
### 6.4 Concurrency
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
---
## 7. Runtime surfaces
### 7.1 Layer 1 — At-create suggestion
In `ClientForm` (and the public `register` form once that hits the new system):
- Debounced 300ms after email or phone field changes
- Calls `findClientMatches` against current port's clients
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
```
┌─────────────────────────────────────┐
│ This looks like an existing client │
│ ML Marcus Laurent │
│ marcus@… +33 6 12 34 56 78 │
│ 2 interests · last 9d ago │
│ [ Use this client ] [ Create new ] │
└─────────────────────────────────────┘
```
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
### 7.2 Layer 2 — Interest-level same-berth guard
Cheap one-liner in `createInterest` service:
- Check `(clientId, berthId)` against existing non-archived interests
- If hit, throw `BerthDuplicateError` with the existing interest details
- UI catches and prompts: "Update existing or create separate?"
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
### 7.3 Layer 3 — Background scoring + review queue
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
```ts
export const clientMergeCandidates = pgTable('client_merge_candidates', {
id: text('id').primaryKey()...,
portId: text('port_id').notNull()...,
clientAId: text('client_a_id').notNull()...,
clientBId: text('client_b_id').notNull()...,
score: integer('score').notNull(),
reasons: jsonb('reasons').notNull(),
status: text('status').notNull().default('pending'), // pending | dismissed | merged
createdAt: timestamp('created_at')...,
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by'),
})
```
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
---
## 8. NocoDB → new system field mapping
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
### 8.1 Top-level transform
```
NocoDB Interests row
─→ 01 client (deduped against existing pool)
─→ 01 client_address
─→ 02 client_contacts (email, phone)
─→ exactly 1 interest
─→ 01 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
─→ 01 document (when documensoID present)
```
### 8.2 Field map
| NocoDB field | Target | Transform |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Full Name` | `clients.fullName` | `normalizeName().display` |
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
| `Time LOI Sent` | `interests.dateContractSent` | parse |
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
### 8.3 Sales-stage mapping (8 → 9)
| NocoDB | New (PIPELINE_STAGES) |
| ------------------------------- | ------------------------------------------------------------------------ |
| General Qualified Interest | `open` |
| Specific Qualified Interest | `details_sent` |
| EOI and NDA Sent | `eoi_sent` |
| Signed EOI and NDA | `eoi_signed` |
| Made Reservation | `deposit_10pct` |
| Contract Negotiation | `contract_sent` |
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
### 8.4 Other tables
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
---
## 9. Migration script
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
```
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
Reads the apply log, undoes the writes (only valid within the undo window).
```
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
### 9.1 Dry-run report format
`.migration/<timestamp>/report.csv`:
```csv
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
```
Plus `.migration/<timestamp>/summary.md`:
```
# Migration Dry-Run — 2026-05-03 14:23 UTC
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
Auto-linked (high confidence, no human action needed):
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
- John Lynch: rows 716,725 → 1 client + 2 interests
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
- [12 more]
Flagged for manual review (medium confidence):
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
- [4 more]
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
- Row 239: "Sag Harbor Y" → AI (likely US)
- [6 more]
Phone parsing failed for 3 rows. All flagged, no contact created:
- Row 178: empty
- Row 641: placeholder "+447000000000"
- Row 175: empty
Run `--apply` to commit these changes.
```
### 9.2 Apply phase
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
### 9.3 Idempotency
The script tracks NocoDB row IDs in a `migration_source_links` table:
```ts
export const migrationSourceLinks = pgTable('migration_source_links', {
id: text('id').primaryKey()...,
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
sourceId: text('source_id').notNull(), // NocoDB row id as string
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
targetEntityId: text('target_entity_id').notNull(),
appliedAt: timestamp('applied_at')...,
appliedBy: text('applied_by'),
}, (table) => [
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
]);
```
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
---
## 10. Test plan
### 10.1 Library-level (vitest unit)
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
### 10.2 Service-level (vitest integration)
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
### 10.3 Migration script (vitest integration with NocoDB mock)
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
### 10.4 E2E (Playwright)
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
---
## 11. Rollback plan
Three layers of safety, ordered by reversibility:
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
---
## 12. Open items
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
- **Profile photo / face match** — out of scope.
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
---
## Implementation sequence
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~57 days.
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
Total: ~1012 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.

View File

@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build && pnpm build:server",
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
@@ -89,6 +91,7 @@
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"esbuild": "^0.25.0",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.30.0",
"eslint": "^9.0.0",

333
pnpm-lock.yaml generated
View File

@@ -103,7 +103,7 @@ importers:
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-auth:
specifier: ^1.2.0
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
bullmq:
specifier: ^5.25.0
version: 5.71.0
@@ -221,7 +221,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitest/coverage-v8':
specifier: ^4.1.0
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -231,6 +231,9 @@ importers:
drizzle-kit:
specifier: ^0.30.0
version: 0.30.6
esbuild:
specifier: ^0.25.0
version: 0.25.12
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
@@ -263,7 +266,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.0
version: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
version: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
packages:
@@ -469,6 +472,12 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
@@ -487,6 +496,12 @@ packages:
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
@@ -505,6 +520,12 @@ packages:
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
@@ -523,6 +544,12 @@ packages:
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
@@ -541,6 +568,12 @@ packages:
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
@@ -559,6 +592,12 @@ packages:
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
@@ -577,6 +616,12 @@ packages:
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
@@ -595,6 +640,12 @@ packages:
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
@@ -613,6 +664,12 @@ packages:
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
@@ -631,6 +688,12 @@ packages:
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
@@ -649,6 +712,12 @@ packages:
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
@@ -667,6 +736,12 @@ packages:
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
@@ -685,6 +760,12 @@ packages:
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
@@ -703,6 +784,12 @@ packages:
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
@@ -721,6 +808,12 @@ packages:
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
@@ -739,6 +832,12 @@ packages:
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
@@ -757,12 +856,24 @@ packages:
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
@@ -781,12 +892,24 @@ packages:
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
@@ -805,12 +928,24 @@ packages:
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
@@ -829,6 +964,12 @@ packages:
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
@@ -847,6 +988,12 @@ packages:
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
@@ -865,6 +1012,12 @@ packages:
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
@@ -883,6 +1036,12 @@ packages:
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
@@ -989,67 +1148,79 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -1149,24 +1320,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.1.0':
resolution: {integrity: sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.1.0':
resolution: {integrity: sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.1.0':
resolution: {integrity: sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.1.0':
resolution: {integrity: sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==}
@@ -1919,36 +2094,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==}
@@ -2216,41 +2397,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3124,6 +3313,11 @@ packages:
engines: {node: '>=12'}
hasBin: true
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
@@ -3816,24 +4010,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5687,6 +5885,9 @@ snapshots:
'@esbuild/aix-ppc64@0.19.12':
optional: true
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/aix-ppc64@0.27.4':
optional: true
@@ -5696,6 +5897,9 @@ snapshots:
'@esbuild/android-arm64@0.19.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
@@ -5705,6 +5909,9 @@ snapshots:
'@esbuild/android-arm@0.19.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
@@ -5714,6 +5921,9 @@ snapshots:
'@esbuild/android-x64@0.19.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
@@ -5723,6 +5933,9 @@ snapshots:
'@esbuild/darwin-arm64@0.19.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
@@ -5732,6 +5945,9 @@ snapshots:
'@esbuild/darwin-x64@0.19.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
@@ -5741,6 +5957,9 @@ snapshots:
'@esbuild/freebsd-arm64@0.19.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
@@ -5750,6 +5969,9 @@ snapshots:
'@esbuild/freebsd-x64@0.19.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
@@ -5759,6 +5981,9 @@ snapshots:
'@esbuild/linux-arm64@0.19.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
@@ -5768,6 +5993,9 @@ snapshots:
'@esbuild/linux-arm@0.19.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
@@ -5777,6 +6005,9 @@ snapshots:
'@esbuild/linux-ia32@0.19.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
@@ -5786,6 +6017,9 @@ snapshots:
'@esbuild/linux-loong64@0.19.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
@@ -5795,6 +6029,9 @@ snapshots:
'@esbuild/linux-mips64el@0.19.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
@@ -5804,6 +6041,9 @@ snapshots:
'@esbuild/linux-ppc64@0.19.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
@@ -5813,6 +6053,9 @@ snapshots:
'@esbuild/linux-riscv64@0.19.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
@@ -5822,6 +6065,9 @@ snapshots:
'@esbuild/linux-s390x@0.19.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
@@ -5831,9 +6077,15 @@ snapshots:
'@esbuild/linux-x64@0.19.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
@@ -5843,9 +6095,15 @@ snapshots:
'@esbuild/netbsd-x64@0.19.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
@@ -5855,9 +6113,15 @@ snapshots:
'@esbuild/openbsd-x64@0.19.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
@@ -5867,6 +6131,9 @@ snapshots:
'@esbuild/sunos-x64@0.19.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
@@ -5876,6 +6143,9 @@ snapshots:
'@esbuild/win32-arm64@0.19.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
@@ -5885,6 +6155,9 @@ snapshots:
'@esbuild/win32-ia32@0.19.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
@@ -5894,6 +6167,9 @@ snapshots:
'@esbuild/win32-x64@0.19.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
@@ -7215,7 +7491,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))':
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0
@@ -7227,7 +7503,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/expect@4.1.0':
dependencies:
@@ -7238,13 +7514,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.1.0
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@4.1.0':
dependencies:
@@ -7519,7 +7795,7 @@ snapshots:
baseline-browser-mapping@2.10.8: {}
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))
@@ -7545,7 +7821,7 @@ snapshots:
next: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- '@cloudflare/workers-types'
@@ -8155,6 +8431,35 @@ snapshots:
'@esbuild/win32-ia32': 0.19.12
'@esbuild/win32-x64': 0.19.12
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
@@ -10691,7 +10996,7 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
@@ -10701,16 +11006,16 @@ snapshots:
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.15
esbuild: 0.19.12
esbuild: 0.25.12
fsevents: 2.3.3
jiti: 1.21.7
tsx: 4.21.0
yaml: 2.8.2
vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)):
vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.1.0
'@vitest/runner': 4.1.0
'@vitest/snapshot': 4.1.0
@@ -10727,7 +11032,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.15

View File

@@ -1,16 +1,5 @@
import { AuditLogList } from '@/components/admin/audit/audit-log-list';
export default function AuditLogPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
<p className="text-muted-foreground">Review system activity and changes</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <AuditLogList />;
}

View File

@@ -1,16 +1,5 @@
import { PortList } from '@/components/admin/ports/port-list';
export default function PortManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Port Management</h1>
<p className="text-muted-foreground">Manage port locations and configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <PortList />;
}

View File

@@ -1,16 +1,5 @@
import { RoleList } from '@/components/admin/roles/role-list';
export default function RoleManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Role Management</h1>
<p className="text-muted-foreground">Configure roles and permissions</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <RoleList />;
}

View File

@@ -1,16 +1,5 @@
import { SettingsManager } from '@/components/admin/settings/settings-manager';
export default function SystemSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">System Settings</h1>
<p className="text-muted-foreground">Configure system-wide settings</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <SettingsManager />;
}

View File

@@ -1,16 +1,5 @@
import { UserList } from '@/components/admin/users/user-list';
export default function UserManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground">Manage user accounts and access</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <UserList />;
}

View File

@@ -1,16 +1,5 @@
import { ReminderList } from '@/components/reminders/reminder-list';
export default function RemindersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reminders</h1>
<p className="text-muted-foreground">Manage tasks and follow-up reminders</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <ReminderList />;
}

View File

@@ -1,16 +1,5 @@
import { UserSettings } from '@/components/settings/user-settings';
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage your account and port preferences</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <UserSettings />;
}

View File

@@ -3,10 +3,13 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, RateLimitError } from '@/lib/errors';
import { publicInterestSchema } from '@/lib/validators/interests';
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
// Max 5 requests per hour per IP
@@ -47,90 +50,68 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
}
// Resolve the full name
const fullName =
data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
: (data.fullName ?? 'Unknown');
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided)
let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
if (data.mooringNumber) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
});
if (berth) {
berthId = berth.id;
resolvedMooringNumber = berth.mooringNumber;
}
}
// Find or create client by email
let clientId: string;
const existingContact = await db.query.clientContacts.findFirst({
where: and(
eq(clientContacts.channel, 'email'),
eq(clientContacts.value, data.email),
),
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
});
if (existingContact) {
// Find the client associated with this contact
const existingClient = await db.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
} else {
// Create new client for this port
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
}
// Update preferred contact method if provided
if (data.preferredContactMethod) {
await db
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
}
} else {
// Create brand-new client
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
clientId = await createNewClient(portId, fullName, data);
}
} else {
clientId = await createNewClient(portId, fullName, data);
}
await db.insert(clientContacts).values({
// Store address if provided
if (data.address && Object.values(data.address).some(Boolean)) {
await db.insert(clientAddresses).values({
clientId,
channel: 'email',
value: data.email,
portId,
label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
isPrimary: true,
});
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
}
}
// Create the interest
@@ -139,6 +120,7 @@ export async function POST(req: NextRequest) {
.values({
portId,
clientId,
berthId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
@@ -151,12 +133,29 @@ export async function POST(req: NextRequest) {
action: 'create',
entityType: 'interest',
entityId: interest!.id,
newValue: { clientId, source: 'website', pipelineStage: 'open' },
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
metadata: { type: 'public_registration', ip },
ipAddress: ip,
userAgent: req.headers.get('user-agent') ?? 'unknown',
});
// Fire notifications asynchronously (non-blocking)
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
columns: { slug: true },
});
void sendInquiryNotifications({
portId,
portSlug: port?.slug ?? portId,
interestId: interest!.id,
clientFullName: fullName,
clientEmail: data.email,
clientPhone: data.phone,
mooringNumber: resolvedMooringNumber,
firstName,
});
return NextResponse.json(
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
{ status: 201 },
@@ -165,3 +164,52 @@ export async function POST(req: NextRequest) {
return errorResponse(error);
}
}
async function createNewClient(
portId: string,
fullName: string,
data: {
email: string;
phone: string;
companyName?: string;
yachtName?: string;
yachtLengthFt?: number;
yachtWidthFt?: number;
yachtDraftFt?: number;
preferredBerthSize?: string;
preferredContactMethod?: string;
},
): Promise<string> {
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
})
.returning();
const clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
return clientId;
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listAuditLogs } from '@/lib/services/audit.service';
import { errorResponse } from '@/lib/errors';
const auditQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
entityType: z.string().optional(),
action: z.string().optional(),
userId: z.string().optional(),
entityId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
search: z.string().optional(),
});
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req, ctx) => {
try {
const query = parseQuery(req, auditQuerySchema);
const result = await listAuditLogs(ctx.portId, query);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getPort, updatePort } from '@/lib/services/ports.service';
import { updatePortSchema } from '@/lib/validators/ports';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
try {
const data = await getPort(params.id!);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const body = await parseBody(req, updatePortSchema);
const data = await updatePort(params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listPorts, createPort } from '@/lib/services/ports.service';
import { createPortSchema } from '@/lib/validators/ports';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_settings', async () => {
try {
const data = await listPorts();
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, createPortSchema);
const data = await createPort(body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,28 +1,49 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { roles } from '@/lib/db/schema';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
import { updateRoleSchema } from '@/lib/validators/roles';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx, params) => {
try {
const { id } = params;
if (!id) {
throw new NotFoundError('Role');
}
const role = await db.query.roles.findFirst({
where: eq(roles.id, id),
});
if (!role) {
throw new NotFoundError('Role');
}
return NextResponse.json({ data: role });
const data = await getRole(params.id!);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_users', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateRoleSchema);
const data = await updateRole(params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
await deleteRole(params.id!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}

View File

@@ -1,19 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { parseBody } from '@/lib/api/route-helpers';
import { listRoles, createRole } from '@/lib/services/roles.service';
import { createRoleSchema } from '@/lib/validators/roles';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx) => {
withPermission('admin', 'manage_users', async () => {
try {
const data = await db.query.roles.findMany({
orderBy: (roles, { asc }) => [asc(roles.name)],
});
const data = await listRoles();
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, ctx) => {
try {
const body = await parseBody(req, createRoleSchema);
const data = await createRole(body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listSettings, upsertSetting, deleteSetting } from '@/lib/services/settings.service';
import { upsertSettingSchema, deleteSettingSchema } from '@/lib/validators/settings';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const data = await listSettings(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const { key, value } = await parseBody(req, upsertSettingSchema);
const data = await upsertSetting(key, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const { key } = await parseBody(req, deleteSettingSchema);
await deleteSetting(key, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getUser, updateUser, removeUserFromPort } from '@/lib/services/users.service';
import { updateUserSchema } from '@/lib/validators/users';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
const data = await getUser(params.id!, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_users', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateUserSchema);
const data = await updateUser(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
await removeUserFromPort(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,40 +1,35 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { userPortRoles, userProfiles, roles } from '@/lib/db/schema';
import { parseBody } from '@/lib/api/route-helpers';
import { listUsers, createUser } from '@/lib/services/users.service';
import { createUserSchema } from '@/lib/validators/users';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
const rows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
isActive: userProfiles.isActive,
lastLoginAt: userProfiles.lastLoginAt,
roleId: roles.id,
roleName: roles.name,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, ctx.portId))
.orderBy(userProfiles.displayName);
const data = rows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
}));
const data = await listUsers(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, ctx) => {
try {
const body = await parseBody(req, createUserSchema);
const data = await createUser(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthSchema } from '@/lib/validators/berths';
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]
@@ -18,7 +18,7 @@ export const GET = withAuth(
}),
);
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
// PATCH /api/v1/berths/[id]
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
@@ -35,3 +35,20 @@ export const PATCH = withAuth(
}
}),
);
// DELETE /api/v1/berths/[id]
export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => {
try {
await deleteBerth(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,12 +1,11 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listBerthsSchema } from '@/lib/validators/berths';
import { listBerths } from '@/lib/services/berths.service';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { listBerthsSchema, createBerthSchema } from '@/lib/validators/berths';
import { listBerths, createBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {
@@ -34,3 +33,20 @@ export const GET = withAuth(
}
}),
);
export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx) => {
try {
const body = await parseBody(req, createBerthSchema);
const data = await createBerth(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { findDuplicates } from '@/lib/services/clients.service';
import { errorResponse } from '@/lib/errors';
const duplicateQuerySchema = z.object({
name: z.string().min(1),
});
export const GET = withAuth(
withPermission('clients', 'view', async (req, ctx) => {
try {
const { name } = parseQuery(req, duplicateQuerySchema);
const data = await findDuplicates(ctx.portId, name);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,5 +1,26 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, type AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
import { z } from 'zod';
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(200).optional(),
phone: z.string().nullable().optional(),
avatarUrl: z.string().url().nullable().optional(),
preferences: z
.object({
dark_mode: z.boolean().optional(),
locale: z.string().optional(),
timezone: z.string().optional(),
})
.passthrough()
.optional(),
});
export const GET = withAuth(async (_req, ctx: AuthContext) => {
return NextResponse.json({
@@ -13,3 +34,45 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
},
});
});
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
try {
const body = await parseBody(req, updateProfileSchema);
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
});
if (!profile) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.phone !== undefined) updates.phone = body.phone;
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
if (body.preferences !== undefined) {
updates.preferences = {
...((profile.preferences as Record<string, unknown>) ?? {}),
...body.preferences,
};
}
const [updated] = await db
.update(userProfiles)
.set(updates)
.where(eq(userProfiles.userId, ctx.userId))
.returning();
return NextResponse.json({
data: {
userId: updated!.userId,
displayName: updated!.displayName,
phone: updated!.phone,
avatarUrl: updated!.avatarUrl,
preferences: updated!.preferences,
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { completeReminder } from '@/lib/services/reminders.service';
import { errorResponse } from '@/lib/errors';
export const POST = withAuth(
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
try {
const data = await completeReminder(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { dismissReminder } from '@/lib/services/reminders.service';
import { errorResponse } from '@/lib/errors';
export const POST = withAuth(
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
try {
const data = await dismissReminder(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getReminder, updateReminder, deleteReminder } from '@/lib/services/reminders.service';
import { updateReminderSchema } from '@/lib/validators/reminders';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_own', async (_req, ctx, params) => {
try {
const data = await getReminder(params.id!, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('reminders', 'edit_own', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateReminderSchema);
const data = await updateReminder(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
try {
await deleteReminder(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { snoozeReminder } from '@/lib/services/reminders.service';
import { snoozeReminderSchema } from '@/lib/validators/reminders';
import { errorResponse } from '@/lib/errors';
export const POST = withAuth(
withPermission('reminders', 'edit_own', async (req, ctx, params) => {
try {
const body = await parseBody(req, snoozeReminderSchema);
const data = await snoozeReminder(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getMyReminders } from '@/lib/services/reminders.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_own', async (_req, ctx) => {
try {
const data = await getMyReminders(ctx.userId, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getOverdueReminders } from '@/lib/services/reminders.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_all', async (_req, ctx) => {
try {
const data = await getOverdueReminders(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { listReminders, createReminder } from '@/lib/services/reminders.service';
import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_own', async (req, ctx) => {
try {
const query = parseQuery(req, reminderListQuerySchema);
const result = await listReminders(ctx.portId, query);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('reminders', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createReminderSchema);
// Check assign_others permission if assigning to someone else
if (body.assignedTo && body.assignedTo !== ctx.userId) {
if (!ctx.isSuperAdmin) {
const perms = ctx.permissions?.reminders;
if (!perms?.assign_others) {
return NextResponse.json(
{ error: 'Cannot assign reminders to other users' },
{ status: 403 },
);
}
}
}
const data = await createReminder(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getUpcomingReminders } from '@/lib/services/reminders.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_own', async (_req, ctx) => {
try {
const data = await getUpcomingReminders(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,257 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
interface AuditEntry {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string;
fieldChanged: string | null;
oldValue: Record<string, unknown> | null;
newValue: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
createdAt: string;
}
const ACTION_COLORS: Record<string, string> = {
create: 'bg-green-600',
update: 'bg-blue-500',
delete: 'bg-red-600',
archive: 'bg-orange-500',
restore: 'bg-teal-500',
login: 'bg-gray-500',
permission_denied: 'bg-red-800',
};
const ENTITY_TYPES = [
'client',
'interest',
'berth',
'document',
'expense',
'invoice',
'reminder',
'user',
'role',
'port',
'setting',
'tag',
'webhook',
];
export function AuditLogList() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
const [actionFilter, setActionFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: '50',
});
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
if (actionFilter !== 'all') params.set('action', actionFilter);
if (search) params.set('search', search);
const res = await apiFetch<{
data: AuditEntry[];
pagination: { total: number };
}>(`/api/v1/admin/audit?${params}`);
setEntries(res.data);
setTotal(res.pagination.total);
} finally {
setLoading(false);
}
}, [page, entityTypeFilter, actionFilter, search]);
useEffect(() => {
void fetchLogs();
}, [fetchLogs]);
const columns: ColumnDef<AuditEntry, unknown>[] = [
{
accessorKey: 'createdAt',
header: 'Time',
cell: ({ row }) => (
<div className="text-sm">
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
</div>
</div>
),
size: 180,
},
{
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
<Badge
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
>
{row.original.action}
</Badge>
),
size: 100,
},
{
accessorKey: 'entityType',
header: 'Entity',
cell: ({ row }) => (
<div>
<span className="font-medium capitalize">{row.original.entityType}</span>
<code className="ml-2 text-xs text-muted-foreground">
{row.original.entityId.slice(0, 8)}...
</code>
</div>
),
},
{
id: 'changes',
header: 'Changes',
cell: ({ row }) => {
const { newValue, fieldChanged } = row.original;
if (fieldChanged) return <span className="text-sm">{fieldChanged}</span>;
if (newValue) {
const keys = Object.keys(newValue);
return (
<span className="text-xs text-muted-foreground">
{keys.slice(0, 3).join(', ')}
{keys.length > 3 ? ` +${keys.length - 3} more` : ''}
</span>
);
}
return <span className="text-xs text-muted-foreground"></span>;
},
},
{
accessorKey: 'userId',
header: 'User',
cell: ({ row }) => (
<code className="text-xs">
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
</code>
),
size: 100,
},
];
return (
<div>
<PageHeader title="Audit Log" description={`${total} entries`} />
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
<Select
value={entityTypeFilter}
onValueChange={(v) => {
setEntityTypeFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={actionFilter}
onValueChange={(v) => {
setActionFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Actions</SelectItem>
<SelectItem value="create">Create</SelectItem>
<SelectItem value="update">Update</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="archive">Archive</SelectItem>
<SelectItem value="restore">Restore</SelectItem>
<SelectItem value="permission_denied">Permission Denied</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={entries}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
}
/>
{total > 50 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {Math.ceil(total / 50)}
</span>
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page >= Math.ceil(total / 50)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
interface PortFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
port?: {
id: string;
name: string;
slug: string;
logoUrl: string | null;
primaryColor: string | null;
defaultCurrency: string;
timezone: string;
isActive: boolean;
} | null;
onSuccess: () => void;
}
export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps) {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [primaryColor, setPrimaryColor] = useState('#0F4C81');
const [defaultCurrency, setDefaultCurrency] = useState('USD');
const [timezone, setTimezone] = useState('America/Anguilla');
const [isActive, setIsActive] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!port;
useEffect(() => {
if (open) {
if (port) {
setName(port.name);
setSlug(port.slug);
setPrimaryColor(port.primaryColor ?? '#0F4C81');
setDefaultCurrency(port.defaultCurrency);
setTimezone(port.timezone);
setIsActive(port.isActive);
} else {
setName('');
setSlug('');
setPrimaryColor('#0F4C81');
setDefaultCurrency('USD');
setTimezone('America/Anguilla');
setIsActive(true);
}
setError(null);
}
}, [open, port]);
function handleNameChange(value: string) {
setName(value);
if (!isEdit) {
setSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/ports/${port.id}`, {
method: 'PATCH',
body: { name, slug, primaryColor, defaultCurrency, timezone, isActive },
});
} else {
await apiFetch('/api/v1/admin/ports', {
method: 'POST',
body: { name, slug, primaryColor, defaultCurrency, timezone },
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Port' : 'New Port'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="port-name">Name</Label>
<Input
id="port-name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Port Nimara"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="port-slug">Slug</Label>
<Input
id="port-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="port-nimara"
pattern="^[a-z0-9-]+$"
required
/>
<p className="text-xs text-muted-foreground">
Used in URLs. Lowercase letters, numbers, and hyphens only.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="port-color">Brand Color</Label>
<div className="flex items-center gap-2">
<input
type="color"
id="port-color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="h-9 w-9 rounded border cursor-pointer"
/>
<Input
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
placeholder="#0F4C81"
className="w-28 font-mono text-sm"
maxLength={7}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="port-currency">Currency</Label>
<Input
id="port-currency"
value={defaultCurrency}
onChange={(e) => setDefaultCurrency(e.target.value.toUpperCase())}
placeholder="USD"
maxLength={3}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="port-timezone">Timezone</Label>
<Input
id="port-timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="America/Anguilla"
required
/>
</div>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="port-active">Port Active</Label>
<p className="text-xs text-muted-foreground">
Inactive ports are hidden from users
</p>
</div>
<Switch id="port-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim() || !slug.trim()}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Port'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Plus } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { PortForm } from './port-form';
interface PortRow {
id: string;
name: string;
slug: string;
logoUrl: string | null;
primaryColor: string | null;
defaultCurrency: string;
timezone: string;
isActive: boolean;
settings: Record<string, unknown>;
createdAt: string;
}
export function PortList() {
const [ports, setPorts] = useState<PortRow[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingPort, setEditingPort] = useState<PortRow | null>(null);
const fetchPorts = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports');
setPorts(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchPorts();
}, [fetchPorts]);
function handleNewPort() {
setEditingPort(null);
setFormOpen(true);
}
function handleEditPort(port: PortRow) {
setEditingPort(port);
setFormOpen(true);
}
const columns: ColumnDef<PortRow, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.primaryColor && (
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: row.original.primaryColor }}
/>
)}
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: 'slug',
header: 'Slug',
cell: ({ row }) => (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">{row.original.slug}</code>
),
},
{
accessorKey: 'defaultCurrency',
header: 'Currency',
},
{
accessorKey: 'timezone',
header: 'Timezone',
},
{
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) =>
row.original.isActive ? (
<Badge variant="default" className="bg-green-600">
Active
</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button variant="ghost" size="sm" onClick={() => handleEditPort(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
),
enableSorting: false,
size: 60,
},
];
return (
<div>
<PageHeader
title="Port Management"
description="Manage marina ports and their configuration"
actions={
<Button onClick={handleNewPort}>
<Plus className="mr-1.5 h-4 w-4" />
New Port
</Button>
}
/>
<DataTable
columns={columns}
data={ports}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No ports configured.</p>
</div>
}
/>
<PortForm
open={formOpen}
onOpenChange={setFormOpen}
port={editingPort}
onSuccess={fetchPorts}
/>
</div>
);
}

View File

@@ -0,0 +1,297 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { apiFetch } from '@/lib/api/client';
/** Default permissions structure matching RolePermissions type */
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
generate_eoi: false,
export: false,
},
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
documents: {
view: false,
create: false,
send_for_signing: false,
upload_signed: false,
delete: false,
},
expenses: {
view: false,
create: false,
edit: false,
delete: false,
export: false,
scan_receipt: false,
},
invoices: {
view: false,
create: false,
edit: false,
delete: false,
send: false,
record_payment: false,
export: false,
},
files: { view: false, upload: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {
view_own: false,
view_all: false,
create: false,
edit_own: false,
edit_all: false,
assign_others: false,
},
calendar: { connect: false, view_events: false },
reports: { view_dashboard: false, view_analytics: false, export: false },
document_templates: { view: false, generate: false, manage: false },
admin: {
manage_users: false,
view_audit_log: false,
manage_settings: false,
manage_webhooks: false,
manage_reports: false,
manage_custom_fields: false,
manage_forms: false,
manage_tags: false,
system_backup: false,
},
};
const GROUP_LABELS: Record<string, string> = {
clients: 'Clients',
interests: 'Interests / Pipeline',
berths: 'Berths',
documents: 'Documents',
expenses: 'Expenses',
invoices: 'Invoices',
files: 'Files',
email: 'Email',
reminders: 'Reminders',
calendar: 'Calendar',
reports: 'Reports',
document_templates: 'Document Templates',
admin: 'Administration',
};
function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
interface RoleFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: {
id: string;
name: string;
description: string | null;
isSystem: boolean;
permissions: Record<string, Record<string, boolean>>;
} | null;
onSuccess: () => void;
}
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
structuredClone(DEFAULT_PERMISSIONS),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!role;
useEffect(() => {
if (open) {
if (role) {
setName(role.name);
setDescription(role.description ?? '');
// Merge role permissions over defaults to fill any missing keys
const merged = structuredClone(DEFAULT_PERMISSIONS);
for (const [group, actions] of Object.entries(role.permissions)) {
if (merged[group]) {
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
merged[group]![action] = value;
}
}
}
setPermissions(merged);
} else {
setName('');
setDescription('');
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
}
setError(null);
}
}, [open, role]);
function togglePermission(group: string, action: string) {
setPermissions((prev) => {
const next = structuredClone(prev);
next[group]![action] = !next[group]![action];
return next;
});
}
function toggleGroup(group: string, value: boolean) {
setPermissions((prev) => {
const next = structuredClone(prev);
for (const action of Object.keys(next[group]!)) {
next[group]![action] = value;
}
return next;
});
}
function isGroupAllChecked(group: string): boolean {
return Object.values(permissions[group]!).every(Boolean);
}
function isGroupPartial(group: string): boolean {
const vals = Object.values(permissions[group]!);
return vals.some(Boolean) && !vals.every(Boolean);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
method: 'PATCH',
body: { name, description: description || null, permissions },
});
} else {
await apiFetch('/api/v1/admin/roles', {
method: 'POST',
body: { name, description: description || undefined, permissions },
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[500px] sm:max-w-[500px]">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
<div className="space-y-4 mb-4">
<div className="space-y-2">
<Label htmlFor="role-name">Name</Label>
<Input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales Manager"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">Description</Label>
<Textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this role is for..."
rows={2}
/>
</div>
</div>
<Label className="mb-2">Permissions</Label>
<ScrollArea className="flex-1 rounded-md border">
<Accordion type="multiple" className="px-3">
{Object.entries(permissions).map(([group, actions]) => (
<AccordionItem key={group} value={group}>
<AccordionTrigger className="text-sm">
<div className="flex items-center gap-3">
<Checkbox
checked={isGroupAllChecked(group)}
ref={(el) => {
if (el) {
(el as unknown as HTMLInputElement).indeterminate =
isGroupPartial(group);
}
}}
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
onClick={(e) => e.stopPropagation()}
/>
<span>{GROUP_LABELS[group] ?? group}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
{Object.entries(actions).map(([action, checked]) => (
<label
key={action}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => togglePermission(group, action)}
/>
{formatAction(action)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
<SheetFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim()}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, Lock } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { RoleForm } from './role-form';
interface Role {
id: string;
name: string;
description: string | null;
isSystem: boolean;
isGlobal: boolean;
permissions: Record<string, Record<string, boolean>>;
createdAt: string;
}
export function RoleList() {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles');
setRoles(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchRoles();
}, [fetchRoles]);
function handleNewRole() {
setEditingRole(null);
setFormOpen(true);
}
function handleEditRole(role: Role) {
setEditingRole(role);
setFormOpen(true);
}
async function handleDeleteRole(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' });
await fetchRoles();
} finally {
setDeletingId(null);
}
}
function countPermissions(perms: Record<string, Record<string, boolean>>): string {
let granted = 0;
let total = 0;
for (const group of Object.values(perms)) {
for (const val of Object.values(group)) {
total++;
if (val) granted++;
}
}
return `${granted}/${total}`;
}
const columns: ColumnDef<Role, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.isSystem && (
<Badge variant="outline" className="text-xs">
<Lock className="mr-1 h-3 w-3" />
System
</Badge>
)}
</div>
),
},
{
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
),
},
{
id: 'permissions',
header: 'Permissions',
cell: ({ row }) => (
<Badge variant="secondary">{countPermissions(row.original.permissions)}</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditRole(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
{!row.original.isSystem && (
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
}
title="Delete Role"
description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`}
confirmLabel="Delete"
onConfirm={() => handleDeleteRole(row.original.id)}
loading={deletingId === row.original.id}
/>
)}
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div>
<PageHeader
title="Role Management"
description="Manage roles and their permissions"
actions={
<Button onClick={handleNewRole}>
<Plus className="mr-1.5 h-4 w-4" />
New Role
</Button>
}
/>
<DataTable
columns={columns}
data={roles}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No roles defined.</p>
</div>
}
/>
<RoleForm
open={formOpen}
onOpenChange={setFormOpen}
role={editingRole}
onSuccess={fetchRoles}
/>
</div>
);
}

View File

@@ -0,0 +1,399 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Trash2, Plus, Save } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api/client';
interface Setting {
key: string;
value: unknown;
portId: string | null;
updatedBy: string | null;
updatedAt: string;
}
/** Well-known settings with their display metadata */
const KNOWN_SETTINGS: Array<{
key: string;
label: string;
description: string;
type: 'boolean' | 'number' | 'json' | 'string';
defaultValue: unknown;
}> = [
{
key: 'ai_interest_scoring',
label: 'AI Interest Scoring',
description: 'Enable AI-powered interest scoring based on engagement signals',
type: 'boolean',
defaultValue: false,
},
{
key: 'ai_email_drafts',
label: 'AI Email Drafts',
description: 'Enable AI-assisted email draft generation',
type: 'boolean',
defaultValue: false,
},
{
key: 'invoice_net10_discount',
label: 'Net-10 Invoice Discount (%)',
description: 'Discount percentage applied when payment terms are net-10',
type: 'number',
defaultValue: 2,
},
{
key: 'pipeline_weights',
label: 'Pipeline Stage Weights',
description: 'Probability weights for revenue forecast by pipeline stage (JSON)',
type: 'json',
defaultValue: {
open: 0.05,
details_sent: 0.1,
in_communication: 0.2,
signed_eoi_nda: 0.4,
deposit_10pct: 0.6,
contract: 0.8,
completed: 1.0,
},
},
{
key: 'berth_rules',
label: 'Berth Status Rules',
description: 'Auto/suggest/off rules for berth status transitions (JSON)',
type: 'json',
defaultValue: [],
},
{
key: 'inquiry_contact_email',
label: 'Inquiry Contact Email',
description:
'Reply-to email shown in client confirmation emails when a new interest is registered',
type: 'string',
defaultValue: 'sales@portnimara.com',
},
{
key: 'inquiry_notification_recipients',
label: 'External Notification Recipients',
description:
'Additional email addresses that receive sales notifications for new interests (JSON array)',
type: 'json',
defaultValue: [],
},
];
export function SettingsManager() {
const [portSettings, setPortSettings] = useState<Setting[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [customKey, setCustomKey] = useState('');
const [customValue, setCustomValue] = useState('');
const fetchSettings = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: { portSettings: Setting[]; globalSettings: Setting[] } }>(
'/api/v1/admin/settings',
);
setPortSettings(res.data.portSettings);
// Build values map from existing settings
const vals: Record<string, unknown> = {};
for (const s of res.data.portSettings) {
vals[s.key] = s.value;
}
setValues(vals);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchSettings();
}, [fetchSettings]);
async function saveSetting(key: string, value: unknown) {
setSaving(key);
try {
await apiFetch('/api/v1/admin/settings', {
method: 'PUT',
body: { key, value },
});
await fetchSettings();
} finally {
setSaving(null);
}
}
async function handleDeleteSetting(key: string) {
await apiFetch('/api/v1/admin/settings', {
method: 'DELETE',
body: { key },
});
await fetchSettings();
}
async function handleAddCustom() {
if (!customKey.trim()) return;
let parsed: unknown;
try {
parsed = JSON.parse(customValue);
} catch {
parsed = customValue;
}
await saveSetting(customKey, parsed);
setCustomKey('');
setCustomValue('');
}
function getEffectiveValue(key: string, defaultValue: unknown): unknown {
return values[key] ?? defaultValue;
}
if (loading) {
return (
<div>
<PageHeader title="System Settings" description="Configure system behavior for this port" />
<div className="flex items-center justify-center py-12 text-muted-foreground">
Loading...
</div>
</div>
);
}
// Custom settings = port settings that aren't in KNOWN_SETTINGS
const knownKeys = new Set(KNOWN_SETTINGS.map((s) => s.key));
const customSettings = portSettings.filter((s) => !knownKeys.has(s.key));
return (
<div>
<PageHeader title="System Settings" description="Configure system behavior for this port" />
<div className="space-y-6 mt-6">
{/* Feature Flags */}
<Card>
<CardHeader>
<CardTitle>Feature Flags</CardTitle>
<CardDescription>Enable or disable optional features</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'boolean').map((setting) => (
<div key={setting.key} className="flex items-center justify-between">
<div>
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
</div>
<Switch
checked={getEffectiveValue(setting.key, setting.defaultValue) === true}
disabled={saving === setting.key}
onCheckedChange={(checked) => saveSetting(setting.key, checked)}
/>
</div>
))}
</CardContent>
</Card>
{/* String Settings */}
{KNOWN_SETTINGS.some((s) => s.type === 'string') && (
<Card>
<CardHeader>
<CardTitle>Inquiry Settings</CardTitle>
<CardDescription>Configure inquiry notification behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-4">
<div className="flex-1">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
</div>
<div className="flex items-center gap-2">
<Input
type="text"
className="w-64"
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
onChange={(e) =>
setValues((prev) => ({
...prev,
[setting.key]: e.target.value,
}))
}
/>
<Button
size="sm"
variant="outline"
disabled={saving === setting.key}
onClick={() =>
saveSetting(setting.key, values[setting.key] ?? setting.defaultValue)
}
>
<Save className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Numeric Settings */}
<Card>
<CardHeader>
<CardTitle>Business Rules</CardTitle>
<CardDescription>Configure financial and operational parameters</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-4">
<div className="flex-1">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24"
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
onChange={(e) =>
setValues((prev) => ({
...prev,
[setting.key]: parseFloat(e.target.value) || 0,
}))
}
/>
<Button
size="sm"
variant="outline"
disabled={saving === setting.key}
onClick={() =>
saveSetting(setting.key, values[setting.key] ?? setting.defaultValue)
}
>
<Save className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{/* JSON Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Configuration</CardTitle>
<CardDescription>
JSON-based settings for pipeline weights and berth rules
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{KNOWN_SETTINGS.filter((s) => s.type === 'json').map((setting) => {
const currentValue = getEffectiveValue(setting.key, setting.defaultValue);
const jsonStr =
values[`${setting.key}_edit`] !== undefined
? String(values[`${setting.key}_edit`])
: JSON.stringify(currentValue, null, 2);
return (
<div key={setting.key} className="space-y-2">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
<Textarea
className="font-mono text-xs"
rows={6}
value={jsonStr}
onChange={(e) =>
setValues((prev) => ({ ...prev, [`${setting.key}_edit`]: e.target.value }))
}
/>
<Button
size="sm"
disabled={saving === setting.key}
onClick={() => {
try {
const parsed = JSON.parse(
String(values[`${setting.key}_edit`] ?? JSON.stringify(currentValue)),
);
void saveSetting(setting.key, parsed);
} catch {
// invalid JSON — do nothing
}
}}
>
{saving === setting.key ? 'Saving...' : 'Save'}
</Button>
</div>
);
})}
</CardContent>
</Card>
{/* Custom Settings */}
<Card>
<CardHeader>
<CardTitle>Custom Settings</CardTitle>
<CardDescription>Additional key-value settings for this port</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{customSettings.map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-2">
<div>
<code className="text-sm font-mono">{setting.key}</code>
<p className="text-xs text-muted-foreground">
{typeof setting.value === 'object'
? JSON.stringify(setting.value)
: String(setting.value)}
</p>
</div>
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
}
title="Delete Setting"
description={`Delete "${setting.key}"? This may affect system behavior.`}
confirmLabel="Delete"
onConfirm={() => handleDeleteSetting(setting.key)}
/>
</div>
))}
<Separator />
<div className="flex gap-2">
<Input
placeholder="Key"
value={customKey}
onChange={(e) => setCustomKey(e.target.value)}
className="w-40"
/>
<Input
placeholder="Value (JSON or string)"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
className="flex-1"
/>
<Button variant="outline" onClick={handleAddCustom} disabled={!customKey.trim()}>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
interface Role {
id: string;
name: string;
}
interface UserFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: {
userId: string;
displayName: string;
email: string;
phone: string | null;
isActive: boolean;
role: { id: string; name: string };
} | null;
onSuccess: () => void;
}
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
const [roles, setRoles] = useState<Role[]>([]);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!user;
useEffect(() => {
if (open) {
void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
}
}, [open]);
useEffect(() => {
if (open) {
if (user) {
setName(user.displayName);
setEmail(user.email);
setDisplayName(user.displayName);
setPhone(user.phone ?? '');
setRoleId(user.role.id);
setIsActive(user.isActive);
setPassword('');
} else {
setName('');
setEmail('');
setDisplayName('');
setPhone('');
setRoleId('');
setIsActive(true);
setPassword('');
}
setError(null);
}
}, [open, user]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: {
displayName,
phone: phone || null,
roleId,
isActive,
},
});
} else {
await apiFetch('/api/v1/admin/users', {
method: 'POST',
body: {
name: name || displayName,
email,
password,
displayName,
phone: phone || undefined,
roleId,
},
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{!isEdit && (
<>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="user-display-name">Display Name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="John Smith"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<Input
id="user-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 555-0123"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account Active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create User'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { UserForm } from './user-form';
interface UserRow {
userId: string;
displayName: string;
email: string;
phone: string | null;
isActive: boolean;
isSuperAdmin: boolean;
lastLoginAt: string | null;
role: { id: string; name: string };
assignedAt: string;
}
export function UserList() {
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users');
setUsers(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchUsers();
}, [fetchUsers]);
function handleNewUser() {
setEditingUser(null);
setFormOpen(true);
}
function handleEditUser(user: UserRow) {
setEditingUser(user);
setFormOpen(true);
}
async function handleRemoveUser(userId: string) {
setDeletingId(userId);
try {
await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
await fetchUsers();
} finally {
setDeletingId(null);
}
}
const columns: ColumnDef<UserRow, unknown>[] = [
{
accessorKey: 'displayName',
header: 'Name',
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.displayName}</span>
<span className="text-xs text-muted-foreground">{row.original.email}</span>
</div>
),
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => <Badge variant="secondary">{row.original.role.name}</Badge>,
},
{
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) =>
row.original.isActive ? (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Active
</Badge>
) : (
<Badge variant="destructive">
<ShieldOff className="mr-1 h-3 w-3" />
Disabled
</Badge>
),
},
{
accessorKey: 'lastLoginAt',
header: 'Last Login',
cell: ({ row }) =>
row.original.lastLoginAt
? new Date(row.original.lastLoginAt).toLocaleDateString()
: 'Never',
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEditUser(row.original)}>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
</Button>
}
title="Remove User"
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
confirmLabel="Remove"
onConfirm={() => handleRemoveUser(row.original.userId)}
loading={deletingId === row.original.userId}
/>
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div>
<PageHeader
title="User Management"
description="Manage users and their roles for this port"
actions={
<Button onClick={handleNewUser}>
<Plus className="mr-1.5 h-4 w-4" />
New User
</Button>
}
/>
<DataTable
columns={columns}
data={users}
isLoading={loading}
getRowId={(row) => row.userId}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No users assigned to this port.</p>
<Button variant="link" onClick={handleNewUser} className="mt-2">
Add the first user
</Button>
</div>
}
/>
<UserForm
open={formOpen}
onOpenChange={setFormOpen}
user={editingUser}
onSuccess={fetchUsers}
/>
</div>
);
}

View File

@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
interface UserOption {
id: string;
displayName: string;
}
interface ReminderFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reminder?: {
id: string;
title: string;
note: string | null;
dueAt: string;
priority: string;
assignedTo: string | null;
clientId: string | null;
interestId: string | null;
berthId: string | null;
} | null;
// Pre-fill entity link when creating from entity detail pages
defaultClientId?: string;
defaultInterestId?: string;
defaultBerthId?: string;
onSuccess: () => void;
}
export function ReminderForm({
open,
onOpenChange,
reminder,
defaultClientId,
defaultInterestId,
defaultBerthId,
onSuccess,
}: ReminderFormProps) {
const [title, setTitle] = useState('');
const [note, setNote] = useState('');
const [dueAt, setDueAt] = useState('');
const [priority, setPriority] = useState('medium');
const [assignedTo, setAssignedTo] = useState('');
const [clientId, setClientId] = useState('');
const [interestId, setInterestId] = useState('');
const [berthId, setBerthId] = useState('');
const [users, setUsers] = useState<UserOption[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { can } = usePermissions();
const canAssignOthers = can('reminders', 'assign_others');
const isEdit = !!reminder;
useEffect(() => {
if (open && canAssignOthers) {
void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) =>
setUsers(res.data),
);
}
}, [open, canAssignOthers]);
useEffect(() => {
if (open) {
if (reminder) {
setTitle(reminder.title);
setNote(reminder.note ?? '');
setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format
setPriority(reminder.priority);
setAssignedTo(reminder.assignedTo ?? '');
setClientId(reminder.clientId ?? '');
setInterestId(reminder.interestId ?? '');
setBerthId(reminder.berthId ?? '');
} else {
setTitle('');
setNote('');
// Default to tomorrow 9 AM
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
setDueAt(tomorrow.toISOString().slice(0, 16));
setPriority('medium');
setAssignedTo('');
setClientId(defaultClientId ?? '');
setInterestId(defaultInterestId ?? '');
setBerthId(defaultBerthId ?? '');
}
setError(null);
}
}, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const body = {
title,
note: note || undefined,
dueAt: new Date(dueAt).toISOString(),
priority,
assignedTo: assignedTo || undefined,
clientId: clientId || undefined,
interestId: interestId || undefined,
berthId: berthId || undefined,
};
if (isEdit) {
await apiFetch(`/api/v1/reminders/${reminder.id}`, {
method: 'PATCH',
body,
});
} else {
await apiFetch('/api/v1/reminders', {
method: 'POST',
body,
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="reminder-title">Title</Label>
<Input
id="reminder-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Follow up with client..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-note">Note</Label>
<Textarea
id="reminder-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Additional details..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="reminder-due">Due Date & Time</Label>
<Input
id="reminder-due"
type="datetime-local"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-priority">Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger id="reminder-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{canAssignOthers && (
<div className="space-y-2">
<Label htmlFor="reminder-assign">Assign To</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger id="reminder-assign">
<SelectValue placeholder="Myself" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Myself</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">
Link to Entity (optional paste UUIDs, or leave blank)
</Label>
<div className="grid grid-cols-1 gap-2">
<Input
placeholder="Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className="text-xs"
/>
<Input
placeholder="Interest ID"
value={interestId}
onChange={(e) => setInterestId(e.target.value)}
className="text-xs"
/>
<Input
placeholder="Berth ID"
value={berthId}
onChange={(e) => setBerthId(e.target.value)}
className="text-xs"
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !title.trim() || !dueAt}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Reminder'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,328 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import { ReminderForm } from './reminder-form';
import { SnoozeDialog } from './snooze-dialog';
interface Reminder {
id: string;
title: string;
note: string | null;
dueAt: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
assignedTo: string | null;
createdBy: string;
clientId: string | null;
interestId: string | null;
berthId: string | null;
autoGenerated: boolean;
snoozedUntil: string | null;
completedAt: string | null;
createdAt: string;
client?: { id: string; fullName: string } | null;
interest?: { id: string; pipelineStage: string } | null;
berth?: { id: string; mooringNumber: string } | null;
}
const PRIORITY_CONFIG = {
urgent: { label: 'Urgent', className: 'bg-red-600 text-white' },
high: { label: 'High', className: 'bg-orange-500 text-white' },
medium: { label: 'Medium', className: 'bg-blue-500 text-white' },
low: { label: 'Low', className: 'bg-gray-400 text-white' },
} as const;
const STATUS_CONFIG = {
pending: { label: 'Pending', icon: Bell },
snoozed: { label: 'Snoozed', icon: Clock },
completed: { label: 'Completed', icon: CheckCircle2 },
dismissed: { label: 'Dismissed', icon: XCircle },
} as const;
export function ReminderList() {
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [snoozingId, setSnoozingId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
const [statusFilter, setStatusFilter] = useState<string>('active');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [total, setTotal] = useState(0);
const { can } = usePermissions();
const canViewAll = can('reminders', 'view_all');
const fetchReminders = useCallback(async () => {
setLoading(true);
try {
if (viewMode === 'my') {
const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
let filtered = res.data;
if (priorityFilter !== 'all') {
filtered = filtered.filter((r) => r.priority === priorityFilter);
}
setReminders(filtered);
setTotal(filtered.length);
} else {
const params = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
if (statusFilter === 'active') {
params.set('status', 'pending');
} else if (statusFilter !== 'all') {
params.set('status', statusFilter);
}
if (priorityFilter !== 'all') {
params.set('priority', priorityFilter);
}
const res = await apiFetch<{
data: Reminder[];
pagination: { total: number };
}>(`/api/v1/reminders?${params}`);
setReminders(res.data);
setTotal(res.pagination.total);
}
} finally {
setLoading(false);
}
}, [viewMode, statusFilter, priorityFilter]);
useEffect(() => {
void fetchReminders();
}, [fetchReminders]);
async function handleComplete(id: string) {
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
await fetchReminders();
}
async function handleDismiss(id: string) {
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
await fetchReminders();
}
function isOverdue(dueAt: string, status: string): boolean {
return (status === 'pending' || status === 'snoozed') && new Date(dueAt) < new Date();
}
const columns: ColumnDef<Reminder, unknown>[] = [
{
accessorKey: 'priority',
header: '',
cell: ({ row }) => {
const config = PRIORITY_CONFIG[row.original.priority];
return <Badge className={`${config.className} text-[10px] px-1.5`}>{config.label}</Badge>;
},
size: 70,
},
{
accessorKey: 'title',
header: 'Reminder',
cell: ({ row }) => {
const overdue = isOverdue(row.original.dueAt, row.original.status);
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.title}</span>
{row.original.autoGenerated && (
<Badge variant="outline" className="text-[10px]">
Auto
</Badge>
)}
{overdue && <AlertTriangle className="h-3.5 w-3.5 text-destructive" />}
</div>
{row.original.client && (
<span className="text-xs text-muted-foreground">
Client: {row.original.client.fullName}
</span>
)}
{row.original.berth && (
<span className="text-xs text-muted-foreground">
Berth: {row.original.berth.mooringNumber}
</span>
)}
</div>
);
},
},
{
accessorKey: 'dueAt',
header: 'Due',
cell: ({ row }) => {
const overdue = isOverdue(row.original.dueAt, row.original.status);
const date = new Date(row.original.dueAt);
return (
<div className={overdue ? 'text-destructive font-medium' : ''}>
<div className="text-sm">{date.toLocaleDateString()}</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
</div>
);
},
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const config = STATUS_CONFIG[row.original.status];
const Icon = config.icon;
return (
<div className="flex items-center gap-1.5 text-sm">
<Icon className="h-3.5 w-3.5" />
{config.label}
</div>
);
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
if (row.original.status === 'completed' || row.original.status === 'dismissed') {
return null;
}
return (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="text-green-600 hover:text-green-700"
onClick={() => handleComplete(row.original.id)}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
<Clock className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</div>
);
},
enableSorting: false,
size: 120,
},
];
return (
<div>
<PageHeader
title="Reminders"
description={`${total} reminder${total !== 1 ? 's' : ''}`}
actions={
<Button
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
}
/>
<div className="flex items-center gap-4 mb-4">
{canViewAll && (
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
<TabsList>
<TabsTrigger value="my">My Reminders</TabsTrigger>
<TabsTrigger value="all">All Reminders</TabsTrigger>
</TabsList>
</Tabs>
)}
{viewMode === 'all' && (
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="snoozed">Snoozed</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={reminders}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<Bell className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
<p className="text-muted-foreground">No reminders.</p>
<Button
variant="link"
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
className="mt-2"
>
Create your first reminder
</Button>
</div>
}
/>
<ReminderForm
open={formOpen}
onOpenChange={setFormOpen}
reminder={editingReminder}
onSuccess={fetchReminders}
/>
<SnoozeDialog
open={!!snoozingId}
onOpenChange={(open) => {
if (!open) setSnoozingId(null);
}}
reminderId={snoozingId}
onSuccess={fetchReminders}
/>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
interface SnoozeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reminderId: string | null;
onSuccess: () => void;
}
const PRESETS = [
{ label: '1 hour', hours: 1 },
{ label: '4 hours', hours: 4 },
{ label: 'Tomorrow 9 AM', hours: -1 }, // special case
{ label: 'Next week', hours: -2 }, // special case
] as const;
function getPresetDate(preset: (typeof PRESETS)[number]): Date {
const now = new Date();
if (preset.hours === -1) {
// Tomorrow 9 AM
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
return tomorrow;
}
if (preset.hours === -2) {
// Next Monday 9 AM
const next = new Date(now);
const daysUntilMonday = (8 - next.getDay()) % 7 || 7;
next.setDate(next.getDate() + daysUntilMonday);
next.setHours(9, 0, 0, 0);
return next;
}
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
}
export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: SnoozeDialogProps) {
const [customDate, setCustomDate] = useState('');
const [loading, setLoading] = useState(false);
async function handleSnooze(snoozeUntil: string) {
if (!reminderId) return;
setLoading(true);
try {
await apiFetch(`/api/v1/reminders/${reminderId}/snooze`, {
method: 'POST',
body: { snoozeUntil },
});
onSuccess();
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Snooze Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="grid grid-cols-2 gap-2">
{PRESETS.map((preset) => (
<Button
key={preset.label}
variant="outline"
className="justify-start"
disabled={loading}
onClick={() => handleSnooze(getPresetDate(preset).toISOString())}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-2 pt-2">
<Label htmlFor="custom-snooze">Custom date & time</Label>
<div className="flex gap-2">
<Input
id="custom-snooze"
type="datetime-local"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
className="flex-1"
/>
<Button
disabled={loading || !customDate}
onClick={() => handleSnooze(new Date(customDate).toISOString())}
>
Snooze
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import { useState, useEffect } from 'react';
import { Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
interface NotificationPrefs {
reminder_due: boolean;
reminder_overdue: boolean;
eoi_signed: boolean;
eoi_completed: boolean;
invoice_overdue: boolean;
duplicate_alert: boolean;
[key: string]: boolean;
}
export function UserSettings() {
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [timezone, setTimezone] = useState('');
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
void loadProfile();
void loadNotificationPrefs();
}, []);
async function loadProfile() {
const res = await apiFetch<{ data: { user?: { name: string } } }>('/api/v1/me', {
method: 'GET',
});
setDisplayName(res.data.user?.name ?? '');
}
async function loadNotificationPrefs() {
try {
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
setNotifPrefs(res.data);
} catch {
// Preferences may not exist yet
setNotifPrefs({
reminder_due: true,
reminder_overdue: true,
eoi_signed: true,
eoi_completed: true,
invoice_overdue: true,
duplicate_alert: true,
});
}
}
async function saveProfile() {
setSaving('profile');
setMessage(null);
try {
await apiFetch('/api/v1/me', {
method: 'PATCH',
body: {
displayName: displayName || undefined,
phone: phone || null,
preferences: { timezone: timezone || undefined },
},
});
setMessage('Profile saved');
} catch (err: unknown) {
setMessage(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(null);
}
}
async function toggleNotifPref(key: string, value: boolean) {
setSaving(key);
try {
await apiFetch('/api/v1/notifications/preferences', {
method: 'PATCH',
body: { [key]: value },
});
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
} finally {
setSaving(null);
}
}
const NOTIF_LABELS: Record<string, string> = {
reminder_due: 'Reminder due',
reminder_overdue: 'Reminder overdue',
eoi_signed: 'EOI signed by a party',
eoi_completed: 'EOI fully completed',
invoice_overdue: 'Invoice overdue',
duplicate_alert: 'Duplicate client detected',
};
return (
<div>
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
<div className="mt-6 space-y-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your display name and contact info</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="settings-name">Display Name</Label>
<Input
id="settings-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings-phone">Phone</Label>
<Input
id="settings-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 555-0123"
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings-tz">Timezone</Label>
<Input
id="settings-tz"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="America/Anguilla"
/>
</div>
<div className="flex items-center gap-3">
<Button onClick={saveProfile} disabled={saving === 'profile'}>
<Save className="mr-1.5 h-4 w-4" />
{saving === 'profile' ? 'Saving...' : 'Save Profile'}
</Button>
{message && <span className="text-sm text-muted-foreground">{message}</span>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Choose which notifications you receive</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{notifPrefs &&
Object.entries(NOTIF_LABELS).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label>{label}</Label>
<Switch
checked={notifPrefs[key] ?? true}
disabled={saving === key}
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
/>
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,956 @@
CREATE TABLE "berth_maintenance_log" (
"id" text PRIMARY KEY NOT NULL,
"berth_id" text NOT NULL,
"port_id" text NOT NULL,
"category" text NOT NULL,
"description" text NOT NULL,
"cost" numeric,
"cost_currency" text DEFAULT 'USD',
"responsible_party" text,
"performed_date" date NOT NULL,
"photo_file_ids" text[],
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "berth_map_data" (
"id" text PRIMARY KEY NOT NULL,
"berth_id" text NOT NULL,
"svg_path" text,
"x" numeric,
"y" numeric,
"transform" text,
"font_size" numeric,
"extra_data" jsonb DEFAULT '{}'::jsonb,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "berth_map_data_berth_id_unique" UNIQUE("berth_id")
);
--> statement-breakpoint
CREATE TABLE "berth_recommendations" (
"id" text PRIMARY KEY NOT NULL,
"interest_id" text NOT NULL,
"berth_id" text NOT NULL,
"match_score" numeric,
"match_reasons" jsonb,
"source" text DEFAULT 'ai' NOT NULL,
"created_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "berth_tags" (
"berth_id" text NOT NULL,
"tag_id" text NOT NULL,
CONSTRAINT "berth_tags_berth_id_tag_id_pk" PRIMARY KEY("berth_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "berth_waiting_list" (
"id" text PRIMARY KEY NOT NULL,
"berth_id" text NOT NULL,
"client_id" text NOT NULL,
"position" integer NOT NULL,
"priority" text DEFAULT 'normal' NOT NULL,
"notify_pref" text DEFAULT 'email',
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "berths" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"mooring_number" text NOT NULL,
"area" text,
"status" text DEFAULT 'available' NOT NULL,
"length_ft" numeric,
"width_ft" numeric,
"draft_ft" numeric,
"length_m" numeric,
"width_m" numeric,
"draft_m" numeric,
"width_is_minimum" boolean DEFAULT false,
"nominal_boat_size" text,
"nominal_boat_size_m" text,
"water_depth" numeric,
"water_depth_m" numeric,
"water_depth_is_minimum" boolean DEFAULT false,
"side_pontoon" text,
"power_capacity" text,
"voltage" text,
"mooring_type" text,
"cleat_type" text,
"cleat_capacity" text,
"bollard_type" text,
"bollard_capacity" text,
"access" text,
"price" numeric,
"price_currency" text DEFAULT 'USD' NOT NULL,
"bow_facing" text,
"berth_approved" boolean DEFAULT false,
"tenure_type" text DEFAULT 'permanent' NOT NULL,
"tenure_years" integer,
"tenure_start_date" date,
"tenure_end_date" date,
"status_last_changed_by" text,
"status_last_changed_reason" text,
"status_last_modified" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_addresses" (
"id" text PRIMARY KEY NOT NULL,
"client_id" text NOT NULL,
"port_id" text NOT NULL,
"label" text DEFAULT 'Primary' NOT NULL,
"street_address" text,
"city" text,
"state_province" text,
"postal_code" text,
"country" text,
"is_primary" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_contacts" (
"id" text PRIMARY KEY NOT NULL,
"client_id" text NOT NULL,
"channel" text NOT NULL,
"value" text NOT NULL,
"label" text,
"is_primary" boolean DEFAULT false NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_merge_log" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"surviving_client_id" text NOT NULL,
"merged_client_id" text NOT NULL,
"merged_by" text NOT NULL,
"merge_details" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_notes" (
"id" text PRIMARY KEY NOT NULL,
"client_id" text NOT NULL,
"author_id" text NOT NULL,
"content" text NOT NULL,
"mentions" text[],
"is_locked" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_relationships" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"client_a_id" text NOT NULL,
"client_b_id" text NOT NULL,
"relationship_type" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_tags" (
"client_id" text NOT NULL,
"tag_id" text NOT NULL,
CONSTRAINT "client_tags_client_id_tag_id_pk" PRIMARY KEY("client_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "clients" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"full_name" text NOT NULL,
"company_name" text,
"nationality" text,
"is_proxy" boolean DEFAULT false NOT NULL,
"proxy_type" text,
"actual_owner_name" text,
"relationship_notes" text,
"yacht_name" text,
"yacht_length_ft" numeric,
"yacht_width_ft" numeric,
"yacht_draft_ft" numeric,
"yacht_length_m" numeric,
"yacht_width_m" numeric,
"yacht_draft_m" numeric,
"berth_size_desired" text,
"preferred_contact_method" text,
"preferred_language" text,
"timezone" text,
"source" text,
"source_details" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "document_events" (
"id" text PRIMARY KEY NOT NULL,
"document_id" text NOT NULL,
"event_type" text NOT NULL,
"signer_id" text,
"event_data" jsonb DEFAULT '{}'::jsonb,
"signature_hash" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "document_signers" (
"id" text PRIMARY KEY NOT NULL,
"document_id" text NOT NULL,
"signer_name" text NOT NULL,
"signer_email" text NOT NULL,
"signer_role" text NOT NULL,
"signing_order" integer NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"signed_at" timestamp with time zone,
"signing_url" text,
"embedded_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "document_templates" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"template_type" text NOT NULL,
"body_html" text NOT NULL,
"merge_fields" jsonb DEFAULT '[]'::jsonb NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"interest_id" text,
"client_id" text,
"document_type" text NOT NULL,
"title" text NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"documenso_id" text,
"file_id" text,
"signed_file_id" text,
"is_manual_upload" boolean DEFAULT false NOT NULL,
"notes" text,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "files" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"client_id" text,
"filename" text NOT NULL,
"original_name" text NOT NULL,
"mime_type" text,
"size_bytes" text,
"storage_path" text NOT NULL,
"storage_bucket" text DEFAULT 'crm-files' NOT NULL,
"category" text,
"uploaded_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "form_submissions" (
"id" text PRIMARY KEY NOT NULL,
"form_template_id" text NOT NULL,
"client_id" text,
"interest_id" text,
"token" text NOT NULL,
"prefilled_data" jsonb DEFAULT '{}'::jsonb,
"submitted_data" jsonb,
"status" text DEFAULT 'pending' NOT NULL,
"expires_at" timestamp with time zone,
"submitted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "form_submissions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "form_templates" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"fields" jsonb NOT NULL,
"branding" jsonb DEFAULT '{}'::jsonb,
"is_active" boolean DEFAULT true NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_accounts" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"port_id" text NOT NULL,
"provider" text NOT NULL,
"email_address" text NOT NULL,
"smtp_host" text NOT NULL,
"smtp_port" integer NOT NULL,
"imap_host" text NOT NULL,
"imap_port" integer NOT NULL,
"credentials_enc" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_sync_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_messages" (
"id" text PRIMARY KEY NOT NULL,
"thread_id" text NOT NULL,
"message_id_header" text,
"from_address" text NOT NULL,
"to_addresses" text[] NOT NULL,
"cc_addresses" text[],
"subject" text,
"body_text" text,
"body_html" text,
"direction" text NOT NULL,
"sent_at" timestamp with time zone NOT NULL,
"attachment_file_ids" text[],
"raw_file_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_threads" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"client_id" text,
"subject" text,
"last_message_at" timestamp with time zone,
"message_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "expenses" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"establishment_name" text,
"amount" numeric NOT NULL,
"currency" text DEFAULT 'USD' NOT NULL,
"amount_usd" numeric,
"exchange_rate" numeric,
"payment_method" text,
"category" text,
"payer" text,
"expense_date" timestamp with time zone NOT NULL,
"description" text,
"receipt_file_ids" text[],
"payment_status" text DEFAULT 'unpaid',
"payment_date" date,
"payment_reference" text,
"payment_notes" text,
"created_by" text NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_expenses" (
"invoice_id" text NOT NULL,
"expense_id" text NOT NULL,
CONSTRAINT "invoice_expenses_invoice_id_expense_id_pk" PRIMARY KEY("invoice_id","expense_id")
);
--> statement-breakpoint
CREATE TABLE "invoice_line_items" (
"id" text PRIMARY KEY NOT NULL,
"invoice_id" text NOT NULL,
"description" text NOT NULL,
"quantity" numeric DEFAULT '1' NOT NULL,
"unit_price" numeric NOT NULL,
"total" numeric NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoices" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"invoice_number" text NOT NULL,
"client_name" text NOT NULL,
"billing_email" text,
"billing_address" text,
"due_date" date NOT NULL,
"payment_terms" text DEFAULT 'net30' NOT NULL,
"currency" text DEFAULT 'USD' NOT NULL,
"subtotal" numeric NOT NULL,
"discount_pct" numeric DEFAULT '0',
"discount_amount" numeric DEFAULT '0',
"fee_pct" numeric DEFAULT '0',
"fee_amount" numeric DEFAULT '0',
"total" numeric NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"payment_status" text DEFAULT 'unpaid',
"payment_date" date,
"payment_method" text,
"payment_reference" text,
"pdf_file_id" text,
"notes" text,
"created_by" text NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ports" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"logo_url" text,
"primary_color" text,
"default_currency" text DEFAULT 'USD' NOT NULL,
"timezone" text DEFAULT 'America/Anguilla' NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "port_role_overrides" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"role_id" text NOT NULL,
"permission_overrides" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "roles" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"permissions" jsonb DEFAULT '{}'::jsonb NOT NULL,
"is_global" boolean DEFAULT true NOT NULL,
"is_system" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"token" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "user_port_roles" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"port_id" text NOT NULL,
"role_id" text NOT NULL,
"assigned_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_profiles" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"display_name" text NOT NULL,
"avatar_url" text,
"phone" text,
"is_super_admin" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_login_at" timestamp with time zone,
"preferences" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_profiles_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "interest_notes" (
"id" text PRIMARY KEY NOT NULL,
"interest_id" text NOT NULL,
"author_id" text NOT NULL,
"content" text NOT NULL,
"mentions" text[],
"is_locked" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "interest_tags" (
"interest_id" text NOT NULL,
"tag_id" text NOT NULL,
CONSTRAINT "interest_tags_interest_id_tag_id_pk" PRIMARY KEY("interest_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "interests" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"client_id" text NOT NULL,
"berth_id" text,
"pipeline_stage" text DEFAULT 'open' NOT NULL,
"lead_category" text,
"source" text,
"eoi_status" text,
"documenso_id" text,
"contract_status" text,
"deposit_status" text,
"reservation_status" text,
"date_first_contact" timestamp with time zone,
"date_last_contact" timestamp with time zone,
"date_eoi_sent" timestamp with time zone,
"date_eoi_signed" timestamp with time zone,
"date_contract_sent" timestamp with time zone,
"date_contract_signed" timestamp with time zone,
"date_deposit_received" timestamp with time zone,
"reminder_enabled" boolean DEFAULT false NOT NULL,
"reminder_days" integer,
"reminder_last_fired" timestamp with time zone,
"notes" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "generated_reports" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"scheduled_report_id" text,
"report_type" text NOT NULL,
"name" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"parameters" jsonb DEFAULT '{}'::jsonb,
"file_id" text,
"error_message" text,
"requested_by" text NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "google_calendar_cache" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"event_id" text NOT NULL,
"title" text NOT NULL,
"start_at" timestamp with time zone NOT NULL,
"end_at" timestamp with time zone,
"location" text,
"description" text,
"is_crm_pushed" boolean DEFAULT false NOT NULL,
"reminder_id" text,
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "google_calendar_tokens" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"token_expiry" timestamp with time zone NOT NULL,
"calendar_id" text DEFAULT 'primary' NOT NULL,
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_sync_at" timestamp with time zone,
"sync_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "google_calendar_tokens_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "notifications" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"description" text,
"link" text,
"entity_type" text,
"entity_id" text,
"is_read" boolean DEFAULT false NOT NULL,
"email_sent" boolean DEFAULT false NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reminders" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"title" text NOT NULL,
"note" text,
"due_at" timestamp with time zone NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"assigned_to" text,
"created_by" text NOT NULL,
"client_id" text,
"interest_id" text,
"berth_id" text,
"auto_generated" boolean DEFAULT false NOT NULL,
"google_calendar_event_id" text,
"google_calendar_synced" boolean DEFAULT false NOT NULL,
"snoozed_until" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "report_recipients" (
"id" text PRIMARY KEY NOT NULL,
"report_id" text NOT NULL,
"email" text NOT NULL,
"user_id" text
);
--> statement-breakpoint
CREATE TABLE "scheduled_reports" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"name" text NOT NULL,
"report_type" text NOT NULL,
"schedule" text NOT NULL,
"last_run_at" timestamp with time zone,
"next_run_at" timestamp with time zone,
"is_active" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text,
"user_id" text,
"action" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text,
"field_changed" text,
"old_value" jsonb,
"new_value" jsonb,
"ip_address" text,
"user_agent" text,
"reverted_by" text,
"reverted_at" timestamp with time zone,
"revert_of" text,
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "currency_rates" (
"id" text PRIMARY KEY NOT NULL,
"base_currency" text NOT NULL,
"target_currency" text NOT NULL,
"rate" numeric NOT NULL,
"source" text DEFAULT 'frankfurter' NOT NULL,
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "custom_field_definitions" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"entity_type" text NOT NULL,
"field_name" text NOT NULL,
"field_label" text NOT NULL,
"field_type" text NOT NULL,
"select_options" jsonb,
"is_required" boolean DEFAULT false NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "custom_field_values" (
"id" text PRIMARY KEY NOT NULL,
"field_id" text NOT NULL,
"entity_id" text NOT NULL,
"value" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "saved_views" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"user_id" text NOT NULL,
"entity_type" text NOT NULL,
"name" text NOT NULL,
"filters" jsonb NOT NULL,
"sort_config" jsonb,
"column_config" jsonb,
"is_shared" boolean DEFAULT false NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "scratchpad_notes" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"content" text NOT NULL,
"linked_client_id" text,
"linked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "system_settings" (
"key" text NOT NULL,
"value" jsonb NOT NULL,
"port_id" text,
"updated_by" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"name" text NOT NULL,
"color" text DEFAULT '#6B7280' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_notification_preferences" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"port_id" text NOT NULL,
"notification_type" text NOT NULL,
"in_app" boolean DEFAULT true NOT NULL,
"email" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhook_deliveries" (
"id" text PRIMARY KEY NOT NULL,
"webhook_id" text NOT NULL,
"event_type" text NOT NULL,
"payload" jsonb NOT NULL,
"response_status" integer,
"response_body" text,
"attempt" integer DEFAULT 1 NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"delivered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhooks" (
"id" text PRIMARY KEY NOT NULL,
"port_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"secret" text,
"events" text[] NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "berth_maintenance_log" ADD CONSTRAINT "berth_maintenance_log_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_maintenance_log" ADD CONSTRAINT "berth_maintenance_log_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_map_data" ADD CONSTRAINT "berth_map_data_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_recommendations" ADD CONSTRAINT "berth_recommendations_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_tags" ADD CONSTRAINT "berth_tags_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_waiting_list" ADD CONSTRAINT "berth_waiting_list_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berth_waiting_list" ADD CONSTRAINT "berth_waiting_list_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "berths" ADD CONSTRAINT "berths_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_addresses" ADD CONSTRAINT "client_addresses_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_addresses" ADD CONSTRAINT "client_addresses_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_contacts" ADD CONSTRAINT "client_contacts_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_merge_log" ADD CONSTRAINT "client_merge_log_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_merge_log" ADD CONSTRAINT "client_merge_log_surviving_client_id_clients_id_fk" FOREIGN KEY ("surviving_client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_notes" ADD CONSTRAINT "client_notes_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_client_a_id_clients_id_fk" FOREIGN KEY ("client_a_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_client_b_id_clients_id_fk" FOREIGN KEY ("client_b_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_tags" ADD CONSTRAINT "client_tags_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "clients" ADD CONSTRAINT "clients_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_events" ADD CONSTRAINT "document_events_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_events" ADD CONSTRAINT "document_events_signer_id_document_signers_id_fk" FOREIGN KEY ("signer_id") REFERENCES "public"."document_signers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_signers" ADD CONSTRAINT "document_signers_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_signed_file_id_files_id_fk" FOREIGN KEY ("signed_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "files" ADD CONSTRAINT "files_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "files" ADD CONSTRAINT "files_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_form_template_id_form_templates_id_fk" FOREIGN KEY ("form_template_id") REFERENCES "public"."form_templates"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "form_templates" ADD CONSTRAINT "form_templates_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_thread_id_email_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."email_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_raw_file_id_files_id_fk" FOREIGN KEY ("raw_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_threads" ADD CONSTRAINT "email_threads_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_threads" ADD CONSTRAINT "email_threads_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_expenses" ADD CONSTRAINT "invoice_expenses_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_expenses" ADD CONSTRAINT "invoice_expenses_expense_id_expenses_id_fk" FOREIGN KEY ("expense_id") REFERENCES "public"."expenses"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_pdf_file_id_files_id_fk" FOREIGN KEY ("pdf_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "port_role_overrides" ADD CONSTRAINT "port_role_overrides_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "port_role_overrides" ADD CONSTRAINT "port_role_overrides_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_port_roles" ADD CONSTRAINT "user_port_roles_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_port_roles" ADD CONSTRAINT "user_port_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interest_notes" ADD CONSTRAINT "interest_notes_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interest_tags" ADD CONSTRAINT "interest_tags_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interests" ADD CONSTRAINT "interests_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interests" ADD CONSTRAINT "interests_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_scheduled_report_id_scheduled_reports_id_fk" FOREIGN KEY ("scheduled_report_id") REFERENCES "public"."scheduled_reports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "google_calendar_cache" ADD CONSTRAINT "google_calendar_cache_reminder_id_reminders_id_fk" FOREIGN KEY ("reminder_id") REFERENCES "public"."reminders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "report_recipients" ADD CONSTRAINT "report_recipients_report_id_scheduled_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."scheduled_reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scheduled_reports" ADD CONSTRAINT "scheduled_reports_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_revert_of_audit_logs_id_fk" FOREIGN KEY ("revert_of") REFERENCES "public"."audit_logs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_field_definitions" ADD CONSTRAINT "custom_field_definitions_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_field_id_custom_field_definitions_id_fk" FOREIGN KEY ("field_id") REFERENCES "public"."custom_field_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scratchpad_notes" ADD CONSTRAINT "scratchpad_notes_linked_client_id_clients_id_fk" FOREIGN KEY ("linked_client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "system_settings" ADD CONSTRAINT "system_settings_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tags" ADD CONSTRAINT "tags_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_notification_preferences" ADD CONSTRAINT "user_notification_preferences_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_webhooks_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_bml_berth" ON "berth_maintenance_log" USING btree ("berth_id");--> statement-breakpoint
CREATE INDEX "idx_bml_port" ON "berth_maintenance_log" USING btree ("port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "berth_map_data_berth_id_idx" ON "berth_map_data" USING btree ("berth_id");--> statement-breakpoint
CREATE UNIQUE INDEX "berth_rec_interest_berth_idx" ON "berth_recommendations" USING btree ("interest_id","berth_id");--> statement-breakpoint
CREATE INDEX "idx_br_interest" ON "berth_recommendations" USING btree ("interest_id");--> statement-breakpoint
CREATE UNIQUE INDEX "berth_waiting_list_berth_client_idx" ON "berth_waiting_list" USING btree ("berth_id","client_id");--> statement-breakpoint
CREATE INDEX "idx_bwl_berth" ON "berth_waiting_list" USING btree ("berth_id","position");--> statement-breakpoint
CREATE INDEX "idx_berths_port" ON "berths" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_berths_status" ON "berths" USING btree ("port_id","status");--> statement-breakpoint
CREATE INDEX "idx_berths_area" ON "berths" USING btree ("port_id","area");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_berths_mooring" ON "berths" USING btree ("port_id","mooring_number");--> statement-breakpoint
CREATE INDEX "idx_ca_client" ON "client_addresses" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_ca_port" ON "client_addresses" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_cc_client" ON "client_contacts" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_cc_email" ON "client_contacts" USING btree ("channel","value") WHERE "client_contacts"."channel" = 'email';--> statement-breakpoint
CREATE INDEX "idx_cc_phone" ON "client_contacts" USING btree ("channel","value") WHERE "client_contacts"."channel" = 'phone';--> statement-breakpoint
CREATE INDEX "idx_cml_port" ON "client_merge_log" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_cn_client" ON "client_notes" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_cr_port" ON "client_relationships" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_clients_port" ON "clients" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_clients_name" ON "clients" USING btree ("port_id","full_name");--> statement-breakpoint
CREATE INDEX "idx_clients_archived" ON "clients" USING btree ("port_id","archived_at");--> statement-breakpoint
CREATE INDEX "idx_de_doc" ON "document_events" USING btree ("document_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_de_dedup" ON "document_events" USING btree ("document_id","signature_hash") WHERE "document_events"."signature_hash" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_ds_doc" ON "document_signers" USING btree ("document_id");--> statement-breakpoint
CREATE INDEX "idx_dt_port" ON "document_templates" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_dt_type" ON "document_templates" USING btree ("port_id","template_type");--> statement-breakpoint
CREATE INDEX "idx_docs_port" ON "documents" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_docs_interest" ON "documents" USING btree ("interest_id");--> statement-breakpoint
CREATE INDEX "idx_docs_client" ON "documents" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_docs_type" ON "documents" USING btree ("port_id","document_type");--> statement-breakpoint
CREATE INDEX "idx_files_port" ON "files" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_files_client" ON "files" USING btree ("client_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_fs_token" ON "form_submissions" USING btree ("token");--> statement-breakpoint
CREATE INDEX "idx_ft_port" ON "form_templates" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_ea_user" ON "email_accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_ea_port" ON "email_accounts" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_em_thread" ON "email_messages" USING btree ("thread_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_em_message_id" ON "email_messages" USING btree ("message_id_header") WHERE "email_messages"."message_id_header" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_et_client" ON "email_threads" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_et_port" ON "email_threads" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_expenses_port" ON "expenses" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_expenses_date" ON "expenses" USING btree ("port_id","expense_date");--> statement-breakpoint
CREATE INDEX "idx_expenses_category" ON "expenses" USING btree ("port_id","category");--> statement-breakpoint
CREATE INDEX "idx_ili_invoice" ON "invoice_line_items" USING btree ("invoice_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_invoices_number" ON "invoices" USING btree ("port_id","invoice_number");--> statement-breakpoint
CREATE INDEX "idx_invoices_port" ON "invoices" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_invoices_status" ON "invoices" USING btree ("port_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "ports_slug_idx" ON "ports" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "port_role_overrides_port_role_idx" ON "port_role_overrides" USING btree ("port_id","role_id");--> statement-breakpoint
CREATE INDEX "port_role_overrides_port_idx" ON "port_role_overrides" USING btree ("port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sessions_token_idx" ON "session" USING btree ("token");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_port_roles_user_port_role_idx" ON "user_port_roles" USING btree ("user_id","port_id","role_id");--> statement-breakpoint
CREATE INDEX "idx_upr_user" ON "user_port_roles" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_upr_port" ON "user_port_roles" USING btree ("port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_profiles_user_id_idx" ON "user_profiles" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_in_interest" ON "interest_notes" USING btree ("interest_id");--> statement-breakpoint
CREATE INDEX "idx_interests_port" ON "interests" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_interests_client" ON "interests" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_interests_berth" ON "interests" USING btree ("berth_id");--> statement-breakpoint
CREATE INDEX "idx_interests_stage" ON "interests" USING btree ("port_id","pipeline_stage");--> statement-breakpoint
CREATE INDEX "idx_interests_archived" ON "interests" USING btree ("port_id","archived_at");--> statement-breakpoint
CREATE INDEX "idx_gr_port_created" ON "generated_reports" USING btree ("port_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_gr_port_status" ON "generated_reports" USING btree ("port_id","status");--> statement-breakpoint
CREATE INDEX "idx_gr_scheduled" ON "generated_reports" USING btree ("scheduled_report_id") WHERE "generated_reports"."scheduled_report_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "gcal_cache_user_event_idx" ON "google_calendar_cache" USING btree ("user_id","event_id");--> statement-breakpoint
CREATE INDEX "idx_gcal_cache_user" ON "google_calendar_cache" USING btree ("user_id","start_at");--> statement-breakpoint
CREATE UNIQUE INDEX "gcal_tokens_user_id_idx" ON "google_calendar_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_notif_user" ON "notifications" USING btree ("user_id","is_read");--> statement-breakpoint
CREATE INDEX "idx_notif_port" ON "notifications" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_notifications_user_type" ON "notifications" USING btree ("user_id","type","created_at");--> statement-breakpoint
CREATE INDEX "idx_reminders_port" ON "reminders" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_reminders_assigned" ON "reminders" USING btree ("assigned_to","status");--> statement-breakpoint
CREATE INDEX "idx_reminders_due" ON "reminders" USING btree ("port_id","due_at") WHERE "reminders"."status" IN ('pending', 'snoozed');--> statement-breakpoint
CREATE UNIQUE INDEX "report_recipients_report_email_idx" ON "report_recipients" USING btree ("report_id","email");--> statement-breakpoint
CREATE INDEX "idx_rr_report" ON "report_recipients" USING btree ("report_id");--> statement-breakpoint
CREATE INDEX "idx_sr_port" ON "scheduled_reports" USING btree ("port_id");--> statement-breakpoint
CREATE INDEX "idx_al_port" ON "audit_logs" USING btree ("port_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_al_entity" ON "audit_logs" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "idx_al_user" ON "audit_logs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_al_created" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "currency_rates_base_target_idx" ON "currency_rates" USING btree ("base_currency","target_currency");--> statement-breakpoint
CREATE UNIQUE INDEX "cfd_port_entity_name_idx" ON "custom_field_definitions" USING btree ("port_id","entity_type","field_name");--> statement-breakpoint
CREATE INDEX "idx_cfd_port" ON "custom_field_definitions" USING btree ("port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "cfv_field_entity_idx" ON "custom_field_values" USING btree ("field_id","entity_id");--> statement-breakpoint
CREATE INDEX "idx_cfv_entity" ON "custom_field_values" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "idx_sv_user" ON "saved_views" USING btree ("user_id","entity_type");--> statement-breakpoint
CREATE INDEX "idx_sp_user" ON "scratchpad_notes" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "system_settings_key_port_idx" ON "system_settings" USING btree ("key","port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "tags_port_name_idx" ON "tags" USING btree ("port_id","name");--> statement-breakpoint
CREATE INDEX "idx_tags_port" ON "tags" USING btree ("port_id");--> statement-breakpoint
CREATE UNIQUE INDEX "unp_user_port_type_idx" ON "user_notification_preferences" USING btree ("user_id","port_id","notification_type");--> statement-breakpoint
CREATE INDEX "idx_wd_webhook" ON "webhook_deliveries" USING btree ("webhook_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_webhooks_port" ON "webhooks" USING btree ("port_id");

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX "idx_ca_primary" ON "client_addresses" USING btree ("client_id") WHERE "client_addresses"."is_primary" = true;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1776185027494,
"tag": "0000_narrow_longshot",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1776185487775,
"tag": "0001_soft_ender_wiggin",
"breakpoints": true
}
]
}

View File

@@ -6,6 +6,7 @@ import {
numeric,
jsonb,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
@@ -14,7 +15,9 @@ import { ports } from './ports';
export const clients = pgTable(
'clients',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
@@ -52,7 +55,9 @@ export const clients = pgTable(
export const clientContacts = pgTable(
'client_contacts',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
@@ -66,15 +71,21 @@ export const clientContacts = pgTable(
},
(table) => [
index('idx_cc_client').on(table.clientId),
index('idx_cc_email').on(table.channel, table.value).where(sql`${table.channel} = 'email'`),
index('idx_cc_phone').on(table.channel, table.value).where(sql`${table.channel} = 'phone'`),
index('idx_cc_email')
.on(table.channel, table.value)
.where(sql`${table.channel} = 'email'`),
index('idx_cc_phone')
.on(table.channel, table.value)
.where(sql`${table.channel} = 'phone'`),
],
);
export const clientRelationships = pgTable(
'client_relationships',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
@@ -94,7 +105,9 @@ export const clientRelationships = pgTable(
export const clientNotes = pgTable(
'client_notes',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
@@ -122,7 +135,9 @@ export const clientTags = pgTable(
export const clientMergeLog = pgTable(
'client_merge_log',
{
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
@@ -137,6 +152,37 @@ export const clientMergeLog = pgTable(
(table) => [index('idx_cml_port').on(table.portId)],
);
export const clientAddresses = pgTable(
'client_addresses',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
label: text('label').notNull().default('Primary'),
streetAddress: text('street_address'),
city: text('city'),
stateProvince: text('state_province'),
postalCode: text('postal_code'),
country: text('country'),
isPrimary: boolean('is_primary').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_ca_client').on(table.clientId),
index('idx_ca_port').on(table.portId),
uniqueIndex('idx_ca_primary')
.on(table.clientId)
.where(sql`${table.isPrimary} = true`),
],
);
export type Client = typeof clients.$inferSelect;
export type NewClient = typeof clients.$inferInsert;
export type ClientContact = typeof clientContacts.$inferSelect;
@@ -147,3 +193,5 @@ export type ClientNote = typeof clientNotes.$inferSelect;
export type NewClientNote = typeof clientNotes.$inferInsert;
export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
export type ClientAddress = typeof clientAddresses.$inferSelect;
export type NewClientAddress = typeof clientAddresses.$inferInsert;

View File

@@ -14,6 +14,7 @@ import {
clientNotes,
clientTags,
clientMergeLog,
clientAddresses,
} from './clients';
// Interests
@@ -100,6 +101,7 @@ export const portsRelations = relations(ports, ({ many }) => ({
berthMaintenanceLogs: many(berthMaintenanceLog),
clientMergeLogs: many(clientMergeLog),
clientRelationships: many(clientRelationships),
clientAddresses: many(clientAddresses),
}));
// ─── Users ────────────────────────────────────────────────────────────────────
@@ -156,6 +158,7 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
waitingListEntries: many(berthWaitingList),
scratchpadNotes: many(scratchpadNotes),
formSubmissions: many(formSubmissions),
addresses: many(clientAddresses),
}));
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
@@ -165,6 +168,17 @@ export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
}),
}));
export const clientAddressesRelations = relations(clientAddresses, ({ one }) => ({
client: one(clients, {
fields: [clientAddresses.clientId],
references: [clients.id],
}),
port: one(ports, {
fields: [clientAddresses.portId],
references: [ports.id],
}),
}));
export const clientRelationshipsRelations = relations(clientRelationships, ({ one }) => ({
port: one(ports, {
fields: [clientRelationships.portId],

View File

@@ -38,6 +38,7 @@ export async function sendEmail(
subject: string,
html: string,
from?: string,
text?: string,
): Promise<nodemailer.SentMessageInfo> {
const transporter = createTransporter();
@@ -46,12 +47,10 @@ export async function sendEmail(
to: Array.isArray(to) ? to.join(', ') : to,
subject,
html,
...(text ? { text } : {}),
});
logger.debug(
{ messageId: info.messageId, to, subject },
'Email sent',
);
logger.debug({ messageId: info.messageId, to, subject }, 'Email sent');
return info;
}

View File

@@ -0,0 +1,82 @@
export interface InquiryClientConfirmationData {
firstName: string;
mooringNumber: string | null;
contactEmail: string;
}
export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
const { firstName, mooringNumber, contactEmail } = data;
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Port Nimara Berth';
const subject = mooringNumber
? `Thank You for Your Interest in Berth ${mooringNumber}`
: 'Thank You for Your Interest in a Port Nimara Berth';
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${subject}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
<p style="margin-bottom:10px; font-size:16px;">
Thank you for expressing interest in ${escapeHtml(berthText)}.
Our team has registered your interest, and we will reach out to you very shortly
by your preferred method of contact with more information.
</p>
<p style="margin-bottom:10px; font-size:16px;">
If you have any questions, please feel free to reach out to us at
<a href="mailto:${escapeHtml(contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
</p>
<p style="font-size:16px;">
Best regards,<br />
The Port Nimara Sales Team
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [
`Dear ${firstName},`,
'',
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
'',
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
'',
'Best regards,',
'The Port Nimara Sales Team',
].join('\n');
return { subject, html, text };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,80 @@
export interface InquirySalesNotificationData {
fullName: string;
email: string;
phone: string;
mooringNumber: string | null;
crmUrl: string;
}
export function inquirySalesNotification(data: InquirySalesNotificationData) {
const { fullName, email, phone, mooringNumber, crmUrl } = data;
const mooringDisplay = mooringNumber || 'None';
const subject = 'New Interest - Port Nimara';
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>New Interest - Port Nimara</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>Port Nimara</strong>. Here are their details:</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</p>
<p style="font-size:16px;">Thank you,<br/>Port Nimara CRM</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [
'Dear Administrator,',
'',
`${fullName} has expressed their interest in Port Nimara. Here are their details:`,
'',
`Name: ${fullName}`,
`Email: ${email}`,
`Telephone: ${phone}`,
`Berths Selected: ${mooringDisplay}`,
'',
`Please visit the Port Nimara CRM (${crmUrl}) to view more information.`,
'',
'Thank you',
'Port Nimara CRM',
].join('\n');
return { subject, html, text };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -52,6 +52,10 @@ const envSchema = z.object({
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
if (process.env.SKIP_ENV_VALIDATION === '1') {
return process.env as unknown as Env;
}
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');

View File

@@ -15,6 +15,42 @@ export const emailWorker = new Worker(
await syncInbox(accountId);
break;
}
case 'send-inquiry-confirmation': {
const { to, firstName, mooringNumber, contactEmail } = job.data as {
to: string;
firstName: string;
mooringNumber: string | null;
contactEmail: string;
};
const { inquiryClientConfirmation } =
await import('@/lib/email/templates/inquiry-client-confirmation');
const { sendEmail } = await import('@/lib/email/index');
const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail });
await sendEmail(to, email.subject, email.html, undefined, email.text);
break;
}
case 'send-inquiry-sales-notification': {
const { to, fullName, email, phone, mooringNumber, crmUrl } = job.data as {
to: string;
fullName: string;
email: string;
phone: string;
mooringNumber: string | null;
crmUrl: string;
};
const { inquirySalesNotification } =
await import('@/lib/email/templates/inquiry-sales-notification');
const { sendEmail } = await import('@/lib/email/index');
const notification = inquirySalesNotification({
fullName,
email,
phone,
mooringNumber,
crmUrl,
});
await sendEmail(to, notification.subject, notification.html, undefined, notification.text);
break;
}
default:
logger.warn({ jobName: job.name }, 'Unknown email job');
}

View File

@@ -24,10 +24,17 @@ export const notificationsWorker = new Worker(
break;
}
case 'reminder-check': {
const { processDocumentReminders } = await import(
'@/jobs/processors/document-reminder'
);
// Document signing reminders (EOI)
const { processDocumentReminders } = await import('@/jobs/processors/document-reminder');
await processDocumentReminders();
// CRM follow-up reminders (BR-060)
const { processFollowUpReminders } = await import('@/lib/services/reminders.service');
await processFollowUpReminders();
break;
}
case 'reminder-overdue-check': {
const { processOverdueReminders } = await import('@/lib/services/reminders.service');
await processOverdueReminders();
break;
}
case 'send-notification-email': {
@@ -57,9 +64,7 @@ export const notificationsWorker = new Worker(
authUser.email,
`[Port Nimara] ${notif.title}`,
`<p>${notif.description ?? notif.title}</p>${
notif.link
? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>`
: ''
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
}`,
);

View File

@@ -0,0 +1,57 @@
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
interface AuditListQuery {
page: number;
limit: number;
entityType?: string;
action?: string;
userId?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
}
export async function listAuditLogs(portId: string, query: AuditListQuery) {
const conditions = [eq(auditLogs.portId, portId)];
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
if (query.action) conditions.push(eq(auditLogs.action, query.action));
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
if (query.search) {
conditions.push(
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
);
}
const offset = (query.page - 1) * query.limit;
const [data, countResult] = await Promise.all([
db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt))
.limit(query.limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLogs)
.where(and(...conditions)),
]);
return {
data,
pagination: {
page: query.page,
limit: query.limit,
total: Number(countResult[0]?.count ?? 0),
},
};
}

View File

@@ -1,19 +1,16 @@
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
berths,
berthTags,
berthWaitingList,
berthMaintenanceLog,
} from '@/lib/db/schema/berths';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { ConflictError } from '@/lib/errors';
import type {
CreateBerthInput,
UpdateBerthInput,
UpdateBerthStatusInput,
ListBerthsQuery,
@@ -71,12 +68,18 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
const sortColumn = (() => {
switch (query.sort) {
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;
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;
}
})();
@@ -161,7 +164,10 @@ export async function updateBerth(
});
if (!existing) throw new NotFoundError('Berth');
const { changed, diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const { changed, diff } = diffEntity(
existing as Record<string, unknown>,
data as Record<string, unknown>,
);
if (!changed) return existing;
@@ -288,12 +294,7 @@ export async function updateBerthStatus(
// ─── Set Tags ─────────────────────────────────────────────────────────────────
export async function setBerthTags(
id: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
@@ -454,6 +455,90 @@ export async function updateWaitingList(
return data.entries;
}
// ─── 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',
});
}
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {

View File

@@ -0,0 +1,137 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userPortRoles, roles } from '@/lib/db/schema/users';
import type { RolePermissions } from '@/lib/db/schema/users';
import { createNotification } from '@/lib/services/notifications.service';
import { getSetting } from '@/lib/services/settings.service';
import { getQueue } from '@/lib/queue';
import { logger } from '@/lib/logger';
interface InquiryNotificationParams {
portId: string;
portSlug: string;
interestId: string;
clientFullName: string;
clientEmail: string;
clientPhone: string;
mooringNumber: string | null;
firstName: string;
}
/**
* Sends inquiry notifications to all relevant parties:
* 1. Confirmation email to the client
* 2. In-app + email notifications to CRM users with interests.view permission
* 3. Email to any external recipients configured in system settings
*
* All operations are fire-and-forget (errors are logged, not thrown).
*/
export async function sendInquiryNotifications(params: InquiryNotificationParams): Promise<void> {
const {
portId,
portSlug,
interestId,
clientFullName,
clientEmail,
clientPhone,
mooringNumber,
firstName,
} = params;
// 1. Queue client confirmation email
try {
const contactEmailSetting = await getSetting('inquiry_contact_email', portId);
const contactEmail =
typeof contactEmailSetting?.value === 'string'
? contactEmailSetting.value
: 'sales@portnimara.com';
const emailQueue = getQueue('email');
await emailQueue.add('send-inquiry-confirmation', {
to: clientEmail,
firstName,
mooringNumber,
contactEmail,
});
} catch (err) {
logger.error({ err, interestId }, 'Failed to queue client confirmation email');
}
// 2. Notify CRM users with interests.view permission on this port
try {
const usersWithAccess = await findUsersWithInterestsPermission(portId);
const crmUrl = `/${portSlug}/interests/${interestId}`;
for (const userId of usersWithAccess) {
try {
await createNotification({
portId,
userId,
type: 'new_registration',
title: 'New Interest Registered',
description: `${clientFullName} has registered interest${mooringNumber ? ` in Berth ${mooringNumber}` : ''} via the website`,
link: crmUrl,
entityType: 'interest',
entityId: interestId,
dedupeKey: `inquiry-${interestId}`,
});
} catch (err) {
logger.error({ err, userId, interestId }, 'Failed to create notification for user');
}
}
} catch (err) {
logger.error({ err, interestId }, 'Failed to notify CRM users');
}
// 3. Notify external recipients
try {
const recipientsSetting = await getSetting('inquiry_notification_recipients', portId);
const externalEmails: string[] = Array.isArray(recipientsSetting?.value)
? recipientsSetting.value
: [];
if (externalEmails.length > 0) {
const emailQueue = getQueue('email');
const appUrl = process.env.APP_URL ?? '';
const crmUrl = `${appUrl}/${portSlug}/interests/${interestId}`;
for (const externalEmail of externalEmails) {
await emailQueue.add('send-inquiry-sales-notification', {
to: externalEmail,
fullName: clientFullName,
email: clientEmail,
phone: clientPhone,
mooringNumber,
crmUrl,
});
}
}
} catch (err) {
logger.error({ err, interestId }, 'Failed to notify external recipients');
}
}
/**
* Finds all user IDs on a port whose role grants `interests.view` permission.
*/
async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
const assignments = await db
.select({
userId: userPortRoles.userId,
permissions: roles.permissions,
})
.from(userPortRoles)
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, portId));
const userIds = new Set<string>();
for (const row of assignments) {
const perms = row.permissions as RolePermissions | null;
if (perms?.interests?.view) {
userIds.add(row.userId);
}
}
return Array.from(userIds);
}

View File

@@ -0,0 +1,110 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema';
import type { PortSettings } from '@/lib/db/schema/ports';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listPorts() {
return db.select().from(ports).orderBy(ports.name);
}
export async function getPort(id: string) {
const port = await db.query.ports.findFirst({
where: eq(ports.id, id),
});
if (!port) throw new NotFoundError('Port');
return port;
}
export async function createPort(data: CreatePortInput, meta: AuditMeta) {
const existing = await db.query.ports.findFirst({
where: eq(ports.slug, data.slug),
});
if (existing) {
throw new ConflictError(`A port with slug "${data.slug}" already exists`);
}
const [port] = await db
.insert(ports)
.values({
name: data.name,
slug: data.slug,
logoUrl: data.logoUrl ?? null,
primaryColor: data.primaryColor ?? null,
defaultCurrency: data.defaultCurrency,
timezone: data.timezone,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'create',
entityType: 'port',
entityId: port!.id,
newValue: { name: port!.name, slug: port!.slug },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return port!;
}
export async function updatePort(id: string, data: UpdatePortInput, meta: AuditMeta) {
const port = await db.query.ports.findFirst({
where: eq(ports.id, id),
});
if (!port) throw new NotFoundError('Port');
if (data.slug && data.slug !== port.slug) {
const conflict = await db.query.ports.findFirst({
where: eq(ports.slug, data.slug),
});
if (conflict) {
throw new ConflictError(`A port with slug "${data.slug}" already exists`);
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.name !== undefined) updates.name = data.name;
if (data.slug !== undefined) updates.slug = data.slug;
if (data.logoUrl !== undefined) updates.logoUrl = data.logoUrl;
if (data.primaryColor !== undefined) updates.primaryColor = data.primaryColor;
if (data.defaultCurrency !== undefined) updates.defaultCurrency = data.defaultCurrency;
if (data.timezone !== undefined) updates.timezone = data.timezone;
if (data.isActive !== undefined) updates.isActive = data.isActive;
if (data.settings !== undefined) updates.settings = data.settings as PortSettings;
const [updated] = await db.update(ports).set(updates).where(eq(ports.id, id)).returning();
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'update',
entityType: 'port',
entityId: id,
oldValue: { name: port.name, slug: port.slug, isActive: port.isActive },
newValue: { name: updated!.name, slug: updated!.slug, isActive: updated!.isActive },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${id}`, 'system:alert', {
alertType: 'port:updated',
message: `Port "${updated!.name}" updated`,
severity: 'info',
});
return updated!;
}

View File

@@ -0,0 +1,468 @@
import { and, eq, lte, gte, desc, asc, inArray, sql, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { reminders, interests, clients } from '@/lib/db/schema';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { createNotification } from '@/lib/services/notifications.service';
import { logger } from '@/lib/logger';
import type {
CreateReminderInput,
UpdateReminderInput,
SnoozeReminderInput,
ReminderListQuery,
} from '@/lib/validators/reminders';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── List ────────────────────────────────────────────────────────────────────
export async function listReminders(portId: string, query: ReminderListQuery) {
const conditions = [eq(reminders.portId, portId)];
if (query.status) conditions.push(eq(reminders.status, query.status));
if (query.priority) conditions.push(eq(reminders.priority, query.priority));
if (query.assignedTo) conditions.push(eq(reminders.assignedTo, query.assignedTo));
if (query.clientId) conditions.push(eq(reminders.clientId, query.clientId));
if (query.interestId) conditions.push(eq(reminders.interestId, query.interestId));
if (query.berthId) conditions.push(eq(reminders.berthId, query.berthId));
if (query.dueBefore) conditions.push(lte(reminders.dueAt, new Date(query.dueBefore)));
if (query.dueAfter) conditions.push(gte(reminders.dueAt, new Date(query.dueAfter)));
if (query.search) {
conditions.push(sql`${reminders.title} ILIKE ${'%' + query.search + '%'}`);
}
const orderDir = query.order === 'asc' ? asc : desc;
const orderCol = query.sort === 'priority' ? reminders.priority : reminders.dueAt;
const offset = (query.page - 1) * query.limit;
const [data, countResult] = await Promise.all([
db
.select()
.from(reminders)
.where(and(...conditions))
.orderBy(orderDir(orderCol))
.limit(query.limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(reminders)
.where(and(...conditions)),
]);
return {
data,
pagination: {
page: query.page,
limit: query.limit,
total: Number(countResult[0]?.count ?? 0),
},
};
}
export async function getMyReminders(userId: string, portId: string) {
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
eq(reminders.assignedTo, userId),
inArray(reminders.status, ['pending', 'snoozed']),
),
)
.orderBy(asc(reminders.dueAt));
}
export async function getOverdueReminders(portId: string) {
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
inArray(reminders.status, ['pending', 'snoozed']),
lte(reminders.dueAt, new Date()),
),
)
.orderBy(asc(reminders.dueAt));
}
export async function getUpcomingReminders(portId: string, days: number = 14) {
const until = new Date();
until.setDate(until.getDate() + days);
return db
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
inArray(reminders.status, ['pending', 'snoozed']),
lte(reminders.dueAt, until),
gte(reminders.dueAt, new Date()),
),
)
.orderBy(asc(reminders.dueAt));
}
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function getReminder(id: string, portId: string) {
const reminder = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
with: { client: true, interest: true, berth: true },
});
if (!reminder) throw new NotFoundError('Reminder');
return reminder;
}
export async function createReminder(portId: string, data: CreateReminderInput, meta: AuditMeta) {
const [reminder] = await db
.insert(reminders)
.values({
portId,
title: data.title,
note: data.note ?? null,
dueAt: new Date(data.dueAt),
priority: data.priority,
assignedTo: data.assignedTo ?? meta.userId,
createdBy: meta.userId,
clientId: data.clientId ?? null,
interestId: data.interestId ?? null,
berthId: data.berthId ?? null,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'reminder',
entityId: reminder!.id,
newValue: { title: reminder!.title, dueAt: reminder!.dueAt, priority: reminder!.priority },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:created', {
reminderId: reminder!.id,
title: reminder!.title,
dueAt: reminder!.dueAt.toISOString(),
assignedTo: reminder!.assignedTo ?? meta.userId,
});
if (reminder!.assignedTo) {
emitToRoom(`user:${reminder!.assignedTo}`, 'reminder:created', {
reminderId: reminder!.id,
title: reminder!.title,
dueAt: reminder!.dueAt.toISOString(),
assignedTo: reminder!.assignedTo,
});
}
return reminder!;
}
export async function updateReminder(
id: string,
portId: string,
data: UpdateReminderInput,
meta: AuditMeta,
) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.note !== undefined) updates.note = data.note;
if (data.dueAt !== undefined) updates.dueAt = new Date(data.dueAt);
if (data.priority !== undefined) updates.priority = data.priority;
if (data.assignedTo !== undefined) updates.assignedTo = data.assignedTo;
if (data.clientId !== undefined) updates.clientId = data.clientId;
if (data.interestId !== undefined) updates.interestId = data.interestId;
if (data.berthId !== undefined) updates.berthId = data.berthId;
const [updated] = await db
.update(reminders)
.set(updates)
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { title: existing.title, dueAt: existing.dueAt, priority: existing.priority },
newValue: { title: updated!.title, dueAt: updated!.dueAt, priority: updated!.priority },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:updated', {
reminderId: updated!.id,
changedFields: Object.keys(data),
});
return updated!;
}
export async function deleteReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'reminder',
entityId: id,
oldValue: { title: existing.title },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Status Actions ──────────────────────────────────────────────────────────
export async function completeReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
if (existing.status === 'completed') throw new ValidationError('Reminder already completed');
const [updated] = await db
.update(reminders)
.set({
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'completed' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:completed', {
reminderId: updated!.id,
title: updated!.title,
completedBy: meta.userId,
});
return updated!;
}
export async function snoozeReminder(
id: string,
portId: string,
data: SnoozeReminderInput,
meta: AuditMeta,
) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const [updated] = await db
.update(reminders)
.set({
status: 'snoozed',
snoozedUntil: new Date(data.snoozeUntil),
updatedAt: new Date(),
})
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'snoozed', snoozedUntil: data.snoozeUntil },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'reminder:snoozed', {
reminderId: updated!.id,
snoozedUntil: data.snoozeUntil,
});
return updated!;
}
export async function dismissReminder(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
});
if (!existing) throw new NotFoundError('Reminder');
const [updated] = await db
.update(reminders)
.set({ status: 'dismissed', updatedAt: new Date() })
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'reminder',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: 'dismissed' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated!;
}
// ─── Background Processors ──────────────────────────────────────────────────
/**
* Hourly check: creates auto-follow-up reminders for interests with
* reminderEnabled=true where no activity in reminderDays days (BR-060).
*/
export async function processFollowUpReminders() {
const ports = await db.query.ports.findMany({ where: eq(sql`true`, true) });
for (const port of ports) {
const enabledInterests = await db
.select({
id: interests.id,
clientId: interests.clientId,
reminderDays: interests.reminderDays,
reminderLastFired: interests.reminderLastFired,
updatedAt: interests.updatedAt,
})
.from(interests)
.where(
and(
eq(interests.portId, port.id),
eq(interests.reminderEnabled, true),
isNull(interests.archivedAt),
),
);
const now = new Date();
for (const interest of enabledInterests) {
if (!interest.reminderDays) continue;
// Check if enough days have passed since last activity
const lastActivity = interest.reminderLastFired ?? interest.updatedAt;
const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceActivity < interest.reminderDays) continue;
// Get client name for the reminder title
const client = interest.clientId
? await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) })
: null;
const title = client ? `Follow up with ${client.fullName}` : 'Follow up on interest';
// Find the assigned user (first userPortRole for this port, or fallback)
// For now, leave assignedTo null — the notification goes to the port room
await db.insert(reminders).values({
portId: port.id,
title,
note: 'Auto-generated: no activity detected within the configured follow-up window.',
dueAt: now,
priority: 'medium',
assignedTo: null,
createdBy: 'system',
interestId: interest.id,
clientId: interest.clientId,
autoGenerated: true,
});
// Update last fired timestamp
await db
.update(interests)
.set({ reminderLastFired: now })
.where(eq(interests.id, interest.id));
// Fire notification to the port room
emitToRoom(`port:${port.id}`, 'system:alert', {
alertType: 'follow_up_created',
message: title,
severity: 'info',
});
logger.info({ interestId: interest.id, portId: port.id }, 'Auto follow-up reminder created');
}
}
}
/**
* Every 15 minutes: checks for past-due reminders and creates overdue notifications.
*/
export async function processOverdueReminders() {
const now = new Date();
// Find pending reminders past their due date
const overdueReminders = await db
.select()
.from(reminders)
.where(and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now)));
for (const reminder of overdueReminders) {
if (reminder.assignedTo) {
void createNotification({
portId: reminder.portId,
userId: reminder.assignedTo,
type: 'reminder_overdue',
title: 'Reminder overdue',
description: reminder.title,
entityType: 'reminder',
entityId: reminder.id,
link: '/reminders',
});
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
reminderId: reminder.id,
title: reminder.title,
dueAt: reminder.dueAt.toISOString(),
});
}
}
// Also un-snooze reminders whose snooze period has passed
await db
.update(reminders)
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
logger.info({ overdueCount: overdueReminders.length }, 'Processed overdue reminders');
}

View File

@@ -0,0 +1,148 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { roles, userPortRoles } from '@/lib/db/schema';
import type { RolePermissions } from '@/lib/db/schema/users';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listRoles() {
return db.select().from(roles).orderBy(roles.name);
}
export async function getRole(id: string) {
const role = await db.query.roles.findFirst({
where: eq(roles.id, id),
});
if (!role) throw new NotFoundError('Role');
return role;
}
export async function createRole(data: CreateRoleInput, meta: AuditMeta) {
// Check name uniqueness
const existing = await db.query.roles.findFirst({
where: eq(roles.name, data.name),
});
if (existing) {
throw new ConflictError(`A role named "${data.name}" already exists`);
}
const [role] = await db
.insert(roles)
.values({
name: data.name,
description: data.description ?? null,
permissions: data.permissions as RolePermissions,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'create',
entityType: 'role',
entityId: role!.id,
newValue: { name: role!.name },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${meta.portId}`, 'system:alert', {
alertType: 'role:created',
message: `Role "${role!.name}" created`,
severity: 'info',
});
return role!;
}
export async function updateRole(id: string, data: UpdateRoleInput, meta: AuditMeta) {
const role = await db.query.roles.findFirst({
where: eq(roles.id, id),
});
if (!role) throw new NotFoundError('Role');
// Check name uniqueness if changing name
if (data.name && data.name !== role.name) {
const conflict = await db.query.roles.findFirst({
where: eq(roles.name, data.name),
});
if (conflict) {
throw new ConflictError(`A role named "${data.name}" already exists`);
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.name !== undefined) updates.name = data.name;
if (data.description !== undefined) updates.description = data.description;
if (data.permissions !== undefined) updates.permissions = data.permissions as RolePermissions;
const [updated] = await db.update(roles).set(updates).where(eq(roles.id, id)).returning();
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'update',
entityType: 'role',
entityId: id,
oldValue: { name: role.name, permissions: role.permissions },
newValue: { name: updated!.name, permissions: updated!.permissions },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${meta.portId}`, 'system:alert', {
alertType: 'role:updated',
message: `Role "${updated!.name}" updated`,
severity: 'info',
});
return updated!;
}
export async function deleteRole(id: string, meta: AuditMeta) {
const role = await db.query.roles.findFirst({
where: eq(roles.id, id),
});
if (!role) throw new NotFoundError('Role');
if (role.isSystem) {
throw new ValidationError('System roles cannot be deleted');
}
// Check if any users are assigned this role
const assignments = await db.query.userPortRoles.findFirst({
where: eq(userPortRoles.roleId, id),
});
if (assignments) {
throw new ConflictError('Cannot delete a role that is assigned to users. Reassign them first.');
}
await db.delete(roles).where(eq(roles.id, id));
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'delete',
entityType: 'role',
entityId: id,
oldValue: { name: role.name },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${meta.portId}`, 'system:alert', {
alertType: 'role:deleted',
message: `Role "${role.name}" deleted`,
severity: 'info',
});
}

View File

@@ -0,0 +1,107 @@
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listSettings(portId: string) {
// Get port-specific settings
const portSettings = await db
.select()
.from(systemSettings)
.where(eq(systemSettings.portId, portId))
.orderBy(systemSettings.key);
// Get global settings (portId is null)
const globalSettings = await db
.select()
.from(systemSettings)
.where(isNull(systemSettings.portId))
.orderBy(systemSettings.key);
return { portSettings, globalSettings };
}
export async function getSetting(key: string, portId: string) {
// Try port-specific first, fall back to global
const setting = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
});
if (setting) return setting;
const global = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
});
return global ?? null;
}
export async function upsertSetting(key: string, value: unknown, portId: string, meta: AuditMeta) {
const existing = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
});
if (existing) {
await db
.update(systemSettings)
.set({ value, updatedBy: meta.userId, updatedAt: new Date() })
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)));
} else {
await db.insert(systemSettings).values({
key,
value,
portId,
updatedBy: meta.userId,
});
}
void createAuditLog({
userId: meta.userId,
portId,
action: existing ? 'update' : 'create',
entityType: 'setting',
entityId: key,
oldValue: existing ? { value: existing.value } : undefined,
newValue: { value },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'setting:updated',
message: `Setting "${key}" updated`,
severity: 'info',
});
return { key, value, portId };
}
export async function deleteSetting(key: string, portId: string, meta: AuditMeta) {
const existing = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
});
if (!existing) throw new NotFoundError('Setting');
await db
.delete(systemSettings)
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'setting',
entityId: key,
oldValue: { value: existing.value },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}

View File

@@ -0,0 +1,243 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export async function listUsers(portId: string) {
const rows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
email: user.email,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
isSuperAdmin: userProfiles.isSuperAdmin,
lastLoginAt: userProfiles.lastLoginAt,
roleId: roles.id,
roleName: roles.name,
assignedAt: userPortRoles.createdAt,
})
.from(userPortRoles)
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(user, eq(userPortRoles.userId, user.id))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, portId))
.orderBy(userProfiles.displayName);
return rows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
assignedAt: row.assignedAt,
}));
}
export async function getUser(userId: string, portId: string) {
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, userId),
});
if (!profile) throw new NotFoundError('User');
const authUser = await db.query.user.findFirst({
where: eq(user.id, userId),
});
const portRole = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
with: { role: true },
});
if (!portRole) throw new NotFoundError('User not assigned to this port');
return {
userId: profile.userId,
displayName: profile.displayName,
email: authUser?.email ?? '',
phone: profile.phone,
isActive: profile.isActive,
isSuperAdmin: profile.isSuperAdmin,
lastLoginAt: profile.lastLoginAt,
avatarUrl: profile.avatarUrl,
preferences: profile.preferences,
role: { id: portRole.role.id, name: portRole.role.name },
createdAt: profile.createdAt,
};
}
export async function createUser(portId: string, data: CreateUserInput, meta: AuditMeta) {
// Check email uniqueness
const existingUser = await db.query.user.findFirst({
where: eq(user.email, data.email.toLowerCase()),
});
if (existingUser) {
throw new ConflictError('A user with this email already exists');
}
// Validate role exists
const role = await db.query.roles.findFirst({
where: eq(roles.id, data.roleId),
});
if (!role) throw new ValidationError('Invalid role ID');
// Create Better Auth user
const authResult = await auth.api.signUpEmail({
body: {
email: data.email.toLowerCase(),
password: data.password,
name: data.name,
},
});
const newUserId = authResult.user.id;
// Create CRM profile
await db.insert(userProfiles).values({
userId: newUserId,
displayName: data.displayName,
phone: data.phone ?? null,
});
// Assign to port with role
await db.insert(userPortRoles).values({
userId: newUserId,
portId,
roleId: data.roleId,
assignedBy: meta.userId,
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'user',
entityId: newUserId,
newValue: { email: data.email, displayName: data.displayName, role: role.name },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'user:created',
message: `User "${data.displayName}" added`,
severity: 'info',
});
return getUser(newUserId, portId);
}
export async function updateUser(
userId: string,
portId: string,
data: UpdateUserInput,
meta: AuditMeta,
) {
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, userId),
});
if (!profile) throw new NotFoundError('User');
const portRole = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
});
if (!portRole) throw new NotFoundError('User not assigned to this port');
// Update profile fields
const profileUpdates: Record<string, unknown> = { updatedAt: new Date() };
if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
if (data.phone !== undefined) profileUpdates.phone = data.phone;
if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
if (Object.keys(profileUpdates).length > 1) {
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
}
// Update role assignment
if (data.roleId && data.roleId !== portRole.roleId) {
const newRole = await db.query.roles.findFirst({
where: eq(roles.id, data.roleId),
});
if (!newRole) throw new ValidationError('Invalid role ID');
await db
.update(userPortRoles)
.set({ roleId: data.roleId, assignedBy: meta.userId })
.where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'user',
entityId: userId,
oldValue: {
displayName: profile.displayName,
isActive: profile.isActive,
roleId: portRole.roleId,
},
newValue: data,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'user:updated',
message: `User "${data.displayName ?? profile.displayName}" updated`,
severity: 'info',
});
return getUser(userId, portId);
}
export async function removeUserFromPort(userId: string, portId: string, meta: AuditMeta) {
const portRole = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
});
if (!portRole) throw new NotFoundError('User not assigned to this port');
// Prevent removing yourself
if (userId === meta.userId) {
throw new ValidationError('Cannot remove yourself from the port');
}
await db
.delete(userPortRoles)
.where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, userId),
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'user',
entityId: userId,
oldValue: { displayName: profile?.displayName, roleId: portRole.roleId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'user:removed',
message: `User "${profile?.displayName}" removed from port`,
severity: 'info',
});
}

View File

@@ -2,6 +2,31 @@ import { z } from 'zod';
import { BERTH_STATUSES } from '@/lib/constants';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
// ─── Create Berth ────────────────────────────────────────────────────────────
export const createBerthSchema = z.object({
mooringNumber: z.string().min(1),
area: z.string().min(1),
lengthFt: z.coerce.number().optional(),
lengthM: z.coerce.number().optional(),
widthFt: z.coerce.number().optional(),
widthM: z.coerce.number().optional(),
draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(),
price: z.coerce.number().optional(),
priceCurrency: z.string().optional(),
status: z.enum(BERTH_STATUSES).default('available'),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
mooringType: z.string().optional(),
powerCapacity: z.string().optional(),
voltage: z.string().optional(),
access: z.string().optional(),
bowFacing: z.string().optional(),
sidePontoon: z.string().optional(),
});
export type CreateBerthInput = z.infer<typeof createBerthSchema>;
// ─── Update Berth ─────────────────────────────────────────────────────────────
export const updateBerthSchema = z.object({

View File

@@ -64,10 +64,25 @@ export const generateRecommendationsSchema = z.object({
// ─── Public Interest ──────────────────────────────────────────────────────────
export const publicInterestSchema = z.object({
fullName: z.string().min(1).max(200),
const addressSchema = z.object({
street: z.string().max(500).optional(),
city: z.string().max(200).optional(),
stateProvince: z.string().max(200).optional(),
postalCode: z.string().max(50).optional(),
country: z.string().max(100).optional(),
});
export const publicInterestSchema = z
.object({
// New: first/last split
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
// Backward compat
fullName: z.string().min(1).max(200).optional(),
email: z.string().email(),
phone: z.string().optional(),
phone: z.string().min(1),
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
mooringNumber: z.string().max(50).optional(),
companyName: z.string().optional(),
yachtName: z.string().optional(),
yachtLengthFt: z.coerce.number().positive().optional(),
@@ -76,7 +91,12 @@ export const publicInterestSchema = z.object({
preferredBerthSize: z.string().optional(),
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
});
address: addressSchema.optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {
message: 'Either fullName or both firstName and lastName are required',
path: ['fullName'],
});
// ─── Reorder Waiting List ─────────────────────────────────────────────────────

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
export const createPortSchema = z.object({
name: z.string().min(1).max(200),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
logoUrl: z.string().url().optional(),
primaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
defaultCurrency: z.string().length(3).default('USD'),
timezone: z.string().min(1).default('America/Anguilla'),
});
export type CreatePortInput = z.infer<typeof createPortSchema>;
export const updatePortSchema = z.object({
name: z.string().min(1).max(200).optional(),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens')
.optional(),
logoUrl: z.string().url().nullable().optional(),
primaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.nullable()
.optional(),
defaultCurrency: z.string().length(3).optional(),
timezone: z.string().min(1).optional(),
isActive: z.boolean().optional(),
settings: z.record(z.string(), z.unknown()).optional(),
});
export type UpdatePortInput = z.infer<typeof updatePortSchema>;

View File

@@ -0,0 +1,47 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
export const createReminderSchema = z.object({
title: z.string().min(1).max(300),
note: z.string().max(2000).optional(),
dueAt: z.string().datetime(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
assignedTo: z.string().optional(),
clientId: z.string().uuid().optional(),
interestId: z.string().uuid().optional(),
berthId: z.string().uuid().optional(),
});
export type CreateReminderInput = z.infer<typeof createReminderSchema>;
export const updateReminderSchema = z.object({
title: z.string().min(1).max(300).optional(),
note: z.string().max(2000).nullable().optional(),
dueAt: z.string().datetime().optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
assignedTo: z.string().nullable().optional(),
clientId: z.string().uuid().nullable().optional(),
interestId: z.string().uuid().nullable().optional(),
berthId: z.string().uuid().nullable().optional(),
});
export type UpdateReminderInput = z.infer<typeof updateReminderSchema>;
export const snoozeReminderSchema = z.object({
snoozeUntil: z.string().datetime(),
});
export type SnoozeReminderInput = z.infer<typeof snoozeReminderSchema>;
export const reminderListQuerySchema = baseListQuerySchema.extend({
status: z.enum(['pending', 'snoozed', 'completed', 'dismissed']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
assignedTo: z.string().optional(),
clientId: z.string().uuid().optional(),
interestId: z.string().uuid().optional(),
berthId: z.string().uuid().optional(),
dueBefore: z.string().datetime().optional(),
dueAfter: z.string().datetime().optional(),
});
export type ReminderListQuery = z.infer<typeof reminderListQuerySchema>;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
const permissionGroupSchema = z.record(z.string(), z.boolean());
const rolePermissionsSchema = z.object({
clients: permissionGroupSchema,
interests: permissionGroupSchema,
berths: permissionGroupSchema,
documents: permissionGroupSchema,
expenses: permissionGroupSchema,
invoices: permissionGroupSchema,
files: permissionGroupSchema,
email: permissionGroupSchema,
reminders: permissionGroupSchema,
calendar: permissionGroupSchema,
reports: permissionGroupSchema,
document_templates: permissionGroupSchema,
admin: permissionGroupSchema,
});
export const createRoleSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
permissions: rolePermissionsSchema,
});
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
export const updateRoleSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
permissions: rolePermissionsSchema.optional(),
});
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const upsertSettingSchema = z.object({
key: z.string().min(1).max(100),
value: z.unknown(),
});
export type UpsertSettingInput = z.infer<typeof upsertSettingSchema>;
export const deleteSettingSchema = z.object({
key: z.string().min(1).max(100),
});
export type DeleteSettingInput = z.infer<typeof deleteSettingSchema>;

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(200),
password: z.string().min(12),
displayName: z.string().min(1).max(200),
phone: z.string().optional(),
roleId: z.string().uuid(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(200).optional(),
phone: z.string().nullable().optional(),
isActive: z.boolean().optional(),
roleId: z.string().uuid().optional(),
});
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export const resetPasswordSchema = z.object({
newPassword: z.string().min(12),
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;

43
src/worker.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Worker entry point for the crm-worker container.
*
* Imports all BullMQ workers and registers recurring job schedules.
* In production this runs as a separate process (Dockerfile.worker).
* In development, server.ts imports workers inline instead.
*/
import { logger } from '@/lib/logger';
import { registerRecurringJobs } from '@/lib/queue/scheduler';
// Import all workers — the act of importing starts them
import { emailWorker } from '@/lib/queue/workers/email';
import { documentsWorker } from '@/lib/queue/workers/documents';
import { notificationsWorker } from '@/lib/queue/workers/notifications';
import { importWorker } from '@/lib/queue/workers/import';
import { exportWorker } from '@/lib/queue/workers/export';
// Keep references so workers aren't GC'd
const workers = [emailWorker, documentsWorker, notificationsWorker, importWorker, exportWorker];
async function main(): Promise<void> {
logger.info({ workerCount: workers.length }, 'BullMQ workers started');
await registerRecurringJobs();
logger.info('Recurring jobs registered');
// Graceful shutdown
const shutdown = async () => {
logger.info('Shutting down workers...');
await Promise.all(workers.map((w) => w.close()));
logger.info('All workers closed');
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
main().catch((err) => {
logger.error(err, 'Worker process failed to start');
process.exit(1);
});

12
tsconfig.server.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"noEmit": false,
"outDir": "./dist",
"plugins": []
},
"include": ["src/server.ts", "src/worker.ts", "src/**/*.ts"],
"exclude": ["node_modules", "client-portal", "**/*.tsx"]
}