Compare commits
1 Commits
1791dd7319
...
docs/dedup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b92eb827 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
30
.gitattributes
vendored
30
.gitattributes
vendored
@@ -1,30 +0,0 @@
|
||||
# Normalize line endings on commit; check out LF on every OS.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary files — never touch line endings.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.eot binary
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.wasm binary
|
||||
|
||||
# Shell scripts must stay LF regardless.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows batch / PowerShell must stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -17,13 +17,3 @@ playwright-report/
|
||||
nginx/certs/
|
||||
tsconfig.tsbuildinfo
|
||||
.playwright-mcp/
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
|
||||
@@ -20,42 +20,16 @@
|
||||
|
||||
### Client Domain
|
||||
|
||||
- `clients` — Anchor records for people/entities. Yacht and company details
|
||||
are no longer stored here — see the Yacht and Company domains.
|
||||
- `clients` — Anchor records for people/entities
|
||||
- `client_contacts` — Multi-channel contact entries per client
|
||||
- `client_addresses` — Physical addresses per client (primary + others)
|
||||
- `client_relationships` — Relationships between clients (referrals, broker, family)
|
||||
- `client_notes` — Timestamped notes on clients
|
||||
- `client_tags` — Tags assigned to clients
|
||||
- `client_merge_log` — Audit trail of client merges
|
||||
|
||||
### Yacht Domain
|
||||
|
||||
- `yachts` — First-class yacht records. Polymorphic ownership via
|
||||
`current_owner_type` (`'client' | 'company'`) + `current_owner_id`.
|
||||
- `yacht_ownership_history` — Append-only log of every transfer; partial
|
||||
unique index `idx_yoh_active` enforces a single active owner per yacht.
|
||||
- `yacht_notes`, `yacht_tags` — Notes / tags on yachts.
|
||||
|
||||
### Company Domain
|
||||
|
||||
- `companies` — Legal entities that may own yachts or be billed.
|
||||
- `company_addresses` — Addresses per company.
|
||||
- `company_memberships` — Active client ↔ company links with role
|
||||
(director / shareholder / beneficial_owner / authorised_signatory),
|
||||
start/end dates.
|
||||
|
||||
### Reservation Domain
|
||||
|
||||
- `berth_reservations` — Concrete client + yacht + berth holds with
|
||||
start/end dates and status. Partial unique index `idx_br_active`
|
||||
enforces one active reservation per berth.
|
||||
|
||||
### Interest Domain
|
||||
|
||||
- `interests` — Per-berth pipeline records. Each row references a
|
||||
`client_id`, `yacht_id` (the yacht in scope for the inquiry), and
|
||||
optional `berth_id`. Milestone dates are inline columns.
|
||||
- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns)
|
||||
- `interest_notes` — Timestamped notes on interests
|
||||
- `interest_tags` — Tags assigned to interests
|
||||
|
||||
|
||||
59
CLAUDE.md
59
CLAUDE.md
@@ -13,19 +13,6 @@ 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)
|
||||
|
||||
# Tests
|
||||
pnpm exec vitest run # Unit + integration (~3s)
|
||||
pnpm exec playwright test --project=smoke # Click-through smoke (~10min)
|
||||
pnpm exec playwright test --project=exhaustive # Full UI exhaustive
|
||||
pnpm exec playwright test --project=destructive # Archive/delete flows
|
||||
pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in)
|
||||
pnpm exec playwright test --project=visual # Pixel-diff baselines
|
||||
pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines
|
||||
|
||||
# Dev helpers
|
||||
pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email
|
||||
pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
@@ -83,47 +70,15 @@ src/
|
||||
- **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]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively.
|
||||
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc.
|
||||
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
|
||||
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
|
||||
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
|
||||
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- **Documenso API responses:** 2.x renamed `id` → `documentId` and recipient `id` → `recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
|
||||
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
|
||||
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
|
||||
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
|
||||
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
|
||||
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
|
||||
- **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. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
|
||||
|
||||
## Schema migrations during dev
|
||||
|
||||
When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it.
|
||||
- **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).
|
||||
|
||||
Optional dev/test-only env vars (not in `.env.example`):
|
||||
|
||||
- `EMAIL_REDIRECT_TO=<address>` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from <original>]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**.
|
||||
- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — read by `tests/e2e/realapi/portal-imap-activation.spec.ts` to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.
|
||||
|
||||
## Testing
|
||||
|
||||
Five Playwright projects, defined in `playwright.config.ts`:
|
||||
|
||||
- `setup` — global setup (seeds users, port, berths, system settings).
|
||||
- `smoke` — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
|
||||
- `exhaustive` — deeper UI coverage that takes longer.
|
||||
- `destructive` — archive/delete/cancel paths against throwaway entities.
|
||||
- `realapi` — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
|
||||
- `visual` — pixel-diff baselines for stable list/landing pages. Snapshots committed under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. Regenerate with `--update-snapshots` after intentional UI changes.
|
||||
|
||||
Vitest covers unit + integration with mocked external services (`tests/unit/`, `tests/integration/`).
|
||||
|
||||
## Docker
|
||||
|
||||
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
|
||||
@@ -134,11 +89,3 @@ Vitest covers unit + integration with mocked external services (`tests/unit/`, `
|
||||
## 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.
|
||||
|
||||
Domain-specific references:
|
||||
|
||||
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
|
||||
paths to the Documenso template's `formValues` keys, with the matching
|
||||
AcroForm field names used by the in-app pathway.
|
||||
- `assets/README.md` — what the in-app EOI source PDF must contain and how
|
||||
to override its path in dev/test.
|
||||
|
||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,22 +1,12 @@
|
||||
# Port Nimara CRM - Project Progress
|
||||
|
||||
**Last updated:** 2026-04-22
|
||||
**Last updated:** 2026-03-26
|
||||
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
|
||||
**Domain:** pn.letsbe.solutions
|
||||
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
|
||||
|
||||
---
|
||||
|
||||
## Since 2026-03-26
|
||||
|
||||
- **Admin surface expanded** — full admin users + roles management, admin ports + system settings management, user settings, expanded audit log, and berth CRUD completions.
|
||||
- **Reminders system** — promoted from "pages only" to full CRUD with background processors.
|
||||
- **Multi-address clients** — new `client_addresses` table with a partial unique index enforcing one primary address per client.
|
||||
- **Inquiry notifications feature (end-to-end)** — public interest form now fires: (a) confirmation email to the inquiring client, (b) in-app notifications to CRM users with `interests.view`, (c) optional email to configured sales recipients. Public schema expanded with first/last name split, address block, and berth mooring lookup. `sendEmail` gained a plain-text fallback. Admin settings UI exposes `inquiry_contact_email` and `inquiry_notification_recipients`. Plan: `docs/superpowers/plans/2026-04-14-inquiry-notifications.md`.
|
||||
- **Build/infra cleanup** — Next.js 15 static-prerender bugs fixed (Suspense boundaries around `useSearchParams` on `/portal/verify` and `/set-password`), `.gitattributes` added to enforce LF in the index across Windows/macOS checkouts, Docker production build fixes, CI trimmed to build+push (deploy job removed).
|
||||
|
||||
---
|
||||
|
||||
## What's Been Built (Layers 0-4 Complete)
|
||||
|
||||
### Layer 0: Foundation (DONE)
|
||||
@@ -90,10 +80,8 @@
|
||||
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
|
||||
- Service: `notifications.service.ts`
|
||||
- Components: `src/components/notifications/`
|
||||
- [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
|
||||
- [x] **Reminders** - Reminder pages
|
||||
- Pages: `/reminders`
|
||||
- API: `/api/v1/reminders/...` (CRUD, my, overdue, upcoming, complete, dismiss, snooze)
|
||||
- Service: `reminders.service.ts`
|
||||
- [x] **Search** - Global search (inline in topbar), saved views
|
||||
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
|
||||
- Service: `search.service.ts`, `saved-views.service.ts`
|
||||
@@ -190,12 +178,11 @@
|
||||
|
||||
### Priority 1: Deployment & Go-Live
|
||||
|
||||
- [x] Push to Gitea (origin/main at `9d815c4` as of 2026-04-22)
|
||||
- [ ] Verify CI/CD pipeline builds the latest image and pushes to the Gitea container registry
|
||||
- [ ] Push to Gitea and verify CI/CD pipeline builds
|
||||
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
|
||||
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
|
||||
- [ ] Configure production `.env` on server
|
||||
- [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
|
||||
- [ ] Run database migrations (`pnpm db:push`)
|
||||
- [ ] Run seed data (`pnpm db:seed`)
|
||||
- [ ] Verify all services start and health check passes
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# `assets/`
|
||||
|
||||
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
|
||||
in `next.config.ts`). These files are read with `fs.readFile` from
|
||||
`process.cwd()` at runtime, so they are NOT served as public URLs — use
|
||||
`public/` for that.
|
||||
|
||||
## `eoi-template.pdf`
|
||||
|
||||
The source PDF used by the in-app EOI generation pathway
|
||||
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
|
||||
Documenso EOI template uploads, so both pathways produce equivalent
|
||||
documents.
|
||||
|
||||
The PDF must contain AcroForm fields with these exact names (mirroring the
|
||||
Documenso template's `formValues` keys — see
|
||||
`docs/eoi-documenso-field-mapping.md`):
|
||||
|
||||
| Field name | Type | Filled with |
|
||||
| -------------- | -------- | ----------------------------------------------------- |
|
||||
| `Name` | Text | `EoiContext.client.fullName` |
|
||||
| `Email` | Text | `EoiContext.client.primaryEmail` |
|
||||
| `Address` | Text | `street, city, country` |
|
||||
| `Yacht Name` | Text | `EoiContext.yacht.name` |
|
||||
| `Length` | Text | `EoiContext.yacht.lengthFt` |
|
||||
| `Width` | Text | `EoiContext.yacht.widthFt` |
|
||||
| `Draft` | Text | `EoiContext.yacht.draftFt` |
|
||||
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
|
||||
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
|
||||
| `Purchase` | Checkbox | always `true` |
|
||||
|
||||
Form fields stay interactive after generation (not flattened), so the
|
||||
recipient can still tweak values before signing if the in-app pathway is
|
||||
followed by a Documenso send.
|
||||
|
||||
### Override path
|
||||
|
||||
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
|
||||
point at a different file (e.g. a fixture).
|
||||
|
||||
### How to extract this PDF
|
||||
|
||||
The legacy flow uploads this PDF to Documenso template ID 8. To get the
|
||||
exact bytes:
|
||||
|
||||
1. In Documenso, open the EOI template.
|
||||
2. Download the source PDF.
|
||||
3. Drop it here as `eoi-template.pdf`.
|
||||
Binary file not shown.
Submodule client-portal updated: 84f89f9409...e2d31815cf
@@ -1,76 +0,0 @@
|
||||
# Documenso EOI Template — Field Mapping
|
||||
|
||||
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
|
||||
|
||||
## Source
|
||||
|
||||
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
|
||||
|
||||
## Documenso template `formValues` keys
|
||||
|
||||
Documenso template IDs and recipient IDs are configured via env vars:
|
||||
|
||||
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
|
||||
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
|
||||
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
|
||||
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
|
||||
|
||||
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
|
||||
|
||||
## Field mapping
|
||||
|
||||
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
|
||||
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
|
||||
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
|
||||
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
|
||||
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. |
|
||||
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. |
|
||||
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
|
||||
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
|
||||
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. |
|
||||
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
|
||||
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
|
||||
|
||||
## Document `meta` fields (non-`formValues`)
|
||||
|
||||
| Documenso key | Type | Legacy source | New source |
|
||||
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
|
||||
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
|
||||
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
|
||||
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
|
||||
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
|
||||
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
|
||||
|
||||
## Recipients (non-`formValues`)
|
||||
|
||||
| Recipient | Role | Name | Email | Signing order |
|
||||
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
|
||||
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
|
||||
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
|
||||
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
|
||||
|
||||
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
|
||||
|
||||
## Company-owned yacht handling
|
||||
|
||||
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
|
||||
|
||||
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
|
||||
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
|
||||
|
||||
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
|
||||
|
||||
## Deprecated fields (no longer sourced from `clients`)
|
||||
|
||||
The legacy system read these fields from the client row. They are now sourced elsewhere:
|
||||
|
||||
| Legacy source | New source |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
|
||||
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
|
||||
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
|
||||
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
|
||||
| `client.companyName` | `companies.name` via polymorphic owner resolution |
|
||||
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,663 +0,0 @@
|
||||
# Data-Model Refactor: Yachts and Companies as First-Class Entities
|
||||
|
||||
**Status:** Draft — awaiting final review
|
||||
**Date:** 2026-04-23
|
||||
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
|
||||
|
||||
## Overview
|
||||
|
||||
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
|
||||
|
||||
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
|
||||
|
||||
- A client owns multiple yachts (a common marina scenario)
|
||||
- A person is a broker or director of multiple companies
|
||||
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
|
||||
- A yacht changes hands between owners and the marina needs chain-of-title
|
||||
|
||||
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
|
||||
|
||||
This spec also fixes two existing schema gaps that surface during the refactor:
|
||||
|
||||
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
|
||||
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope (this spec)
|
||||
|
||||
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
|
||||
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
|
||||
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
|
||||
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
|
||||
- Removal of yacht, company, and proxy columns from `clients`
|
||||
- New services, API routes, permissions, and socket/webhook events
|
||||
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
|
||||
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
|
||||
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
|
||||
- Seeder with realistic multi-cardinality dummy data
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **Importing NocoDB records and MinIO documents** → Spec 2
|
||||
- **Client merge endpoint** → Spec 3
|
||||
- Yacht survey / class-cert document categorization
|
||||
- Company hierarchy (holding → subsidiary)
|
||||
- Line-item-level yacht references on invoices
|
||||
- Auto-renewal flow for berth reservations
|
||||
- Per-yacht row-level permissions
|
||||
- Portal branding per company
|
||||
|
||||
## Decisions and rationale
|
||||
|
||||
| Topic | Decision | Why |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
|
||||
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
|
||||
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
|
||||
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
|
||||
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
|
||||
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
|
||||
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
|
||||
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
|
||||
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
|
||||
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
|
||||
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
|
||||
|
||||
## Schema design
|
||||
|
||||
### New tables
|
||||
|
||||
```
|
||||
yachts
|
||||
id text PK
|
||||
portId text NOT NULL FK → ports.id
|
||||
name text NOT NULL
|
||||
hullNumber text
|
||||
registration text
|
||||
flag text
|
||||
yearBuilt integer
|
||||
builder text
|
||||
model text
|
||||
hullMaterial text
|
||||
lengthFt numeric
|
||||
widthFt numeric
|
||||
draftFt numeric
|
||||
lengthM numeric
|
||||
widthM numeric
|
||||
draftM numeric
|
||||
currentOwnerType text NOT NULL -- 'client' | 'company'
|
||||
currentOwnerId text NOT NULL
|
||||
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
|
||||
notes text
|
||||
archivedAt timestamptz
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_yachts_port on (portId)
|
||||
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
|
||||
idx_yachts_name on (portId, name)
|
||||
|
||||
yacht_ownership_history
|
||||
id text PK
|
||||
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
|
||||
ownerType text NOT NULL -- 'client' | 'company'
|
||||
ownerId text NOT NULL
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = currently active
|
||||
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
|
||||
transferNotes text
|
||||
createdBy text NOT NULL
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_yoh_yacht on (yachtId)
|
||||
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
|
||||
|
||||
yacht_notes -- mirrors client_notes shape
|
||||
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
|
||||
|
||||
yacht_tags
|
||||
yachtId, tagId composite PK; tagId references system.tags.id
|
||||
|
||||
companies
|
||||
id text PK
|
||||
portId text NOT NULL FK → ports.id
|
||||
name text NOT NULL
|
||||
legalName text
|
||||
taxId text
|
||||
registrationNumber text
|
||||
incorporationCountry text
|
||||
incorporationDate date
|
||||
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
|
||||
billingEmail text
|
||||
notes text
|
||||
archivedAt timestamptz
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_companies_port on (portId)
|
||||
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
|
||||
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
|
||||
|
||||
company_memberships
|
||||
id text PK
|
||||
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
|
||||
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
|
||||
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
|
||||
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = active
|
||||
isPrimary boolean NOT NULL DEFAULT false
|
||||
notes text
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_cm_company on (companyId)
|
||||
idx_cm_client on (clientId)
|
||||
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
|
||||
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
|
||||
|
||||
company_addresses -- mirrors client_addresses shape with companyId FK
|
||||
company_notes -- mirrors client_notes shape with companyId FK
|
||||
company_tags
|
||||
companyId, tagId composite PK
|
||||
|
||||
berth_reservations
|
||||
id text PK
|
||||
berthId text NOT NULL FK → berths.id
|
||||
portId text NOT NULL FK → ports.id
|
||||
clientId text NOT NULL FK → clients.id -- contract holder
|
||||
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
|
||||
interestId text FK → interests.id -- nullable link back to originating interest
|
||||
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
|
||||
startDate date NOT NULL
|
||||
endDate date -- NULL = open-ended
|
||||
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
|
||||
contractFileId text FK → files.id
|
||||
createdBy text NOT NULL
|
||||
createdAt timestamptz NOT NULL DEFAULT now()
|
||||
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||
Indexes:
|
||||
idx_br_berth on (berthId)
|
||||
idx_br_client on (clientId)
|
||||
idx_br_yacht on (yachtId)
|
||||
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
|
||||
```
|
||||
|
||||
### Modified tables
|
||||
|
||||
```
|
||||
clients
|
||||
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
|
||||
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
|
||||
DROP COLUMN companyName
|
||||
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
|
||||
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
|
||||
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
|
||||
|
||||
interests
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
|
||||
ADD INDEX idx_interests_yacht on (yachtId)
|
||||
|
||||
berth_waiting_list
|
||||
ADD COLUMN yachtId text FK → yachts.id
|
||||
|
||||
invoices
|
||||
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
|
||||
ADD COLUMN billingEntityId text NOT NULL
|
||||
(clientName column kept as immutable snapshot — must never auto-update)
|
||||
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
|
||||
|
||||
files
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
|
||||
|
||||
documents
|
||||
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||
```
|
||||
|
||||
### DB-level invariants
|
||||
|
||||
| # | Invariant | Enforced by |
|
||||
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
|
||||
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
|
||||
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
|
||||
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
|
||||
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
|
||||
|
||||
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
|
||||
|
||||
| # | Invariant | Enforced by |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| 6 | `yacht.currentOwnerType='client'` ↔ `currentOwnerId` references an existing row in `clients`; same for `'company'` ↔ `companies` | Zod validator + service-layer lookup before insert/update |
|
||||
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
|
||||
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
|
||||
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
|
||||
|
||||
### Drizzle relations (`relations.ts`)
|
||||
|
||||
All new tables wire into the relations map. Notable additions:
|
||||
|
||||
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
|
||||
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
|
||||
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
|
||||
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
|
||||
|
||||
## Service layer and API
|
||||
|
||||
### New services (`src/lib/services/`)
|
||||
|
||||
| File | Key functions |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
|
||||
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
|
||||
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
|
||||
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
|
||||
|
||||
### Modified services
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
|
||||
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
|
||||
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
|
||||
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
|
||||
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
|
||||
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
|
||||
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
|
||||
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
|
||||
|
||||
### New REST endpoints
|
||||
|
||||
```
|
||||
# Yachts
|
||||
GET /api/v1/yachts
|
||||
POST /api/v1/yachts
|
||||
GET /api/v1/yachts/:id
|
||||
PATCH /api/v1/yachts/:id
|
||||
DELETE /api/v1/yachts/:id — archive (soft delete)
|
||||
POST /api/v1/yachts/:id/transfer — ownership transfer
|
||||
GET /api/v1/yachts/:id/ownership-history
|
||||
GET /api/v1/yachts/autocomplete?q=…
|
||||
|
||||
# Companies
|
||||
GET /api/v1/companies
|
||||
POST /api/v1/companies
|
||||
GET /api/v1/companies/:id
|
||||
PATCH /api/v1/companies/:id
|
||||
DELETE /api/v1/companies/:id — archive
|
||||
GET /api/v1/companies/autocomplete?q=…
|
||||
|
||||
# Company memberships
|
||||
GET /api/v1/companies/:id/members
|
||||
POST /api/v1/companies/:id/members
|
||||
PATCH /api/v1/companies/:id/members/:mid
|
||||
DELETE /api/v1/companies/:id/members/:mid — sets endDate
|
||||
|
||||
# Berth reservations
|
||||
GET /api/v1/berths/:id/reservations
|
||||
POST /api/v1/berths/:id/reservations — create pending
|
||||
PATCH /api/v1/berth-reservations/:id — state transitions
|
||||
```
|
||||
|
||||
### Modified endpoints
|
||||
|
||||
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
|
||||
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
|
||||
- `POST /api/v1/interests` — requires `yachtId`
|
||||
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
|
||||
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
|
||||
|
||||
### Permissions (new keys)
|
||||
|
||||
```
|
||||
yachts:view
|
||||
yachts:write
|
||||
yachts:transfer — higher-stakes operation, separate from :write
|
||||
yachts:delete — archive permission
|
||||
|
||||
companies:view
|
||||
companies:write
|
||||
companies:delete
|
||||
|
||||
memberships:write — covers both directions of company_memberships
|
||||
|
||||
reservations:view
|
||||
reservations:write
|
||||
```
|
||||
|
||||
Existing role updates:
|
||||
|
||||
- `admin` — all new keys
|
||||
- `team_lead` — `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
|
||||
- `front_desk` — all `:view` keys
|
||||
|
||||
### Socket / webhook events (new)
|
||||
|
||||
```
|
||||
yacht.created
|
||||
yacht.updated
|
||||
yacht.ownership_transferred
|
||||
yacht.archived
|
||||
company.created
|
||||
company.updated
|
||||
company.archived
|
||||
company_membership.added
|
||||
company_membership.ended
|
||||
berth_reservation.created
|
||||
berth_reservation.activated
|
||||
berth_reservation.ended
|
||||
berth_reservation.cancelled
|
||||
```
|
||||
|
||||
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
|
||||
|
||||
## EOI template strategy (dual-path)
|
||||
|
||||
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
|
||||
|
||||
### Shared payload builder
|
||||
|
||||
```ts
|
||||
// src/lib/services/eoi-context.ts
|
||||
export async function buildEoiContext(interestId: string): Promise<EoiContext>
|
||||
|
||||
type EoiContext = {
|
||||
client: { fullName; nationality; primaryEmail; primaryPhone; address; … }
|
||||
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; … } // via interest.yachtId
|
||||
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
|
||||
owner: { type: 'client' | 'company'; name; … } // polymorphic current owner
|
||||
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; … }
|
||||
interest: { stage; leadCategory; dateFirstContact; notes; … }
|
||||
port: { name; defaultCurrency; legalEntity; … }
|
||||
date: { today; year }
|
||||
}
|
||||
```
|
||||
|
||||
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
|
||||
|
||||
### Path A — Documenso template
|
||||
|
||||
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
|
||||
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
|
||||
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
|
||||
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
|
||||
|
||||
### Path B — In-app PDF template
|
||||
|
||||
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
|
||||
- `resolveTemplate()` substitutes tokens from `EoiContext`
|
||||
- `pdfme` renders the resolved HTML to PDF
|
||||
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
|
||||
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
|
||||
|
||||
### UI picker
|
||||
|
||||
Generate-EOI dialog adds a Template dropdown:
|
||||
|
||||
```
|
||||
Template: [ Documenso — Standard EOI v ]
|
||||
[ Documenso — Standard EOI ]
|
||||
[ In-app — Standard EOI ]
|
||||
[ In-app — (any custom template user authored) ]
|
||||
```
|
||||
|
||||
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
|
||||
|
||||
## UI impact
|
||||
|
||||
### New pages
|
||||
|
||||
| Route | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
|
||||
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
|
||||
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
|
||||
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
|
||||
|
||||
### Modified pages
|
||||
|
||||
| Page | Change |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
|
||||
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
|
||||
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
|
||||
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
|
||||
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
|
||||
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
|
||||
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
|
||||
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
|
||||
| Global search | Extended to yachts and companies |
|
||||
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
|
||||
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
|
||||
|
||||
### Portal pages
|
||||
|
||||
- Dashboard: shows owned + represented yachts, active memberships, active reservations
|
||||
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
|
||||
- New "My Reservations" tab
|
||||
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
|
||||
|
||||
### New components (`src/components/`)
|
||||
|
||||
```
|
||||
yachts/
|
||||
yacht-form.tsx
|
||||
yacht-detail.tsx
|
||||
yacht-detail-header.tsx
|
||||
yacht-tabs.tsx
|
||||
yacht-columns.tsx
|
||||
yacht-picker.tsx
|
||||
yacht-ownership-history.tsx
|
||||
yacht-transfer-dialog.tsx
|
||||
companies/
|
||||
company-form.tsx
|
||||
company-detail.tsx
|
||||
company-detail-header.tsx
|
||||
company-tabs.tsx
|
||||
company-columns.tsx
|
||||
company-picker.tsx
|
||||
company-members-tab.tsx
|
||||
company-owned-yachts-tab.tsx
|
||||
add-membership-dialog.tsx
|
||||
reservations/
|
||||
reservation-form.tsx
|
||||
reservation-list.tsx
|
||||
berth-reserve-dialog.tsx
|
||||
shared/
|
||||
owner-picker.tsx — polymorphic client|company autocomplete
|
||||
billing-entity-picker.tsx
|
||||
```
|
||||
|
||||
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
|
||||
|
||||
### Seeder (`src/lib/db/seed.ts`) — rewrite
|
||||
|
||||
Produces realistic multi-cardinality fixtures:
|
||||
|
||||
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
|
||||
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
|
||||
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
|
||||
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
|
||||
- A handful of active berth reservations + a few ended/cancelled ones
|
||||
- Rich contact / address / membership / ownership-history data covering every test scenario
|
||||
|
||||
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Coverage targets (CI-enforced)
|
||||
|
||||
| Tier | Target |
|
||||
| ------------- | ------------------- |
|
||||
| Service layer | ≥ 90% line coverage |
|
||||
| Validators | 100% line coverage |
|
||||
| API routes | ≥ 85% line coverage |
|
||||
| Overall | ≥ 85% line coverage |
|
||||
|
||||
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
|
||||
|
||||
### Tier 1 — Unit tests (Vitest)
|
||||
|
||||
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
|
||||
- Merge-field resolver: every new token resolves correctly across each context shape
|
||||
- Validators: every zod schema tested for pass + fail on each field
|
||||
|
||||
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
|
||||
|
||||
- Migration up/down correctness
|
||||
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
|
||||
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
|
||||
- Atomic `transferOwnership`: concurrent retries result in consistent state
|
||||
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
|
||||
- Company name case-insensitive uniqueness
|
||||
- Every new API route: auth → permission → service → DB → response shape
|
||||
|
||||
### Tier 3 — E2E scenario tests (Playwright)
|
||||
|
||||
Full-lifecycle flows:
|
||||
|
||||
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
|
||||
2. Same, in-app template path → verify PDF content contains expected yacht name
|
||||
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
|
||||
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
|
||||
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
|
||||
6. Public interest form → admin sees new client+yacht+company+interest trio
|
||||
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
|
||||
|
||||
Multi-cardinality flows (the core justification for this refactor):
|
||||
|
||||
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
|
||||
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
|
||||
|
||||
Portal flows:
|
||||
|
||||
10. Portal user views "my yachts" — sees only owned/represented
|
||||
11. Portal user submits interest — new yacht linked to their identity
|
||||
|
||||
### Tier 3.5 — Exhaustive Playwright click-through suite
|
||||
|
||||
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
|
||||
|
||||
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
|
||||
|
||||
Per-page logic:
|
||||
|
||||
1. Navigate to page
|
||||
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
|
||||
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
|
||||
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
|
||||
|
||||
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
|
||||
|
||||
Destructive actions allowlist (tested separately with create-then-destroy isolation):
|
||||
|
||||
```
|
||||
yachts.delete, yachts.archive, yachts.transferOwnership
|
||||
companies.delete, companies.archive
|
||||
companyMemberships.end
|
||||
berthReservations.cancel, berthReservations.end
|
||||
invoices.delete
|
||||
```
|
||||
|
||||
Acceptance criteria for Spec 1 completion:
|
||||
|
||||
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
|
||||
- Every allowlist entry has its own narrow destructive test
|
||||
- Zero console errors across the full suite
|
||||
- Zero unexpected 4xx/5xx responses
|
||||
|
||||
### Tier 4 — EOI template regression
|
||||
|
||||
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
|
||||
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
|
||||
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
|
||||
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
|
||||
|
||||
### Tier 5 — Security tests
|
||||
|
||||
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
|
||||
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
|
||||
- Portal authorization: portal user cannot see yachts they don't own/represent
|
||||
- Public interest endpoint: anonymous submitter cannot read existing records
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
Fixture factories in `tests/helpers/factories.ts`:
|
||||
|
||||
```
|
||||
makeYacht({ owner: client|company, ...overrides })
|
||||
makeCompany({ overrides })
|
||||
makeMembership({ client, company, role, ...overrides })
|
||||
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
|
||||
makeReservation({ berth, client, yacht, status })
|
||||
```
|
||||
|
||||
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
|
||||
|
||||
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
|
||||
|
||||
## Rollout plan
|
||||
|
||||
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
|
||||
|
||||
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
|
||||
|
||||
| # | PR | Depends on |
|
||||
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
|
||||
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
|
||||
| 3 | API routes for new services + new permissions | 2 |
|
||||
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
|
||||
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
|
||||
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
|
||||
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
|
||||
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
|
||||
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
|
||||
| 10 | Invoice billing-entity support (client or company) | 6 |
|
||||
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
|
||||
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
|
||||
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
|
||||
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
|
||||
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
|
||||
|
||||
After PR 15, merge the feature branch into `main` as one final PR.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
|
||||
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
|
||||
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
|
||||
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
|
||||
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
|
||||
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
|
||||
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
|
||||
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
|
||||
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
|
||||
|
||||
## Open questions / deferred items
|
||||
|
||||
Explicitly out of scope for this spec:
|
||||
|
||||
- Yacht survey / class-cert document categorization (requires taxonomy work)
|
||||
- Multi-level company hierarchy (holding → subsidiary) — additive later
|
||||
- Invoice line items referencing specific yacht
|
||||
- Berth reservation auto-renewal flow
|
||||
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
|
||||
- Portal branding per company
|
||||
|
||||
## Success criteria
|
||||
|
||||
Spec 1 is complete when:
|
||||
|
||||
1. All PRs in the sequence are merged to `main`
|
||||
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
|
||||
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
|
||||
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
|
||||
5. Documentation (CLAUDE.md + numbered spec files) updated
|
||||
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema
|
||||
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal 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 ~190–200 unique humans (~20–25% 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; // 0–100
|
||||
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 50–89 — `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 0–5 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: 1k–10k. 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
|
||||
─→ 0–1 client (deduped against existing pool)
|
||||
─→ 0–1 client_address
|
||||
─→ 0–2 client_contacts (email, phone)
|
||||
─→ exactly 1 interest
|
||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
||||
─→ 0–1 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. ~5–7 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: ~10–12 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.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Website → CRM wiring refactor
|
||||
|
||||
The `website/` subrepo (Nuxt) currently writes inquiry submissions to NocoDB.
|
||||
The new CRM exposes its own public ingestion endpoints, so the website needs
|
||||
to be re-pointed at the CRM and the website's local server-side helpers can
|
||||
eventually be retired.
|
||||
|
||||
This document describes **what needs to change in the website repo**. Nothing
|
||||
here applies to the CRM repo — that side is already done.
|
||||
|
||||
## Endpoints the CRM now exposes
|
||||
|
||||
Both are unauthenticated, IP-rate-limited (5/hour), and require an explicit
|
||||
port id (query param `?portId=…` or header `X-Port-Id`).
|
||||
|
||||
| Form intent | New CRM endpoint | Old NocoDB target |
|
||||
| -------------------- | ---------------------------------------- | ------------------------ |
|
||||
| Berth interest | `POST /api/public/interests` | `Interests` (NocoDB) |
|
||||
| Residential interest | `POST /api/public/residential-inquiries` | `Interests (Residences)` |
|
||||
|
||||
Notification emails (client confirmation + sales-team alert) are sent by the
|
||||
CRM itself when these endpoints succeed, so the website's
|
||||
`sendRegistrationEmails` helper (`server/utils/email.ts`) is no longer
|
||||
required for these flows.
|
||||
|
||||
## Required changes in the website repo
|
||||
|
||||
### 1. New env vars
|
||||
|
||||
Add to `.env` and the deploy environment:
|
||||
|
||||
```
|
||||
PN_CRM_BASE_URL=https://crm.portnimara.com
|
||||
PN_CRM_PORT_ID=<uuid of the Port Nimara port row in CRM>
|
||||
```
|
||||
|
||||
`PN_CRM_BASE_URL` defaults to the prod CRM. In dev it can point to the local
|
||||
tunnel (`shoulder-contain-…trycloudflare.com`) so submissions hit a dev DB.
|
||||
|
||||
### 2. Refactor `server/api/register.ts`
|
||||
|
||||
Today the file owns both the berth and residence branches and writes to
|
||||
NocoDB directly. After the refactor, both branches just relay to the CRM:
|
||||
|
||||
```ts
|
||||
const baseUrl = process.env.PN_CRM_BASE_URL;
|
||||
const portId = process.env.PN_CRM_PORT_ID;
|
||||
|
||||
if (category === 'Residences') {
|
||||
await $fetch(`${baseUrl}/api/public/residential-inquiries?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
placeOfResidence: body.address,
|
||||
preferredContactMethod: body.method_of_contact, // 'email' | 'phone'
|
||||
notes: body.notes,
|
||||
// preferences: collect via new optional textarea (see section 4)
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Berth branch
|
||||
await $fetch(`${baseUrl}/api/public/interests?portId=${portId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
// map to the CRM's publicInterestSchema (see src/lib/validators/interests.ts)
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
berthSize: body.berth_size,
|
||||
berthMinLength: body.berth_min_length,
|
||||
berthMinWidth: body.berth_min_width,
|
||||
berthMinDraught: body.berth_min_draught,
|
||||
yachtName: body.berth_yacht_name,
|
||||
preferredMethodOfContact: body.method_of_contact,
|
||||
specificBerthMooring: body.berth, // optional, links interest to a specific berth
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
```
|
||||
|
||||
The reCAPTCHA verification stays in the website handler — the CRM trusts the
|
||||
website to gate its public endpoints.
|
||||
|
||||
### 3. Retire dead code
|
||||
|
||||
After step 2, the following can be deleted from the website:
|
||||
|
||||
- `server/utils/websiteInterests.ts`
|
||||
- `server/utils/residentialInterests.ts`
|
||||
- `server/utils/nocodb.ts`
|
||||
- The NocoDB-specific call sites in `server/utils/email.ts` (the CRM
|
||||
sends its own confirmation/alert emails)
|
||||
- NocoDB env vars (`NOCODB_*`)
|
||||
|
||||
The Nuxt `/api/berths` route stays as-is — it reads from the
|
||||
`directus_items.berths` collection for the public site, not the CRM.
|
||||
|
||||
### 4. Form additions on `pages/register.vue`
|
||||
|
||||
The current residence branch only collects contact info. The CRM accepts an
|
||||
optional `preferences` field (free-text) and `notes` field. Add a
|
||||
"Preferences" textarea inside the residences block of
|
||||
`components/pn/specific/website/register/form.vue`:
|
||||
|
||||
```vue
|
||||
<transition name="fade-down">
|
||||
<div v-show="interest === 'residences'">
|
||||
<vee-field
|
||||
as="textarea"
|
||||
class="form-input py-3 px-0 md:text-lg border-0 border-t border-davysgrey ..."
|
||||
placeholder="Tell us what you're looking for (unit type, budget, timeline)"
|
||||
name="residence_preferences"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
```
|
||||
|
||||
Append `preferences: body.residence_preferences` in the POST body in
|
||||
`server/api/register.ts`.
|
||||
|
||||
### 5. Stand up a residential-only `residences.vue` form (optional)
|
||||
|
||||
Today the residences interest is captured on `register.vue` via a radio. If
|
||||
the marketing team wants a dedicated CTA on `residences.vue`, add a small
|
||||
inline form using the same submit handler from step 2. No new endpoint —
|
||||
this is purely a UX addition.
|
||||
|
||||
## Deployment order
|
||||
|
||||
1. **CRM first**: deploy this repo, ensure `/api/public/interests` and
|
||||
`/api/public/residential-inquiries` are reachable from the website host.
|
||||
2. **Verify in CRM**: configure `Inquiry Contact Email` and (for residential)
|
||||
`Residential Notification Recipients` per port in
|
||||
admin → settings.
|
||||
3. **Smoke test from a dev tunnel** (curl the public endpoints with a JSON
|
||||
payload). Confirm rows land in `clients`/`residential_clients` and
|
||||
notification emails are received.
|
||||
4. **Then deploy website changes** (sections 1–3 above). The form
|
||||
submissions immediately start landing in the new CRM.
|
||||
5. **Cut-over note**: once the website is pointed at the CRM, leave the
|
||||
NocoDB tables read-only as a historical archive. Don't delete them until
|
||||
prod data has been imported into the new CRM (see "Prod data import
|
||||
strategy" task #59 in the task list).
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Port routing for multi-port deploys**: today the website only knows about
|
||||
Port Nimara. If/when the website serves multiple ports, the `portId`
|
||||
resolution needs to happen per-domain or per-route, not a single env var.
|
||||
- **Brand/email domain**: confirm whether residential confirmations should
|
||||
send from the same `noreply@letsbe.solutions` address as marina, or a
|
||||
dedicated residential mailbox. The CRM uses `SMTP_FROM`, which is global.
|
||||
@@ -18,12 +18,6 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
outputFileTracingIncludes: {
|
||||
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
|
||||
// runtime in the standalone build. Reading via fs.readFile from
|
||||
// process.cwd() requires the file to be traced explicitly.
|
||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/lib/db/seed.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --project=smoke",
|
||||
"test:e2e:exhaustive": "playwright test --project=exhaustive",
|
||||
"test:e2e:destructive": "playwright test --project=destructive",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -69,7 +65,6 @@
|
||||
"next-themes": "^0.4.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.0",
|
||||
@@ -96,9 +91,9 @@
|
||||
"@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",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testDir: './tests/e2e/smoke',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
@@ -22,53 +22,11 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /smoke\/global-setup\.ts/,
|
||||
testMatch: /global-setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'smoke',
|
||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exhaustive',
|
||||
testMatch: /exhaustive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'destructive',
|
||||
testMatch: /destructive\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Real-API tests hit live external services (Documenso, IMAP, etc.).
|
||||
// Opt-in only: pnpm exec playwright test --project=realapi
|
||||
name: 'realapi',
|
||||
testMatch: /realapi\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
timeout: 120_000,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Visual regression baselines. Regenerate with --update-snapshots after
|
||||
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
|
||||
// diffs against the committed PNGs.
|
||||
name: 'visual',
|
||||
testMatch: /visual\/.*\.spec\.ts/,
|
||||
testMatch: /\d{2}-.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -152,9 +152,6 @@ importers:
|
||||
openai:
|
||||
specifier: ^6.27.0
|
||||
version: 6.27.0(ws@8.18.3)(zod@3.25.76)
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@@ -4420,9 +4417,6 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
@@ -5381,9 +5375,6 @@ packages:
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -9677,13 +9668,6 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
dependencies:
|
||||
'@pdf-lib/standard-fonts': 1.0.0
|
||||
'@pdf-lib/upng': 1.0.1
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
performance-now@2.1.0: {}
|
||||
@@ -10859,8 +10843,6 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Dev-only helper: create (or upsert) a CRM better-auth user and mark them
|
||||
* super_admin. Idempotent — re-running with the same email will reset the
|
||||
* password.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main() {
|
||||
const [email, password, displayNameArg] = process.argv.slice(2);
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
'Usage: pnpm tsx scripts/dev-create-crm-user.ts <email> <password> [displayName]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = displayNameArg ?? email.split('@')[0] ?? 'User';
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
|
||||
try {
|
||||
// 1. Check if better-auth user already exists.
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
|
||||
`;
|
||||
|
||||
let userId: string;
|
||||
|
||||
if (existing.length > 0) {
|
||||
const row = existing[0];
|
||||
if (!row) throw new Error('unreachable');
|
||||
userId = row.id;
|
||||
console.log(`User ${email} exists (id=${userId}); resetting password.`);
|
||||
// Use better-auth's internal context to hash and update the credential.
|
||||
const ctx = await auth.$context;
|
||||
const hash = await ctx.password.hash(password);
|
||||
await sql`
|
||||
UPDATE account
|
||||
SET password = ${hash}, updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND provider_id = 'credential'
|
||||
`;
|
||||
} else {
|
||||
console.log(`Creating better-auth user ${email}…`);
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: { email, password, name: displayName },
|
||||
});
|
||||
userId = result.user.id;
|
||||
console.log(`Created user_id=${userId}`);
|
||||
}
|
||||
|
||||
// 2. Upsert user_profiles entry as super admin.
|
||||
const profile = await db
|
||||
.select()
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (profile.length === 0) {
|
||||
await db.insert(userProfiles).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
});
|
||||
console.log(`Created super_admin profile for ${userId}`);
|
||||
} else {
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ displayName, isSuperAdmin: true, isActive: true })
|
||||
.where(eq(userProfiles.userId, userId));
|
||||
console.log(`Updated profile for ${userId} (super_admin=true)`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`✓ Done. Sign in at http://localhost:3000/login with`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` password: ${password}`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
|
||||
* showing TO/FROM/subject/date so we can see what the dev mailbox is
|
||||
* actually receiving.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-imap-probe.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.env.IMAP_HOST!;
|
||||
const port = Number(process.env.IMAP_PORT ?? 993);
|
||||
const user = process.env.IMAP_USER!;
|
||||
const pass = process.env.IMAP_PASS!;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${user}@${host}:${port}…`);
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
console.log('Connected. Inbox status:');
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true, recent: true });
|
||||
console.log(' total:', status.messages, '| recent:', status.recent);
|
||||
|
||||
// Pull the last 10 by UID
|
||||
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
|
||||
const result = await client.search({ since });
|
||||
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
|
||||
console.log(`Found ${uids.length} messages in last 30min:`);
|
||||
for (const uid of uids) {
|
||||
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
|
||||
if (!msg || !msg.source) continue;
|
||||
const parsed = await simpleParser(msg.source);
|
||||
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
|
||||
.flatMap((a) => a.value.map((v) => v.address ?? ''))
|
||||
.join(', ');
|
||||
console.log(
|
||||
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
async function main() {
|
||||
const sql = postgres(env.DATABASE_URL);
|
||||
const users =
|
||||
await sql`SELECT id, email, name, email_verified, created_at FROM "user" ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user ---');
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
const profiles =
|
||||
await sql`SELECT user_id, display_name, is_super_admin, is_active FROM user_profiles ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- user_profiles ---');
|
||||
console.log(JSON.stringify(profiles, null, 2));
|
||||
const accounts =
|
||||
await sql`SELECT user_id, provider_id, account_id FROM account ORDER BY created_at DESC LIMIT 20`;
|
||||
console.log('--- account ---');
|
||||
console.log(JSON.stringify(accounts, null, 2));
|
||||
await sql.end();
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Dev-only helper: issue a CRM admin invite and send the activation email.
|
||||
* The email gets routed via EMAIL_REDIRECT_TO if that's set, so it always
|
||||
* lands in the dev inbox.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const email = args[0];
|
||||
if (!email) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-trigger-crm-invite.ts <email> [name] [--super]');
|
||||
process.exit(1);
|
||||
}
|
||||
const isSuperAdmin = args.includes('--super');
|
||||
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
|
||||
|
||||
const { inviteId, link } = await createCrmInvite({ email, name, isSuperAdmin });
|
||||
console.log(`✓ Invite created (id=${inviteId})`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` super_admin: ${isSuperAdmin}`);
|
||||
console.log(` activation link: ${link}`);
|
||||
console.log('');
|
||||
console.log('Email sent (subject permitting via EMAIL_REDIRECT_TO).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Dev-only helper: pick an existing client and trigger a portal-invite email.
|
||||
* The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless
|
||||
* of the per-portal-user `email` field — so we can use any throwaway address
|
||||
* here without conflicting with seed data.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-trigger-portal-invite.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
import { createPortalUser } from '@/lib/services/portal-auth.service';
|
||||
import { env } from '@/lib/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!env.EMAIL_REDIRECT_TO) {
|
||||
throw new Error(
|
||||
'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.',
|
||||
);
|
||||
}
|
||||
console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`);
|
||||
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'),
|
||||
});
|
||||
if (!client) throw new Error('No client found in port-nimara');
|
||||
|
||||
// Use the redirect target as the portal user's actual email, so the
|
||||
// tester can sign in with the same address that received the activation mail.
|
||||
const portalEmail = env.EMAIL_REDIRECT_TO;
|
||||
console.log(
|
||||
`Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}…`,
|
||||
);
|
||||
|
||||
// Clear any prior dev-script seed so uniqueness checks don't trip.
|
||||
await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id));
|
||||
await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail));
|
||||
|
||||
const result = await createPortalUser({
|
||||
clientId: client.id,
|
||||
portId: client.portId,
|
||||
email: portalEmail,
|
||||
name: client.fullName,
|
||||
createdBy: 'dev-script',
|
||||
});
|
||||
|
||||
console.log('Portal user created:', result);
|
||||
console.log(`Activation email enqueued — should arrive at ${portalEmail}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Script failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,5 +8,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center wave-watermark"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<div className="w-full max-w-md px-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
@@ -55,53 +55,64 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Marina CRM</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const resetSchema = z.object({
|
||||
@@ -49,55 +49,69 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">We'll email you a link</p>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Reset your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Check your email</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If an account exists for that email address, we have sent a password reset link.
|
||||
Please check your inbox and spam folder.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.email && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="font-medium text-gray-900">Check your email</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
If an account exists for that email address, we have sent a password reset link. Please
|
||||
check your inbox and spam folder.
|
||||
</p>
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending…' : 'Send reset link'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-[#007bff] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</BrandedAuthShell>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Remember your password?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
const MIN_LENGTH = 9;
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, 'Must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
@@ -27,11 +31,25 @@ const passwordSchema = z
|
||||
|
||||
type SetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
function SetPasswordInner() {
|
||||
type Requirement = {
|
||||
label: string;
|
||||
test: (value: string) => boolean;
|
||||
};
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
|
||||
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
|
||||
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
|
||||
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
|
||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||
];
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -43,7 +61,7 @@ function SetPasswordInner() {
|
||||
|
||||
async function onSubmit(data: SetPasswordFormData) {
|
||||
if (!token) {
|
||||
toast.error('Invalid or missing reset token. Please request a new link.');
|
||||
toast.error('Invalid or missing reset token. Please request a new password reset link.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +75,7 @@ function SetPasswordInner() {
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
|
||||
toast.error(body.message ?? 'Failed to set password. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,78 +88,89 @@ function SetPasswordInner() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please use the link from the email we sent you. If the link is broken, ask your
|
||||
administrator for a new one.
|
||||
</p>
|
||||
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
|
||||
<p className="text-sm text-muted-foreground">Set your password</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!token ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
Invalid or missing token. Please request a new password reset link.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.password && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('password', {
|
||||
onChange: (e) => setPasswordValue(e.target.value),
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('password')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
<ul className="space-y-1 pt-1">
|
||||
{requirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<li
|
||||
key={req.label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword &&
|
||||
'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Setting password…' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
|
||||
<SetPasswordInner />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'branding_app_name',
|
||||
label: 'App name',
|
||||
description: 'Shown in the email subject prefix and the in-app header.',
|
||||
type: 'string',
|
||||
placeholder: 'Port Nimara CRM',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_logo_url',
|
||||
label: 'Logo URL',
|
||||
description:
|
||||
'Public HTTPS URL of the logo used in email headers and the branded auth shell. Recommended size: 240×80 PNG with transparent background.',
|
||||
type: 'string',
|
||||
placeholder: 'https://example.com/logo.png',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_primary_color',
|
||||
label: 'Primary color',
|
||||
description: 'Used for buttons and links in transactional email templates.',
|
||||
type: 'color',
|
||||
defaultValue: '#1e293b',
|
||||
},
|
||||
{
|
||||
key: 'branding_email_header_html',
|
||||
label: 'Email header HTML',
|
||||
description: 'Optional HTML rendered above each email body. Leave blank to use the default.',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'branding_email_footer_html',
|
||||
label: 'Email footer HTML',
|
||||
description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandingSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell
|
||||
and outgoing email templates.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsFormCard
|
||||
title="Identity"
|
||||
description="App name, logo, and primary color."
|
||||
fields={FIELDS.slice(0, 3)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Email branding"
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
|
||||
const API_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_api_url_override',
|
||||
label: 'API URL override',
|
||||
description: 'Optional. Falls back to DOCUMENSO_API_URL env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'https://documenso.example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_key_override',
|
||||
label: 'API key override',
|
||||
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
const EOI_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_eoi_template_id',
|
||||
label: 'EOI Documenso template ID',
|
||||
description: 'Numeric template ID used by the Documenso EOI pathway.',
|
||||
type: 'string',
|
||||
placeholder: '12345',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'eoi_default_pathway',
|
||||
label: 'Default EOI pathway',
|
||||
description:
|
||||
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'documenso-template', label: 'Documenso template' },
|
||||
{ value: 'inapp', label: 'In-app (pdf-lib)' },
|
||||
],
|
||||
defaultValue: 'documenso-template',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Documenso & EOI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
API credentials and default EOI generation pathway. Use the test-connection button to
|
||||
verify a saved configuration before relying on it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Documenso API"
|
||||
description="Per-port API credentials. Leave blank to use the global env defaults."
|
||||
fields={API_FIELDS}
|
||||
extra={<DocumensoTestButton />}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="EOI generation"
|
||||
description="Default pathway and template used when an interest's EOI is generated."
|
||||
fields={EOI_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'email_from_name',
|
||||
label: 'From name',
|
||||
description: 'Display name shown in the From: header on outgoing email.',
|
||||
type: 'string',
|
||||
placeholder: 'Port Nimara',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_from_address',
|
||||
label: 'From address',
|
||||
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'noreply@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_reply_to',
|
||||
label: 'Reply-to address',
|
||||
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
|
||||
type: 'string',
|
||||
placeholder: 'sales@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_signature_html',
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>—<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_footer_html',
|
||||
label: 'Email footer (HTML)',
|
||||
description: 'Legal/contact footer rendered at the very bottom of all emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_host_override',
|
||||
label: 'SMTP host override',
|
||||
description: 'Optional. Falls back to SMTP_HOST env when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'mail.example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_port_override',
|
||||
label: 'SMTP port override',
|
||||
description: 'Optional. Falls back to SMTP_PORT env when blank.',
|
||||
type: 'number',
|
||||
placeholder: '587',
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
key: 'smtp_user_override',
|
||||
label: 'SMTP username override',
|
||||
description: 'Optional. Falls back to SMTP_USER env when blank.',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_pass_override',
|
||||
label: 'SMTP password override',
|
||||
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Email Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-port outgoing email configuration. SMTP credentials and the From address default to
|
||||
environment variables when these fields are blank.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsFormCard
|
||||
title="From address & signature"
|
||||
description="Identity headers and shared HTML used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 5)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="SMTP transport overrides"
|
||||
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
|
||||
fields={FIELDS.slice(5)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
|
||||
|
||||
export default function FormTemplatesPage() {
|
||||
return <FormTemplateList />;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
|
||||
<p className="text-muted-foreground">Create and manage intake form templates</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 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||
|
||||
export default function InvitationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Invitations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a single-use invitation to a new CRM user. The recipient sets their own password via
|
||||
the link in the email.
|
||||
</p>
|
||||
</div>
|
||||
<InvitationsManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
Briefcase,
|
||||
Database,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
Sliders,
|
||||
Tag,
|
||||
Upload,
|
||||
Users,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface AdminSection {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSection[] = [
|
||||
{
|
||||
href: 'users',
|
||||
label: 'Users',
|
||||
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: 'invitations',
|
||||
label: 'Invitations',
|
||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'roles',
|
||||
label: 'Roles & Permissions',
|
||||
description: 'Default permission sets and per-port role overrides.',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
href: 'audit',
|
||||
label: 'Audit Log',
|
||||
description: 'Searchable log of every authenticated mutation in the system.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'email',
|
||||
label: 'Email Settings',
|
||||
description: 'From address, signatures, and per-port SMTP overrides.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'documenso',
|
||||
label: 'Documenso & EOI',
|
||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'reminders',
|
||||
label: 'Reminders',
|
||||
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
href: 'branding',
|
||||
label: 'Branding',
|
||||
description: 'App name, logo, primary color, and email header/footer HTML.',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
href: 'settings',
|
||||
label: 'System Settings',
|
||||
description: 'Generic key/value configuration store for advanced flags.',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
href: 'webhooks',
|
||||
label: 'Webhooks',
|
||||
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
|
||||
icon: Webhook,
|
||||
},
|
||||
{
|
||||
href: 'forms',
|
||||
label: 'Forms',
|
||||
description: 'Form templates used by client-facing inquiry and intake flows.',
|
||||
icon: Sliders,
|
||||
},
|
||||
{
|
||||
href: 'templates',
|
||||
label: 'Document Templates',
|
||||
description: 'PDF + email templates with merge-field placeholders.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'tags',
|
||||
label: 'Tags',
|
||||
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
href: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
href: 'reports',
|
||||
label: 'Reports',
|
||||
description: 'Saved analytics views and ad-hoc query results.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
href: 'monitoring',
|
||||
label: 'Queue Monitoring',
|
||||
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
href: 'import',
|
||||
label: 'Bulk Import',
|
||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||
icon: Upload,
|
||||
},
|
||||
{
|
||||
href: 'backup',
|
||||
label: 'Backup & Restore',
|
||||
description: 'Database snapshots and on-demand exports.',
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
href: 'ports',
|
||||
label: 'Ports',
|
||||
description: 'Manage the marinas/ports this installation serves.',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
href: 'onboarding',
|
||||
label: 'Onboarding',
|
||||
description: 'Initial-setup wizard for fresh ports.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function AdminLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Administration</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-port configuration and system administration. Each card below opens a dedicated
|
||||
settings page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{SECTIONS.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
|
||||
const DEFAULT_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'reminder_default_enabled',
|
||||
label: 'Enable reminders by default on new interests',
|
||||
description:
|
||||
'When on, newly-created interests inherit reminderEnabled=true. Users can still toggle it on a per-interest basis.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'reminder_default_days',
|
||||
label: 'Default inactivity days',
|
||||
description:
|
||||
"Default value for an interest's reminderDays field. Reminders fire after this many days of no contact.",
|
||||
type: 'number',
|
||||
placeholder: '7',
|
||||
defaultValue: 7,
|
||||
},
|
||||
];
|
||||
|
||||
const DIGEST_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'reminder_digest_enabled',
|
||||
label: 'Batch reminders into a daily digest',
|
||||
description:
|
||||
'Off (default): reminders fire as soon as the threshold is hit. On: pending reminders are accumulated and delivered once per day at the digest time.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'reminder_digest_time',
|
||||
label: 'Digest delivery time',
|
||||
description: '24-hour HH:MM in the digest timezone.',
|
||||
type: 'string',
|
||||
placeholder: '09:00',
|
||||
defaultValue: '09:00',
|
||||
},
|
||||
{
|
||||
key: 'reminder_digest_timezone',
|
||||
label: 'Digest timezone',
|
||||
description: 'IANA timezone name used to interpret the delivery time (e.g. Europe/Warsaw).',
|
||||
type: 'string',
|
||||
placeholder: 'Europe/Warsaw',
|
||||
defaultValue: 'Europe/Warsaw',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReminderSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Reminders</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Default reminder behaviour for new interests and the optional daily-digest delivery
|
||||
window. Individual users can still configure their own digest preferences in Notifications
|
||||
→ Preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Defaults for new interests"
|
||||
description="Applied when an interest is created without an explicit reminder configuration."
|
||||
fields={DEFAULT_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Daily digest"
|
||||
description="Optional batching window so reminder notifications go out once per day instead of as they fire."
|
||||
fields={DIGEST_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { CompanyDetail } from '@/components/companies/company-detail';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface CompanyDetailPageProps {
|
||||
params: Promise<{ companyId: string }>;
|
||||
}
|
||||
|
||||
export default async function CompanyDetailPage({ params }: CompanyDetailPageProps) {
|
||||
const { companyId } = await params;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CompanyList } from '@/components/companies/company-list';
|
||||
|
||||
export default function CompaniesPage() {
|
||||
return <CompanyList />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
}
|
||||
@@ -1,47 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { EmailAccountsList } from '@/components/email/email-accounts-list';
|
||||
import { EmailThreadsList } from '@/components/email/email-threads-list';
|
||||
import { ComposeDialog } from '@/components/email/compose-dialog';
|
||||
|
||||
export default function EmailPage() {
|
||||
const [tab, setTab] = useState('threads');
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
||||
<p className="text-muted-foreground">Send and manage client communications</p>
|
||||
</div>
|
||||
<Button onClick={() => setComposeOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Compose
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Email</h1>
|
||||
<p className="text-muted-foreground">Send and manage client communications</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 3</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="threads">Inbox</TabsTrigger>
|
||||
<TabsTrigger value="accounts">Accounts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="threads" className="pt-4">
|
||||
<EmailThreadsList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts" className="pt-4">
|
||||
<EmailAccountsList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ComposeDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OwnerPicker } from '@/components/shared/owner-picker';
|
||||
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
|
||||
@@ -56,13 +55,7 @@ export default function NewInvoicePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
|
||||
|
||||
const watchedValues = watch();
|
||||
const lineItems = watchedValues.lineItems ?? [];
|
||||
@@ -94,7 +87,7 @@ export default function NewInvoicePage() {
|
||||
async function goNext() {
|
||||
if (step === 1) {
|
||||
const valid = await methods.trigger([
|
||||
'billingEntity',
|
||||
'clientName',
|
||||
'billingEmail',
|
||||
'billingAddress',
|
||||
'dueDate',
|
||||
@@ -119,7 +112,11 @@ export default function NewInvoicePage() {
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/${portSlug}/invoices`)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">New Invoice</h1>
|
||||
@@ -134,16 +131,22 @@ export default function NewInvoicePage() {
|
||||
step > s.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: step === s.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
||||
</div>
|
||||
<span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
step === s.id ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
{idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className="w-8 h-px bg-border mx-1" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,29 +160,18 @@ export default function NewInvoicePage() {
|
||||
<CardTitle className="text-base">Client Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Billing entity <span className="text-destructive">*</span>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="clientName">
|
||||
Client Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<OwnerPicker
|
||||
value={watchedValues.billingEntity ?? null}
|
||||
onChange={(ref) => {
|
||||
if (ref) {
|
||||
setValue('billingEntity', ref, { shouldValidate: true });
|
||||
}
|
||||
}}
|
||||
<Input
|
||||
id="clientName"
|
||||
{...register('clientName')}
|
||||
placeholder="Client or company name"
|
||||
/>
|
||||
{errors.billingEntity && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.billingEntity.message ??
|
||||
errors.billingEntity.id?.message ??
|
||||
errors.billingEntity.type?.message}
|
||||
</p>
|
||||
{errors.clientName && (
|
||||
<p className="text-xs text-destructive">{errors.clientName.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the client or company to invoice. Their name will be snapshotted into the
|
||||
invoice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -210,7 +202,11 @@ export default function NewInvoicePage() {
|
||||
<Label htmlFor="dueDate">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="dueDate" type="date" {...register('dueDate')} />
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
{...register('dueDate')}
|
||||
/>
|
||||
{errors.dueDate && (
|
||||
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
||||
)}
|
||||
@@ -220,9 +216,7 @@ export default function NewInvoicePage() {
|
||||
<Label>Payment Terms</Label>
|
||||
<Select
|
||||
defaultValue="net30"
|
||||
onValueChange={(v) =>
|
||||
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
|
||||
}
|
||||
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select terms" />
|
||||
@@ -290,19 +284,8 @@ export default function NewInvoicePage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Billing Entity</span>
|
||||
<p className="font-medium mt-0.5">
|
||||
{watchedValues.billingEntity ? (
|
||||
<>
|
||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
||||
<span className="text-xs opacity-60">
|
||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Not selected</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="text-muted-foreground">Client</span>
|
||||
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Due Date</span>
|
||||
@@ -310,7 +293,9 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payment Terms</span>
|
||||
<p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
|
||||
<p className="font-medium mt-0.5 capitalize">
|
||||
{watchedValues.paymentTerms}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Currency</span>
|
||||
@@ -369,7 +354,12 @@ export default function NewInvoicePage() {
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
>
|
||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
||||
|
||||
export default function NotificationPreferencesPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notification Preferences</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which notifications you receive and how.
|
||||
</p>
|
||||
</div>
|
||||
<NotificationPreferencesForm />
|
||||
<ReminderDigestForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
redirect(`/${portSlug}/dashboard` as any);
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ResidentialClientDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialClientDetail clientId={id} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ResidentialClientsList } from '@/components/residential/residential-clients-list';
|
||||
|
||||
export default function ResidentialClientsPage() {
|
||||
return <ResidentialClientsList />;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ResidentialInterestDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialInterestDetail interestId={id} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ResidentialInterestsList } from '@/components/residential/residential-interests-list';
|
||||
|
||||
export default function ResidentialInterestsPage() {
|
||||
return <ResidentialInterestsList />;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { YachtDetail } from '@/components/yachts/yacht-detail';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface YachtDetailPageProps {
|
||||
params: Promise<{ yachtId: string }>;
|
||||
}
|
||||
|
||||
export default async function YachtDetailPage({ params }: YachtDetailPageProps) {
|
||||
const { yachtId } = await params;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { YachtList } from '@/components/yachts/yacht-list';
|
||||
|
||||
export default function YachtsPage() {
|
||||
return <YachtList />;
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||
import { userPortRoles } from '@/lib/db/schema/users';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { SocketProvider } from '@/providers/socket-provider';
|
||||
import { PortProvider } from '@/providers/port-provider';
|
||||
@@ -17,44 +16,26 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user) redirect('/login');
|
||||
|
||||
// Super admins have implicit access to every port; everyone else only sees
|
||||
// ports they have an explicit user_port_roles row for.
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
// Load user's port assignments for PortProvider
|
||||
const portRoles = await db.query.userPortRoles.findMany({
|
||||
where: eq(userPortRoles.userId, session.user.id),
|
||||
with: { port: true, role: true },
|
||||
});
|
||||
|
||||
const ports = profile?.isSuperAdmin
|
||||
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
||||
: portRoles.map((pr) => pr.port);
|
||||
const ports = portRoles.map((pr) => pr.port);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||
user={{
|
||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<Sidebar portRoles={portRoles} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar
|
||||
ports={ports}
|
||||
user={{
|
||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||
<Topbar ports={ports} />
|
||||
<main className="flex-1 overflow-y-auto bg-background p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { PasswordSetForm } from '@/components/portal/password-set-form';
|
||||
|
||||
export default function PortalActivatePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PasswordSetForm
|
||||
endpoint="/api/portal/auth/activate"
|
||||
title="Activate your account"
|
||||
description="Welcome — choose a password to finish setting up your client portal account."
|
||||
successTitle="Account activated"
|
||||
successDescription="You can now sign in with your new password."
|
||||
submitLabel="Activate account"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
|
||||
import { Anchor, FileText, Receipt } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
@@ -21,12 +21,15 @@ export default async function PortalDashboardPage() {
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||
</h1>
|
||||
{dashboard.client.nationality && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
|
||||
{dashboard.client.companyName && (
|
||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
||||
)}
|
||||
{dashboard.client.yachtName && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<PortalCard
|
||||
title="Berth Interests"
|
||||
value={dashboard.counts.interests}
|
||||
@@ -48,33 +51,13 @@ export default async function PortalDashboardPage() {
|
||||
icon={Receipt}
|
||||
href="/portal/invoices"
|
||||
/>
|
||||
<PortalCard
|
||||
title="My Yachts"
|
||||
value={dashboard.counts.yachts}
|
||||
description="Vessels you own directly or through a company"
|
||||
icon={Sailboat}
|
||||
href="/portal/my-yachts"
|
||||
/>
|
||||
<PortalCard
|
||||
title="My Memberships"
|
||||
value={dashboard.counts.memberships}
|
||||
description="Companies where you hold an active role"
|
||||
icon={Building2}
|
||||
/>
|
||||
<PortalCard
|
||||
title="My Active Reservations"
|
||||
value={dashboard.counts.activeReservations}
|
||||
description="Current and pending berth reservations"
|
||||
icon={CalendarCheck}
|
||||
href="/portal/my-reservations"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
|
||||
your account. All changes must be made through your port contact.
|
||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
|
||||
of your account. All changes must be made through your port contact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
export default function PortalForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
// Always returns 200 — caller never sees whether email exists.
|
||||
await fetch('/api/portal/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
} finally {
|
||||
setSubmitted(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
||||
<CheckCircle2 className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
If <strong>{email}</strong> matches a portal account, we've sent a reset link. The
|
||||
link expires in 30 minutes.
|
||||
</p>
|
||||
<Link
|
||||
href="/portal/login"
|
||||
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remember your password?{' '}
|
||||
<Link href="/portal/login" className="text-[#007bff] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Mail, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') ?? '/portal/dashboard';
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -26,90 +18,101 @@ export default function PortalLoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/portal/auth/sign-in', {
|
||||
const res = await fetch('/api/portal/auth/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError((data as { error?: string }).error ?? 'Invalid email or password');
|
||||
setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// typedRoutes: `next` is a runtime string we can't statically check.
|
||||
router.replace(next as never);
|
||||
router.refresh();
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError('Unable to connect. Please try again.');
|
||||
setError('Unable to connect. Please check your connection and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
||||
<Mail className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
If <strong>{email}</strong> is associated with a client account, you will receive a
|
||||
sign-in link shortly. The link expires in 24 hours.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSubmitted(false); setEmail(''); }}
|
||||
className="mt-6 text-sm text-[#1e2844] hover:underline"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Enter your email to receive a sign-in link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending link...
|
||||
</>
|
||||
) : (
|
||||
'Send sign-in link'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||
disabled={loading || !email || !password}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">
|
||||
This portal is for existing clients only.
|
||||
</p>
|
||||
</BrandedAuthShell>
|
||||
<p className="text-center text-xs text-gray-400 mt-4">
|
||||
This portal is for existing clients only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { CalendarCheck } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getPortalUserReservations } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export const metadata: Metadata = { title: 'My Reservations' };
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
pending: 'secondary',
|
||||
active: 'default',
|
||||
ended: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const TENURE_LABELS: Record<string, string> = {
|
||||
permanent: 'Permanent',
|
||||
fixed_term: 'Fixed term',
|
||||
seasonal: 'Seasonal',
|
||||
};
|
||||
|
||||
function formatDate(d: Date | string): string {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PortalMyReservationsPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const reservations = await getPortalUserReservations(session.clientId, session.portId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
|
||||
</div>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No active reservations</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Contact your port representative to discuss reservations.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reservations.map((r) => (
|
||||
<div key={r.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||
{r.berthMooringNumber && (
|
||||
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
|
||||
<span>
|
||||
From {formatDate(r.startDate)}
|
||||
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Sailboat } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getPortalUserYachts } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export const metadata: Metadata = { title: 'My Yachts' };
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
retired: 'secondary',
|
||||
sold_away: 'outline',
|
||||
};
|
||||
|
||||
export default async function PortalMyYachtsPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const yachts = await getPortalUserYachts(session.clientId, session.portId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
|
||||
</div>
|
||||
|
||||
{yachts.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No yachts on file</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Yachts owned by you or a company you are a member of will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{yachts.map((y) => (
|
||||
<div key={y.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{y.name}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
|
||||
{y.flag ? ` · ${y.flag}` : ''}
|
||||
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
|
||||
</p>
|
||||
{y.ownerContext === 'company' && y.ownerCompanyName && (
|
||||
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
|
||||
{y.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{(y.lengthFt || y.widthFt || y.registration) && (
|
||||
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||
{y.registration && <span>Reg: {y.registration}</span>}
|
||||
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
|
||||
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { PasswordSetForm } from '@/components/portal/password-set-form';
|
||||
|
||||
export default function PortalResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PasswordSetForm
|
||||
endpoint="/api/portal/auth/reset-password"
|
||||
title="Choose a new password"
|
||||
description="Enter a new password to regain access to your client portal."
|
||||
successTitle="Password updated"
|
||||
successDescription="You can now sign in with your new password."
|
||||
submitLabel="Update password"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
35
src/app/(portal)/portal/verify/page.tsx
Normal file
35
src/app/(portal)/portal/verify/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function PortalVerifyPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const calledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (calledRef.current) return;
|
||||
calledRef.current = true;
|
||||
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
router.replace('/portal/login?error=missing_token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to the verify API route which will set the cookie and redirect
|
||||
window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844] mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">Verifying your access...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await consumeCrmInvite({
|
||||
token: parsed.data.token,
|
||||
password: parsed.data.password,
|
||||
});
|
||||
return NextResponse.json({ success: true, email: result.email });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await activateAccount(parsed.data.token, parsed.data.password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({ email: z.string().email() });
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Always return 200 to prevent account-enumeration. Errors are logged
|
||||
// server-side, never surfaced to the client.
|
||||
try {
|
||||
await requestPasswordReset(parsed.data.email);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
28
src/app/api/portal/auth/request/route.ts
Normal file
28
src/app/api/portal/auth/request/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requestMagicLink } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
|
||||
}
|
||||
|
||||
await requestMagicLink(parsed.data.email);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal magic link request failed');
|
||||
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPassword(parsed.data.token, parsed.data.password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||
import { signIn } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24h, matches createPortalToken
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn(parsed.data);
|
||||
const res = NextResponse.json({ success: true });
|
||||
res.cookies.set(PORTAL_COOKIE, result.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
38
src/app/api/portal/auth/verify/route.ts
Normal file
38
src/app/api/portal/auth/verify/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { verifyPortalToken, PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const token = req.nextUrl.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/portal/login?error=missing_token', env.APP_URL));
|
||||
}
|
||||
|
||||
const session = await verifyPortalToken(token);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL('/portal/login?error=invalid_token', env.APP_URL));
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(new URL('/portal/dashboard', env.APP_URL));
|
||||
|
||||
response.cookies.set(PORTAL_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
logger.info({ clientId: session.clientId }, 'Portal session created');
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal token verification failed');
|
||||
return NextResponse.redirect(new URL('/portal/login?error=server_error', env.APP_URL));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
@@ -39,14 +35,7 @@ function checkRateLimit(ip: string): void {
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||
// `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts).
|
||||
// Keep the helper aligned with that.
|
||||
type Tx = typeof db;
|
||||
|
||||
// POST /api/public/interests — unauthenticated public interest registration.
|
||||
// Creates the trio (client + yacht + interest) plus an optional company +
|
||||
// membership, all inside a single transaction.
|
||||
// POST /api/public/interests — unauthenticated public interest registration
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
@@ -61,6 +50,7 @@ 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}`
|
||||
@@ -68,10 +58,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
||||
// to do outside the transaction.
|
||||
// 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)),
|
||||
@@ -82,172 +72,74 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transactional trio creation ────────────────────────────────────────
|
||||
const result = await withTransaction(async (tx) => {
|
||||
// 1. Find or create client by email (case-sensitive contact match, same
|
||||
// behavior as before the refactor).
|
||||
let clientId: string;
|
||||
const existingContact = await tx.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
});
|
||||
if (existingContact) {
|
||||
const existingClient = await tx.query.clients.findFirst({
|
||||
where: eq(clients.id, existingContact.clientId),
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
if (data.preferredContactMethod) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
}
|
||||
// Find or create client by email
|
||||
let clientId: string;
|
||||
|
||||
// 2. Optional: upsert company + add membership
|
||||
let companyId: string | null = null;
|
||||
if (data.company) {
|
||||
const existingCompany = await tx.query.companies.findFirst({
|
||||
where: and(
|
||||
eq(companies.portId, portId),
|
||||
sql`lower(${companies.name}) = lower(${data.company.name})`,
|
||||
),
|
||||
});
|
||||
if (existingCompany) {
|
||||
companyId = existingCompany.id;
|
||||
} else {
|
||||
const [newCompany] = await tx
|
||||
.insert(companies)
|
||||
.values({
|
||||
portId,
|
||||
name: data.company.name,
|
||||
legalName: data.company.legalName ?? null,
|
||||
taxId: data.company.taxId ?? null,
|
||||
incorporationCountry: data.company.incorporationCountry ?? null,
|
||||
status: 'active',
|
||||
})
|
||||
.returning();
|
||||
companyId = newCompany!.id;
|
||||
}
|
||||
|
||||
// Add active membership only if one doesn't already exist (open row).
|
||||
const existingMembership = await tx.query.companyMemberships.findFirst({
|
||||
where: and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.clientId, clientId),
|
||||
isNull(companyMemberships.endDate),
|
||||
),
|
||||
});
|
||||
if (!existingMembership) {
|
||||
await tx.insert(companyMemberships).values({
|
||||
companyId,
|
||||
clientId,
|
||||
role: data.company.role ?? 'representative',
|
||||
startDate: new Date(),
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create yacht. Owner is the company when provided, else the client.
|
||||
const ownerType: 'client' | 'company' = companyId ? 'company' : 'client';
|
||||
const ownerId = companyId ?? clientId;
|
||||
const [newYacht] = await tx
|
||||
.insert(yachts)
|
||||
.values({
|
||||
portId,
|
||||
name: data.yacht.name,
|
||||
hullNumber: data.yacht.hullNumber ?? null,
|
||||
registration: data.yacht.registration ?? null,
|
||||
flag: data.yacht.flag ?? null,
|
||||
yearBuilt: data.yacht.yearBuilt ?? null,
|
||||
lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null,
|
||||
widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null,
|
||||
draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null,
|
||||
currentOwnerType: ownerType,
|
||||
currentOwnerId: ownerId,
|
||||
status: 'active',
|
||||
})
|
||||
.returning();
|
||||
const yachtId = newYacht!.id;
|
||||
|
||||
// 3a. Open ownership_history row for the new yacht.
|
||||
await tx.insert(yachtOwnershipHistory).values({
|
||||
yachtId,
|
||||
ownerType,
|
||||
ownerId,
|
||||
startDate: new Date(),
|
||||
endDate: null,
|
||||
createdBy: 'public-submission',
|
||||
});
|
||||
|
||||
// 4. Store address if provided AND no primary address exists yet.
|
||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||
const existingAddr = await tx.query.clientAddresses.findFirst({
|
||||
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
|
||||
});
|
||||
if (!existingAddr) {
|
||||
await tx.insert(clientAddresses).values({
|
||||
clientId,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create interest with yachtId wired up.
|
||||
const [newInterest] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
berthId,
|
||||
yachtId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
interestId: newInterest!.id,
|
||||
clientId,
|
||||
yachtId,
|
||||
companyId,
|
||||
};
|
||||
const existingContact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
});
|
||||
|
||||
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
|
||||
if (existingContact) {
|
||||
const existingClient = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, existingContact.clientId),
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
// Update preferred contact method if provided
|
||||
if (data.preferredContactMethod) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
} else {
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
|
||||
// Store address if provided
|
||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the interest
|
||||
const [interest] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
berthId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: null as unknown as string,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'interest',
|
||||
entityId: result.interestId,
|
||||
newValue: {
|
||||
clientId: result.clientId,
|
||||
yachtId: result.yachtId,
|
||||
companyId: result.companyId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
berthId,
|
||||
},
|
||||
entityId: interest!.id,
|
||||
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 },
|
||||
@@ -256,7 +148,7 @@ export async function POST(req: NextRequest) {
|
||||
void sendInquiryNotifications({
|
||||
portId,
|
||||
portSlug: port?.slug ?? portId,
|
||||
interestId: result.interestId,
|
||||
interestId: interest!.id,
|
||||
clientFullName: fullName,
|
||||
clientEmail: data.email,
|
||||
clientPhone: data.phone,
|
||||
@@ -265,7 +157,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
||||
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -273,33 +165,46 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createClientInTx(
|
||||
tx: Tx,
|
||||
async function createNewClient(
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
data: {
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
yachtName?: string;
|
||||
yachtLengthFt?: number;
|
||||
yachtWidthFt?: number;
|
||||
yachtDraftFt?: number;
|
||||
preferredBerthSize?: string;
|
||||
preferredContactMethod?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const [newClient] = await tx
|
||||
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 tx.insert(clientContacts).values({
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await tx.insert(clientContacts).values({
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
|
||||
|
||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_HITS = 5;
|
||||
|
||||
function checkRateLimit(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = ipHits.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return;
|
||||
}
|
||||
if (entry.count >= MAX_HITS) {
|
||||
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
|
||||
}
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
||||
* the public website's residential interest form. Creates a
|
||||
* `residential_clients` row and an opening `residential_interests` row in a
|
||||
* single transaction.
|
||||
*
|
||||
* Required: `portId` query param or `X-Port-Id` header.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
checkRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicResidentialInquirySchema.parse(body);
|
||||
|
||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||
if (!portId) {
|
||||
throw new ValidationError('portId is required');
|
||||
}
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) {
|
||||
throw new ValidationError('Unknown port');
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [client] = await tx
|
||||
.insert(residentialClients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
source: 'website',
|
||||
status: 'prospect',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
if (!client) throw new Error('Failed to create residential client');
|
||||
|
||||
const [interest] = await tx
|
||||
.insert(residentialInterests)
|
||||
.values({
|
||||
portId,
|
||||
residentialClientId: client.id,
|
||||
pipelineStage: 'new',
|
||||
source: 'website',
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
})
|
||||
.returning();
|
||||
if (!interest) throw new Error('Failed to create residential interest');
|
||||
|
||||
return { clientId: client.id, interestId: interest.id };
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
||||
|
||||
// Send notification emails (non-blocking — failures shouldn't 500 the
|
||||
// public form).
|
||||
void sendResidentialNotifications({
|
||||
portId,
|
||||
data,
|
||||
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
||||
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
||||
|
||||
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResidentialNotifications(args: {
|
||||
portId: string;
|
||||
data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
};
|
||||
crmDeepLink: string;
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation({
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
});
|
||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||
|
||||
// Sales-team alert — pull recipients from system_settings if configured;
|
||||
// fall back to the inquiry_contact_email if available.
|
||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||
where: and(
|
||||
eq(systemSettings.key, 'residential_notification_recipients'),
|
||||
eq(systemSettings.portId, portId),
|
||||
),
|
||||
});
|
||||
const fallbackRow = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
|
||||
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
|
||||
const fallback =
|
||||
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
|
||||
? [fallbackRow.value]
|
||||
: [];
|
||||
const recipients = configured.length > 0 ? configured : fallback;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
logger.warn(
|
||||
{ portId },
|
||||
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert({
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
crmDeepLink,
|
||||
});
|
||||
|
||||
await sendEmail(recipients, alert.subject, alert.html);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
||||
|
||||
/**
|
||||
* Admin probe — calls Documenso /api/v1/health using the port's effective
|
||||
* config. Used by the "Test connection" button on /admin/documenso.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
const result = await checkDocumensoHealth(ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
deleteFormTemplate,
|
||||
getFormTemplateById,
|
||||
updateFormTemplate,
|
||||
} from '@/lib/services/form-templates.service';
|
||||
import { updateFormTemplateSchema } from '@/lib/validators/form-templates';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
const tpl = await getFormTemplateById(params.id, ctx.portId);
|
||||
return NextResponse.json({ data: tpl });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
const body = await parseBody(req, updateFormTemplateSchema);
|
||||
const tpl = await updateFormTemplate(params.id, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: tpl });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!params.id) throw new NotFoundError('Form template');
|
||||
await deleteFormTemplate(params.id, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createFormTemplate, listFormTemplates } from '@/lib/services/form-templates.service';
|
||||
import { createFormTemplateSchema } from '@/lib/validators/form-templates';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listFormTemplates(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_forms', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createFormTemplateSchema);
|
||||
const tpl = await createFormTemplate(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: tpl }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const result = await resendCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
await revokeCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, _ctx) => {
|
||||
try {
|
||||
const data = await listCrmInvites();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const createInviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
isSuperAdmin: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, _ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createInviteSchema);
|
||||
const result = await createCrmInvite(body);
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,114 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { requirePermission } from '@/lib/auth/permissions';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
activate,
|
||||
cancel,
|
||||
endReservation,
|
||||
getById,
|
||||
} from '@/lib/services/berth-reservations.service';
|
||||
|
||||
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
||||
|
||||
const patchBodySchema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('activate'),
|
||||
contractFileId: z.string().optional(),
|
||||
effectiveDate: z.coerce.date().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('end'),
|
||||
endDate: z.coerce.date(),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('cancel'),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||
try {
|
||||
const reservation = await getById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: reservation });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, patchBodySchema);
|
||||
const meta = {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
};
|
||||
|
||||
if (body.action === 'activate') {
|
||||
requirePermission(ctx, 'reservations', 'activate');
|
||||
const result = await activate(
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
{
|
||||
contractFileId: body.contractFileId,
|
||||
effectiveDate: body.effectiveDate,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
return NextResponse.json({ data: result });
|
||||
}
|
||||
|
||||
if (body.action === 'end') {
|
||||
// `end` is lifecycle progression; same privilege as activate.
|
||||
requirePermission(ctx, 'reservations', 'activate');
|
||||
const result = await endReservation(
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
{ endDate: body.endDate, notes: body.notes },
|
||||
meta,
|
||||
);
|
||||
return NextResponse.json({ data: result });
|
||||
}
|
||||
|
||||
// action === 'cancel'
|
||||
requirePermission(ctx, 'reservations', 'cancel');
|
||||
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
||||
try {
|
||||
await cancel(
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
{},
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
||||
// on the `action` field in the body. `requirePermission` is called inside the
|
||||
// handler after the body is parsed.
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
export const DELETE = withAuth(withPermission('reservations', 'cancel', deleteHandler));
|
||||
@@ -1,65 +0,0 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { NotFoundError, errorResponse } from '@/lib/errors';
|
||||
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
|
||||
|
||||
// URL berthId is authoritative; make body berthId optional (ignored anyway).
|
||||
const createPendingBodySchema = createPendingSchema
|
||||
.omit({ berthId: true })
|
||||
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
|
||||
|
||||
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
}
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
const query = parseQuery(req, listReservationsSchema);
|
||||
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await assertBerthInPort(params.id!, ctx.portId);
|
||||
const body = await parseBody(req, createPendingBodySchema);
|
||||
const reservation = await createPending(
|
||||
ctx.portId,
|
||||
{ ...body, berthId: params.id! },
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: reservation }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('reservations', 'create', createHandler));
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service';
|
||||
import { db } from '@/lib/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/clients/:id/portal-user
|
||||
*
|
||||
* Admin creates a portal account for a client and triggers the activation
|
||||
* email. Idempotent in spirit: if a portal user already exists for the
|
||||
* email, returns 409 — the admin can resend the activation via
|
||||
* ?action=resend.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const action = url.searchParams.get('action');
|
||||
|
||||
if (action === 'resend') {
|
||||
// Body is optional in resend mode; the portal user id is the path id
|
||||
// in this case (not the client id). Looking up by client+email so
|
||||
// admins don't have to track portal-user ids.
|
||||
const body = await parseBody(req, inviteSchema);
|
||||
const existing = await db.query.portalUsers.findFirst({
|
||||
where: eq(portalUsers.email, body.email.toLowerCase().trim()),
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Portal user not found' }, { status: 404 });
|
||||
}
|
||||
await resendActivation(existing.id, ctx.portId);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const body = await parseBody(req, inviteSchema);
|
||||
const result = await createPortalUser({
|
||||
clientId: params.id!,
|
||||
portId: ctx.portId,
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
createdBy: ctx.userId,
|
||||
});
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
|
||||
import { updateCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const company = await getCompanyById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: company });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateCompanySchema);
|
||||
const updated = await updateCompany(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveCompany(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
||||
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateMembershipSchema);
|
||||
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
let endDate = new Date();
|
||||
const text = await req.text();
|
||||
if (text.length > 0) {
|
||||
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
||||
endDate = parsed.endDate;
|
||||
}
|
||||
await endMembership(
|
||||
params.mid!,
|
||||
ctx.portId,
|
||||
{ endDate },
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setPrimary } from '@/lib/services/company-memberships.service';
|
||||
|
||||
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
||||
try {
|
||||
const membership = await setPrimary(params.mid!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: membership });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
||||
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
activeOnly: z
|
||||
.enum(['true', 'false'])
|
||||
.transform((v) => v === 'true')
|
||||
.default('true'),
|
||||
});
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { activeOnly } = parseQuery(req, listQuerySchema);
|
||||
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
||||
return NextResponse.json({ data: memberships });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, addMembershipSchema);
|
||||
const membership = await addMembership(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: membership }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { updateNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
const body = await parseBody(req, updateNoteSchema);
|
||||
const note = await notesService.update(ctx.portId, 'companies', companyId, noteId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'company_note',
|
||||
entityId: noteId,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('companies', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
await notesService.deleteNote(ctx.portId, 'companies', companyId, noteId);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'company_note',
|
||||
entityId: noteId,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { createNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('companies', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
const notes = await notesService.listForEntity(ctx.portId, 'companies', companyId);
|
||||
return NextResponse.json({ data: notes });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const companyId = params.id;
|
||||
if (!companyId) throw new NotFoundError('Company');
|
||||
const body = await parseBody(req, createNoteSchema);
|
||||
const note = await notesService.create(ctx.portId, 'companies', companyId, ctx.userId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'company_note',
|
||||
entityId: note.id,
|
||||
metadata: { companyId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('companies', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('companies', 'delete', deleteHandler));
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setCompanyTags } from '@/lib/services/companies.service';
|
||||
|
||||
const setTagsSchema = z.object({
|
||||
tagIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const { tagIds } = await parseBody(req, setTagsSchema);
|
||||
await setCompanyTags(params.id!, ctx.portId, tagIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { autocomplete } from '@/lib/services/companies.service';
|
||||
|
||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const q = req.nextUrl.searchParams.get('q');
|
||||
if (!q) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
const companies = await autocomplete(ctx.portId, q);
|
||||
return NextResponse.json({ data: companies });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listCompanies, createCompany } from '@/lib/services/companies.service';
|
||||
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listCompaniesSchema);
|
||||
const result = await listCompanies(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createCompanySchema);
|
||||
const company = await createCompany(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: company }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('companies', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('companies', 'create', createHandler));
|
||||
@@ -11,7 +11,7 @@ export const POST = withAuth(
|
||||
try {
|
||||
const body = await parseBody(req, generateAndSignSchema);
|
||||
const result = await generateAndSign(
|
||||
params.id === 'documenso-template' ? null : params.id!,
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
{
|
||||
clientId: body.clientId,
|
||||
@@ -19,7 +19,6 @@ export const POST = withAuth(
|
||||
berthId: body.berthId,
|
||||
},
|
||||
body.signers,
|
||||
body.pathway,
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setYachtTags } from '@/lib/services/yachts.service';
|
||||
import { generateEoi } from '@/lib/services/documents.service';
|
||||
import { generateEoiSchema } from '@/lib/validators/documents';
|
||||
|
||||
const setTagsSchema = z.object({
|
||||
tagIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const { tagIds } = await parseBody(req, setTagsSchema);
|
||||
await setYachtTags(params.id!, ctx.portId, tagIds, {
|
||||
const body = await parseBody(req, generateEoiSchema);
|
||||
const doc = await generateEoi(body.interestId, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ data: doc }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveResidentialClient,
|
||||
getResidentialClientById,
|
||||
updateResidentialClient,
|
||||
} from '@/lib/services/residential.service';
|
||||
import { updateResidentialClientSchema } from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const client = await getResidentialClientById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: client });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('residential_clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateResidentialClientSchema);
|
||||
const updated = await updateResidentialClient(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('residential_clients', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveResidentialClient(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createResidentialClient,
|
||||
listResidentialClients,
|
||||
} from '@/lib/services/residential.service';
|
||||
import {
|
||||
createResidentialClientSchema,
|
||||
listResidentialClientsSchema,
|
||||
} from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listResidentialClientsSchema);
|
||||
const result = await listResidentialClients(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('residential_clients', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createResidentialClientSchema);
|
||||
const client = await createResidentialClient(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: client }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveResidentialInterest,
|
||||
getResidentialInterestById,
|
||||
updateResidentialInterest,
|
||||
} from '@/lib/services/residential.service';
|
||||
import { updateResidentialInterestSchema } from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_interests', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const interest = await getResidentialInterestById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: interest });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('residential_interests', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateResidentialInterestSchema);
|
||||
const updated = await updateResidentialInterest(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('residential_interests', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveResidentialInterest(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createResidentialInterest,
|
||||
listResidentialInterests,
|
||||
} from '@/lib/services/residential.service';
|
||||
import {
|
||||
createResidentialInterestSchema,
|
||||
listResidentialInterestsSchema,
|
||||
} from '@/lib/validators/residential';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('residential_interests', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listResidentialInterestsSchema);
|
||||
const result = await listResidentialInterests(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('residential_interests', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createResidentialInterestSchema);
|
||||
const interest = await createResidentialInterest(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: interest }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
});
|
||||
return NextResponse.json({ data: profile?.preferences ?? {} });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
const patch = await parseBody(req, updateUserPreferencesSchema);
|
||||
|
||||
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 next: UserPreferences = {
|
||||
...(profile.preferences ?? {}),
|
||||
...patch,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({ preferences: next })
|
||||
.where(eq(userProfiles.userId, ctx.userId));
|
||||
|
||||
return NextResponse.json({ data: next });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
|
||||
import { updateYachtSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const yacht = await getYachtById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: yacht });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateYachtSchema);
|
||||
const updated = await updateYacht(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveYacht(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { updateNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
const body = await parseBody(req, updateNoteSchema);
|
||||
const note = await notesService.update(ctx.portId, 'yachts', yachtId, noteId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'yacht_note',
|
||||
entityId: noteId,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('yachts', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
const noteId = params.noteId;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
if (!noteId) throw new NotFoundError('Note');
|
||||
await notesService.deleteNote(ctx.portId, 'yachts', yachtId, noteId);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'yacht_note',
|
||||
entityId: noteId,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { createNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('yachts', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
const notes = await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
|
||||
return NextResponse.json({ data: notes });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('yachts', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const yachtId = params.id;
|
||||
if (!yachtId) throw new NotFoundError('Yacht');
|
||||
const body = await parseBody(req, createNoteSchema);
|
||||
const note = await notesService.create(ctx.portId, 'yachts', yachtId, ctx.userId, body);
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'yacht_note',
|
||||
entityId: note.id,
|
||||
metadata: { yachtId },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
||||
|
||||
export const historyHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: history });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
||||
@@ -1,7 +0,0 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('yachts', 'delete', deleteHandler));
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { transferOwnership } from '@/lib/services/yachts.service';
|
||||
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
||||
|
||||
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, transferOwnershipSchema);
|
||||
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: yacht });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { autocomplete } from '@/lib/services/yachts.service';
|
||||
|
||||
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const q = req.nextUrl.searchParams.get('q');
|
||||
if (!q) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
const yachts = await autocomplete(ctx.portId, q);
|
||||
return NextResponse.json({ data: yachts });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user