From e8d61c91c461061dd8d13c41c25c28825a7e9f84 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Mon, 27 Apr 2026 21:54:32 +0200 Subject: [PATCH] feat(platform): residential module + admin UI + reliability fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale ) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 + CLAUDE.md | 9 +- docs/website-refactor.md | 160 + scripts/dev-create-crm-user.ts | 102 + scripts/dev-list-users.ts | 25 + scripts/dev-trigger-crm-invite.ts | 36 + src/app/(auth)/layout.tsx | 11 +- src/app/(auth)/login/page.tsx | 105 +- src/app/(auth)/reset-password/page.tsx | 112 +- src/app/(auth)/set-password/page.tsx | 181 +- .../[portSlug]/admin/forms/page.tsx | 17 +- .../(dashboard)/[portSlug]/dashboard/page.tsx | 5 + src/app/(dashboard)/[portSlug]/email/page.tsx | 49 +- .../notifications/preferences/page.tsx | 15 + src/app/(dashboard)/[portSlug]/page.tsx | 8 +- .../residential/clients/[id]/page.tsx | 10 + .../[portSlug]/residential/clients/page.tsx | 5 + .../residential/interests/[id]/page.tsx | 10 + .../[portSlug]/residential/interests/page.tsx | 5 + src/app/(dashboard)/layout.tsx | 22 +- .../(portal)/portal/forgot-password/page.tsx | 110 +- src/app/(portal)/portal/login/page.tsx | 6 +- src/app/api/auth/set-password/route.ts | 37 + .../api/public/residential-inquiries/route.ts | 176 + .../api/v1/admin/form-templates/[id]/route.ts | 58 + src/app/api/v1/admin/form-templates/route.ts | 35 + .../v1/berths/[id]/reservations/handlers.ts | 65 + .../api/v1/berths/[id]/reservations/route.ts | 70 +- src/app/api/v1/companies/[id]/handlers.ts | 45 + .../v1/companies/[id]/notes/[noteId]/route.ts | 63 + src/app/api/v1/companies/[id]/notes/route.ts | 47 + src/app/api/v1/companies/[id]/route.ts | 46 +- src/app/api/v1/companies/[id]/tags/route.ts | 28 + src/app/api/v1/companies/handlers.ts | 44 + src/app/api/v1/companies/route.ts | 45 +- .../api/v1/residential/clients/[id]/route.ts | 55 + src/app/api/v1/residential/clients/route.ts | 54 + .../v1/residential/interests/[id]/route.ts | 55 + src/app/api/v1/residential/interests/route.ts | 54 + src/app/api/v1/yachts/[id]/handlers.ts | 45 + .../v1/yachts/[id]/notes/[noteId]/route.ts | 63 + src/app/api/v1/yachts/[id]/notes/route.ts | 47 + src/app/api/v1/yachts/[id]/route.ts | 46 +- src/app/api/v1/yachts/[id]/tags/route.ts | 28 + src/app/api/v1/yachts/handlers.ts | 44 + src/app/api/v1/yachts/route.ts | 45 +- src/app/dashboard/page.tsx | 38 + .../admin/forms/form-template-form.tsx | 243 + .../admin/forms/form-template-list.tsx | 123 + src/components/admin/roles/role-form.tsx | 8 + .../admin/settings/settings-manager.tsx | 16 + src/components/admin/users/user-form.tsx | 21 + .../berths/berth-status-suggestion-dialog.tsx | 15 +- .../berths/waiting-list-manager.tsx | 40 +- src/components/clients/client-columns.tsx | 36 +- .../clients/client-detail-header.tsx | 30 +- src/components/clients/client-detail.tsx | 1 + src/components/clients/client-tabs.tsx | 172 +- src/components/clients/contacts-editor.tsx | 329 + src/components/companies/company-tabs.tsx | 186 +- src/components/email/compose-dialog.tsx | 153 + src/components/email/email-accounts-list.tsx | 264 + src/components/email/email-threads-list.tsx | 70 + src/components/interests/interest-tabs.tsx | 117 +- src/components/interests/pipeline-board.tsx | 23 +- src/components/invoices/invoice-detail.tsx | 46 +- src/components/layout/breadcrumbs.tsx | 21 +- src/components/layout/sidebar.tsx | 57 +- src/components/layout/topbar.tsx | 9 +- .../notification-preferences-form.tsx | 131 + src/components/portal/password-set-form.tsx | 14 +- .../reservations/reservation-list.tsx | 13 +- .../residential/residential-client-detail.tsx | 299 + .../residential/residential-clients-list.tsx | 246 + .../residential-interest-detail.tsx | 158 + .../residential-interests-list.tsx | 154 + .../branded-auth-shell.tsx} | 8 +- .../shared/inline-editable-field.tsx | 272 + src/components/shared/inline-tag-editor.tsx | 132 + src/components/shared/notes-list.tsx | 16 +- src/components/yachts/yacht-tabs.tsx | 235 +- src/lib/api/client.ts | 39 +- src/lib/api/helpers.ts | 17 + src/lib/auth/index.ts | 2 +- .../db/migrations/0010_brave_joshua_kane.sql | 13 + src/lib/db/migrations/0011_red_cargill.sql | 43 + src/lib/db/migrations/0012_large_zarda.sql | 1 + src/lib/db/migrations/meta/0010_snapshot.json | 8868 ++++++++++++++++ src/lib/db/migrations/meta/0011_snapshot.json | 9225 ++++++++++++++++ src/lib/db/migrations/meta/0012_snapshot.json | 9232 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 21 + src/lib/db/schema/crm-invites.ts | 32 + src/lib/db/schema/index.ts | 7 + src/lib/db/schema/relations.ts | 24 + src/lib/db/schema/residential.ts | 94 + src/lib/db/schema/users.ts | 20 + src/lib/db/seed.ts | 40 + src/lib/email/templates/crm-invite.ts | 101 + .../email/templates/residential-inquiry.ts | 107 + src/lib/services/clients.service.ts | 44 +- src/lib/services/companies.service.ts | 40 +- src/lib/services/crm-invite.service.ts | 118 + src/lib/services/form-templates.service.ts | 121 + src/lib/services/notes.service.ts | 179 +- src/lib/services/portal-auth.service.ts | 49 + src/lib/services/residential.service.ts | 328 + src/lib/services/users.service.ts | 18 +- src/lib/services/yachts.service.ts | 42 +- src/lib/socket/events.ts | 9 + src/lib/validators/form-templates.ts | 24 + src/lib/validators/residential.ts | 87 + src/lib/validators/users.ts | 2 + src/middleware.ts | 2 + src/providers/socket-provider.tsx | 36 +- tests/e2e/smoke/26-residential.spec.ts | 91 + tests/helpers/factories.ts | 32 + tests/integration/api/companies.test.ts | 4 +- tests/integration/api/reservations.test.ts | 2 +- tests/integration/api/yachts-detail.test.ts | 2 +- tests/integration/api/yachts.test.ts | 3 +- tsconfig.json | 2 +- 121 files changed, 34105 insertions(+), 1016 deletions(-) create mode 100644 docs/website-refactor.md create mode 100644 scripts/dev-create-crm-user.ts create mode 100644 scripts/dev-list-users.ts create mode 100644 scripts/dev-trigger-crm-invite.ts create mode 100644 src/app/(dashboard)/[portSlug]/dashboard/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/residential/clients/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/residential/interests/page.tsx create mode 100644 src/app/api/auth/set-password/route.ts create mode 100644 src/app/api/public/residential-inquiries/route.ts create mode 100644 src/app/api/v1/admin/form-templates/[id]/route.ts create mode 100644 src/app/api/v1/admin/form-templates/route.ts create mode 100644 src/app/api/v1/berths/[id]/reservations/handlers.ts create mode 100644 src/app/api/v1/companies/[id]/handlers.ts create mode 100644 src/app/api/v1/companies/[id]/notes/[noteId]/route.ts create mode 100644 src/app/api/v1/companies/[id]/notes/route.ts create mode 100644 src/app/api/v1/companies/[id]/tags/route.ts create mode 100644 src/app/api/v1/companies/handlers.ts create mode 100644 src/app/api/v1/residential/clients/[id]/route.ts create mode 100644 src/app/api/v1/residential/clients/route.ts create mode 100644 src/app/api/v1/residential/interests/[id]/route.ts create mode 100644 src/app/api/v1/residential/interests/route.ts create mode 100644 src/app/api/v1/yachts/[id]/handlers.ts create mode 100644 src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts create mode 100644 src/app/api/v1/yachts/[id]/notes/route.ts create mode 100644 src/app/api/v1/yachts/[id]/tags/route.ts create mode 100644 src/app/api/v1/yachts/handlers.ts create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/components/admin/forms/form-template-form.tsx create mode 100644 src/components/admin/forms/form-template-list.tsx create mode 100644 src/components/clients/contacts-editor.tsx create mode 100644 src/components/email/compose-dialog.tsx create mode 100644 src/components/email/email-accounts-list.tsx create mode 100644 src/components/email/email-threads-list.tsx create mode 100644 src/components/notifications/notification-preferences-form.tsx create mode 100644 src/components/residential/residential-client-detail.tsx create mode 100644 src/components/residential/residential-clients-list.tsx create mode 100644 src/components/residential/residential-interest-detail.tsx create mode 100644 src/components/residential/residential-interests-list.tsx rename src/components/{portal/portal-auth-shell.tsx => shared/branded-auth-shell.tsx} (69%) create mode 100644 src/components/shared/inline-editable-field.tsx create mode 100644 src/components/shared/inline-tag-editor.tsx create mode 100644 src/lib/db/migrations/0010_brave_joshua_kane.sql create mode 100644 src/lib/db/migrations/0011_red_cargill.sql create mode 100644 src/lib/db/migrations/0012_large_zarda.sql create mode 100644 src/lib/db/migrations/meta/0010_snapshot.json create mode 100644 src/lib/db/migrations/meta/0011_snapshot.json create mode 100644 src/lib/db/migrations/meta/0012_snapshot.json create mode 100644 src/lib/db/schema/crm-invites.ts create mode 100644 src/lib/db/schema/residential.ts create mode 100644 src/lib/email/templates/crm-invite.ts create mode 100644 src/lib/email/templates/residential-inquiry.ts create mode 100644 src/lib/services/crm-invite.service.ts create mode 100644 src/lib/services/form-templates.service.ts create mode 100644 src/lib/services/residential.service.ts create mode 100644 src/lib/validators/form-templates.ts create mode 100644 src/lib/validators/residential.ts create mode 100644 tests/e2e/smoke/26-residential.spec.ts diff --git a/.gitignore b/.gitignore index 72acff5..f13acf0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ docker-compose.override.yml .remember/ .DS_Store eoi/ + +# Brainstorming companion mockup files +.superpowers/ + +# Ad-hoc screenshots / scratch artifacts at repo root +/*.png diff --git a/CLAUDE.md b/CLAUDE.md index 4dfcf77..af30dfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,10 +91,17 @@ src/ - **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 `` URLs reference `s3.portnimara.com` directly (will move to `/public` later). -- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` all wrap their content in `` (`src/components/portal/portal-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. +- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `` (`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 `` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1//[id]/tags` endpoint backed by a `setTags` 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. `` 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. - **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. + ## 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). diff --git a/docs/website-refactor.md b/docs/website-refactor.md new file mode 100644 index 0000000..75a5f8e --- /dev/null +++ b/docs/website-refactor.md @@ -0,0 +1,160 @@ +# 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= +``` + +`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 + +
+ +
+
+``` + +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. diff --git a/scripts/dev-create-crm-user.ts b/scripts/dev-create-crm-user.ts new file mode 100644 index 0000000..29c7dd0 --- /dev/null +++ b/scripts/dev-create-crm-user.ts @@ -0,0 +1,102 @@ +/** + * 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 [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 [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); +}); diff --git a/scripts/dev-list-users.ts b/scripts/dev-list-users.ts new file mode 100644 index 0000000..7d0d81f --- /dev/null +++ b/scripts/dev-list-users.ts @@ -0,0 +1,25 @@ +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); +}); diff --git a/scripts/dev-trigger-crm-invite.ts b/scripts/dev-trigger-crm-invite.ts new file mode 100644 index 0000000..341e9cc --- /dev/null +++ b/scripts/dev-trigger-crm-invite.ts @@ -0,0 +1,36 @@ +/** + * 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 [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 [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); +}); diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index e516c3e..541ab10 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -8,14 +8,5 @@ export const metadata: Metadata = { }; export default function AuthLayout({ children }: { children: React.ReactNode }) { - return ( -
-
- {children} -
-
- ); + return <>{children}; } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 9559ab1..4618a26 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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,64 +55,53 @@ export default function LoginPage() { } return ( -
- - -

Port Nimara

-

Marina CRM

-
- -
-
- - - {errors.email && ( -

{errors.email.message}

- )} -
+ +
+

Port Nimara CRM

+

Sign in to continue

+
-
-
- - - Forgot password? - -
- - {errors.password && ( -

{errors.password.message}

- )} -
+ +
+ + + {errors.email &&

{errors.email.message}

} +
- - -
-
-
+
+
+ + + Forgot password? + +
+ + {errors.password &&

{errors.password.message}

} +
+ + + +
); } diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index 772c663..3dc602e 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -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,69 +49,55 @@ export default function ResetPasswordPage() { } return ( -
- - -

Port Nimara

-

Reset your password

-
- - {submitted ? ( -
-
-

Check your email

-

- If an account exists for that email address, we have sent a password reset link. - Please check your inbox and spam folder. -

-
- - Back to sign in - -
- ) : ( -
-
- - - {errors.email && ( -

{errors.email.message}

- )} -
+ +
+

Reset your password

+

We'll email you a link

+
- + {submitted ? ( +
+

Check your email

+

+ If an account exists for that email address, we have sent a password reset link. Please + check your inbox and spam folder. +

+ + Back to sign in + +
+ ) : ( + +
+ + + {errors.email &&

{errors.email.message}

} +
-

- Remember your password?{' '} - - Sign in - -

- - )} -
-
-
+ + +

+ Remember your password?{' '} + + Sign in + +

+ + )} + ); } diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index d16db42..4ca3fed 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -1,27 +1,23 @@ 'use client'; import { Suspense, useState } from 'react'; +import Link from 'next/link'; 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(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'), + password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`), confirmPassword: z.string().min(1, 'Please confirm your password'), }) .refine((data) => data.password === data.confirmPassword, { @@ -31,25 +27,11 @@ const passwordSchema = z type SetPasswordFormData = z.infer; -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) }, -]; - function SetPasswordInner() { const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get('token'); const [isLoading, setIsLoading] = useState(false); - const [passwordValue, setPasswordValue] = useState(''); const { register, @@ -61,7 +43,7 @@ function SetPasswordInner() { async function onSubmit(data: SetPasswordFormData) { if (!token) { - toast.error('Invalid or missing reset token. Please request a new password reset link.'); + toast.error('Invalid or missing reset token. Please request a new link.'); return; } @@ -75,7 +57,7 @@ function SetPasswordInner() { if (!response.ok) { const body = await response.json().catch(() => ({})); - toast.error(body.message ?? 'Failed to set password. Please try again.'); + toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.'); return; } @@ -88,102 +70,77 @@ function SetPasswordInner() { } } + if (!token) { + return ( + +
+

Link is missing or invalid

+

+ Please use the link from the email we sent you. If the link is broken, ask your + administrator for a new one. +

+ + Back to sign in + +
+
+ ); + } + return ( -
- - -

Port Nimara

-

Set your password

-
- - {!token ? ( -

- Invalid or missing token. Please request a new password reset link. -

- ) : ( -
-
- - setPasswordValue(e.target.value), - })} - /> - {errors.password && ( -

{errors.password.message}

- )} + +
+

Set your password

+

Choose a password for your CRM account

+
-
    - {requirements.map((req) => { - const met = req.test(passwordValue); - return ( -
  • - {met ? ( - - ) : ( - - )} - {req.label} -
  • - ); - })} -
-
+ +
+ + +

At least {MIN_LENGTH} characters.

+ {errors.password &&

{errors.password.message}

} +
-
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- - -
+
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

)} - - -
+
+ + + + ); } export default function SetPasswordPage() { return ( - - } - > + {null}}> ); diff --git a/src/app/(dashboard)/[portSlug]/admin/forms/page.tsx b/src/app/(dashboard)/[portSlug]/admin/forms/page.tsx index 8c63d48..f07d53d 100644 --- a/src/app/(dashboard)/[portSlug]/admin/forms/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/forms/page.tsx @@ -1,16 +1,5 @@ +import { FormTemplateList } from '@/components/admin/forms/form-template-list'; + export default function FormTemplatesPage() { - return ( -
-
-

Form Templates

-

Create and manage intake form templates

-
-
-

Coming in Layer 3

-

- This feature will be implemented in the next phase. -

-
-
- ); + return ; } diff --git a/src/app/(dashboard)/[portSlug]/dashboard/page.tsx b/src/app/(dashboard)/[portSlug]/dashboard/page.tsx new file mode 100644 index 0000000..9dac256 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardShell } from '@/components/dashboard/dashboard-shell'; + +export default function DashboardPage() { + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/email/page.tsx b/src/app/(dashboard)/[portSlug]/email/page.tsx index 628637f..4bca4f8 100644 --- a/src/app/(dashboard)/[portSlug]/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/email/page.tsx @@ -1,16 +1,47 @@ +'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 (
-
-

Email

-

Send and manage client communications

-
-
-

Coming in Layer 3

-

- This feature will be implemented in the next phase. -

+
+
+

Email

+

Send and manage client communications

+
+
+ + + + Inbox + Accounts + + + + + + + + + + + +
); } diff --git a/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx new file mode 100644 index 0000000..1556b93 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx @@ -0,0 +1,15 @@ +import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; + +export default function NotificationPreferencesPage() { + return ( +
+
+

Notification Preferences

+

+ Choose which notifications you receive and how. +

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/page.tsx b/src/app/(dashboard)/[portSlug]/page.tsx index 9dac256..5524339 100644 --- a/src/app/(dashboard)/[portSlug]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/page.tsx @@ -1,5 +1,7 @@ -import { DashboardShell } from '@/components/dashboard/dashboard-shell'; +import { redirect } from 'next/navigation'; -export default function DashboardPage() { - return ; +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); } diff --git a/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx new file mode 100644 index 0000000..b35e2ed --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx @@ -0,0 +1,10 @@ +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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/residential/clients/page.tsx b/src/app/(dashboard)/[portSlug]/residential/clients/page.tsx new file mode 100644 index 0000000..4230ea9 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/clients/page.tsx @@ -0,0 +1,5 @@ +import { ResidentialClientsList } from '@/components/residential/residential-clients-list'; + +export default function ResidentialClientsPage() { + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx new file mode 100644 index 0000000..070ce25 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx @@ -0,0 +1,10 @@ +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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/residential/interests/page.tsx b/src/app/(dashboard)/[portSlug]/residential/interests/page.tsx new file mode 100644 index 0000000..2c6c32c --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/interests/page.tsx @@ -0,0 +1,5 @@ +import { ResidentialInterestsList } from '@/components/residential/residential-interests-list'; + +export default function ResidentialInterestsPage() { + return ; +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 594b37f..e7ec176 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; -import { userPortRoles } from '@/lib/db/schema/users'; +import { ports as portsTable } from '@/lib/db/schema/ports'; +import { userPortRoles, userProfiles } from '@/lib/db/schema/users'; import { QueryProvider } from '@/providers/query-provider'; import { SocketProvider } from '@/providers/socket-provider'; import { PortProvider } from '@/providers/port-provider'; @@ -16,26 +17,31 @@ export default async function DashboardLayout({ children }: { children: React.Re const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user) redirect('/login'); - // Load user's port assignments for PortProvider + // 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), + }); + const portRoles = await db.query.userPortRoles.findMany({ where: eq(userPortRoles.userId, session.user.id), with: { port: true, role: true }, }); - const ports = portRoles.map((pr) => pr.port); + const ports = profile?.isSuperAdmin + ? await db.query.ports.findMany({ orderBy: portsTable.name }) + : portRoles.map((pr) => pr.port); return ( - +
- +
-
- {children} -
+
{children}
diff --git a/src/app/(portal)/portal/forgot-password/page.tsx b/src/app/(portal)/portal/forgot-password/page.tsx index 56fa322..559131f 100644 --- a/src/app/(portal)/portal/forgot-password/page.tsx +++ b/src/app/(portal)/portal/forgot-password/page.tsx @@ -2,11 +2,12 @@ import Link from 'next/link'; import { useState } from 'react'; -import { Loader2, Mail } from 'lucide-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(''); @@ -31,77 +32,74 @@ export default function PortalForgotPasswordPage() { if (submitted) { return ( -
-
+ +
- +

Check your email

-

+

If {email} matches a portal account, we've sent a reset link. The link expires in 30 minutes.

Back to sign in
-
+ ); } return ( -
-
-
-
-

Reset your password

-

- Enter your email and we'll send you a reset link. -

-
- -
-
- - setEmail(e.target.value)} - required - autoFocus - disabled={loading} - /> -
- - -
- - - Back to sign in - -
+ +
+

Reset your password

+

+ Enter your email and we'll send you a reset link. +

-
+ +
+
+ + setEmail(e.target.value)} + required + autoFocus + autoComplete="email" + disabled={loading} + /> +
+ + + +

+ Remember your password?{' '} + + Sign in + +

+
+ ); } diff --git a/src/app/(portal)/portal/login/page.tsx b/src/app/(portal)/portal/login/page.tsx index 985f45c..b2e2a47 100644 --- a/src/app/(portal)/portal/login/page.tsx +++ b/src/app/(portal)/portal/login/page.tsx @@ -8,7 +8,7 @@ import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { PortalAuthShell } from '@/components/portal/portal-auth-shell'; +import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; export default function PortalLoginPage() { const router = useRouter(); @@ -49,7 +49,7 @@ export default function PortalLoginPage() { } return ( - +

Client Portal

Sign in to your account

@@ -110,6 +110,6 @@ export default function PortalLoginPage() {

This portal is for existing clients only.

- + ); } diff --git a/src/app/api/auth/set-password/route.ts b/src/app/api/auth/set-password/route.ts new file mode 100644 index 0000000..09b4141 --- /dev/null +++ b/src/app/api/auth/set-password/route.ts @@ -0,0 +1,37 @@ +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 { + 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); + } +} diff --git a/src/app/api/public/residential-inquiries/route.ts b/src/app/api/public/residential-inquiries/route.ts new file mode 100644 index 0000000..9a963da --- /dev/null +++ b/src/app/api/public/residential-inquiries/route.ts @@ -0,0 +1,176 @@ +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(); +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 { + 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); +} diff --git a/src/app/api/v1/admin/form-templates/[id]/route.ts b/src/app/api/v1/admin/form-templates/[id]/route.ts new file mode 100644 index 0000000..79f4fd2 --- /dev/null +++ b/src/app/api/v1/admin/form-templates/[id]/route.ts @@ -0,0 +1,58 @@ +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); + } + }), +); diff --git a/src/app/api/v1/admin/form-templates/route.ts b/src/app/api/v1/admin/form-templates/route.ts new file mode 100644 index 0000000..b643aa0 --- /dev/null +++ b/src/app/api/v1/admin/form-templates/route.ts @@ -0,0 +1,35 @@ +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); + } + }), +); diff --git a/src/app/api/v1/berths/[id]/reservations/handlers.ts b/src/app/api/v1/berths/[id]/reservations/handlers.ts new file mode 100644 index 0000000..0b4bbd9 --- /dev/null +++ b/src/app/api/v1/berths/[id]/reservations/handlers.ts @@ -0,0 +1,65 @@ +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 { + 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); + } +}; diff --git a/src/app/api/v1/berths/[id]/reservations/route.ts b/src/app/api/v1/berths/[id]/reservations/route.ts index e54027a..4db6df8 100644 --- a/src/app/api/v1/berths/[id]/reservations/route.ts +++ b/src/app/api/v1/berths/[id]/reservations/route.ts @@ -1,72 +1,6 @@ -import { and, eq } from 'drizzle-orm'; -import { NextResponse } from 'next/server'; +import { withAuth, withPermission } from '@/lib/api/helpers'; -import { withAuth, withPermission, 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 { - 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); - // URL berthId is authoritative; override any client-supplied value. - 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); - // URL berthId is authoritative; any body-supplied berthId is ignored. - 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); - } -}; +import { listHandler, createHandler } from './handlers'; export const GET = withAuth(withPermission('reservations', 'view', listHandler)); export const POST = withAuth(withPermission('reservations', 'create', createHandler)); diff --git a/src/app/api/v1/companies/[id]/handlers.ts b/src/app/api/v1/companies/[id]/handlers.ts new file mode 100644 index 0000000..bc4d351 --- /dev/null +++ b/src/app/api/v1/companies/[id]/handlers.ts @@ -0,0 +1,45 @@ +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); + } +}; diff --git a/src/app/api/v1/companies/[id]/notes/[noteId]/route.ts b/src/app/api/v1/companies/[id]/notes/[noteId]/route.ts new file mode 100644 index 0000000..1f96eb5 --- /dev/null +++ b/src/app/api/v1/companies/[id]/notes/[noteId]/route.ts @@ -0,0 +1,63 @@ +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); + } + }), +); diff --git a/src/app/api/v1/companies/[id]/notes/route.ts b/src/app/api/v1/companies/[id]/notes/route.ts new file mode 100644 index 0000000..882f1b1 --- /dev/null +++ b/src/app/api/v1/companies/[id]/notes/route.ts @@ -0,0 +1,47 @@ +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); + } + }), +); diff --git a/src/app/api/v1/companies/[id]/route.ts b/src/app/api/v1/companies/[id]/route.ts index 9b8e547..3bc57fa 100644 --- a/src/app/api/v1/companies/[id]/route.ts +++ b/src/app/api/v1/companies/[id]/route.ts @@ -1,48 +1,6 @@ -import { NextResponse } from 'next/server'; +import { withAuth, withPermission } from '@/lib/api/helpers'; -import { withAuth, withPermission, 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); - } -}; +import { getHandler, patchHandler, deleteHandler } from './handlers'; export const GET = withAuth(withPermission('companies', 'view', getHandler)); export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler)); diff --git a/src/app/api/v1/companies/[id]/tags/route.ts b/src/app/api/v1/companies/[id]/tags/route.ts new file mode 100644 index 0000000..e4654bf --- /dev/null +++ b/src/app/api/v1/companies/[id]/tags/route.ts @@ -0,0 +1,28 @@ +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); + } + }), +); diff --git a/src/app/api/v1/companies/handlers.ts b/src/app/api/v1/companies/handlers.ts new file mode 100644 index 0000000..30e6df1 --- /dev/null +++ b/src/app/api/v1/companies/handlers.ts @@ -0,0 +1,44 @@ +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); + } +}; diff --git a/src/app/api/v1/companies/route.ts b/src/app/api/v1/companies/route.ts index 747dfa6..e245eed 100644 --- a/src/app/api/v1/companies/route.ts +++ b/src/app/api/v1/companies/route.ts @@ -1,47 +1,6 @@ -import { NextResponse } from 'next/server'; +import { withAuth, withPermission } from '@/lib/api/helpers'; -import { withAuth, withPermission, 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); - } -}; +import { listHandler, createHandler } from './handlers'; export const GET = withAuth(withPermission('companies', 'view', listHandler)); export const POST = withAuth(withPermission('companies', 'create', createHandler)); diff --git a/src/app/api/v1/residential/clients/[id]/route.ts b/src/app/api/v1/residential/clients/[id]/route.ts new file mode 100644 index 0000000..8c3e029 --- /dev/null +++ b/src/app/api/v1/residential/clients/[id]/route.ts @@ -0,0 +1,55 @@ +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); + } + }), +); diff --git a/src/app/api/v1/residential/clients/route.ts b/src/app/api/v1/residential/clients/route.ts new file mode 100644 index 0000000..7a84294 --- /dev/null +++ b/src/app/api/v1/residential/clients/route.ts @@ -0,0 +1,54 @@ +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); + } + }), +); diff --git a/src/app/api/v1/residential/interests/[id]/route.ts b/src/app/api/v1/residential/interests/[id]/route.ts new file mode 100644 index 0000000..1a13d2d --- /dev/null +++ b/src/app/api/v1/residential/interests/[id]/route.ts @@ -0,0 +1,55 @@ +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); + } + }), +); diff --git a/src/app/api/v1/residential/interests/route.ts b/src/app/api/v1/residential/interests/route.ts new file mode 100644 index 0000000..89cdf3e --- /dev/null +++ b/src/app/api/v1/residential/interests/route.ts @@ -0,0 +1,54 @@ +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); + } + }), +); diff --git a/src/app/api/v1/yachts/[id]/handlers.ts b/src/app/api/v1/yachts/[id]/handlers.ts new file mode 100644 index 0000000..5bdf8e6 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/handlers.ts @@ -0,0 +1,45 @@ +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); + } +}; diff --git a/src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts b/src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts new file mode 100644 index 0000000..44db285 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts @@ -0,0 +1,63 @@ +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); + } + }), +); diff --git a/src/app/api/v1/yachts/[id]/notes/route.ts b/src/app/api/v1/yachts/[id]/notes/route.ts new file mode 100644 index 0000000..9265eff --- /dev/null +++ b/src/app/api/v1/yachts/[id]/notes/route.ts @@ -0,0 +1,47 @@ +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); + } + }), +); diff --git a/src/app/api/v1/yachts/[id]/route.ts b/src/app/api/v1/yachts/[id]/route.ts index b145fe1..4205d5b 100644 --- a/src/app/api/v1/yachts/[id]/route.ts +++ b/src/app/api/v1/yachts/[id]/route.ts @@ -1,48 +1,6 @@ -import { NextResponse } from 'next/server'; +import { withAuth, withPermission } from '@/lib/api/helpers'; -import { withAuth, withPermission, 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); - } -}; +import { getHandler, patchHandler, deleteHandler } from './handlers'; export const GET = withAuth(withPermission('yachts', 'view', getHandler)); export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler)); diff --git a/src/app/api/v1/yachts/[id]/tags/route.ts b/src/app/api/v1/yachts/[id]/tags/route.ts new file mode 100644 index 0000000..1721dd5 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/tags/route.ts @@ -0,0 +1,28 @@ +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'; + +const setTagsSchema = z.object({ + tagIds: z.array(z.string()), +}); + +export const PUT = withAuth( + withPermission('yachts', 'edit', async (req, ctx, params) => { + try { + const { tagIds } = await parseBody(req, setTagsSchema); + await setYachtTags(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); + } + }), +); diff --git a/src/app/api/v1/yachts/handlers.ts b/src/app/api/v1/yachts/handlers.ts new file mode 100644 index 0000000..0fca287 --- /dev/null +++ b/src/app/api/v1/yachts/handlers.ts @@ -0,0 +1,44 @@ +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 { listYachts, createYacht } from '@/lib/services/yachts.service'; +import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts'; + +export const listHandler: RouteHandler = async (req, ctx) => { + try { + const query = parseQuery(req, listYachtsSchema); + const result = await listYachts(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, createYachtSchema); + const yacht = await createYacht(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: yacht }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}; diff --git a/src/app/api/v1/yachts/route.ts b/src/app/api/v1/yachts/route.ts index 7ed967d..4433698 100644 --- a/src/app/api/v1/yachts/route.ts +++ b/src/app/api/v1/yachts/route.ts @@ -1,47 +1,6 @@ -import { NextResponse } from 'next/server'; +import { withAuth, withPermission } from '@/lib/api/helpers'; -import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; -import { parseQuery, parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; -import { listYachts, createYacht } from '@/lib/services/yachts.service'; -import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts'; - -export const listHandler: RouteHandler = async (req, ctx) => { - try { - const query = parseQuery(req, listYachtsSchema); - const result = await listYachts(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, createYachtSchema); - const yacht = await createYacht(ctx.portId, body, { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }); - return NextResponse.json({ data: yacht }, { status: 201 }); - } catch (error) { - return errorResponse(error); - } -}; +import { listHandler, createHandler } from './handlers'; export const GET = withAuth(withPermission('yachts', 'view', listHandler)); export const POST = withAuth(withPermission('yachts', 'create', createHandler)); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..5212eca --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; +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'; + +/** + * Plain `/dashboard` lands users into their default port's dashboard. Used + * by post-login redirects and any code that doesn't yet know the active + * port slug. + */ +export default async function DashboardRedirectPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) redirect('/login'); + + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, session.user.id), + }); + + let slug: string | undefined; + if (profile?.isSuperAdmin) { + const first = await db.query.ports.findFirst({ orderBy: portsTable.name }); + slug = first?.slug; + } else { + const role = await db.query.userPortRoles.findFirst({ + where: eq(userPortRoles.userId, session.user.id), + with: { port: true }, + }); + slug = role?.port.slug; + } + + if (!slug) redirect('/login?error=no-port-access'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/${slug}/dashboard` as any); +} diff --git a/src/components/admin/forms/form-template-form.tsx b/src/components/admin/forms/form-template-form.tsx new file mode 100644 index 0000000..7fbab9b --- /dev/null +++ b/src/components/admin/forms/form-template-form.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { apiFetch } from '@/lib/api/client'; +import type { FormField } from '@/lib/validators/form-templates'; + +interface FormTemplate { + id: string; + name: string; + description: string | null; + fields: FormField[]; + isActive: boolean; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + template: FormTemplate | null; + onSaved: () => void; +} + +const DEFAULT_FIELD: FormField = { + key: '', + label: '', + type: 'text', + required: false, +}; + +const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [ + { value: 'text', label: 'Text' }, + { value: 'textarea', label: 'Long text' }, + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone' }, + { value: 'number', label: 'Number' }, + { value: 'select', label: 'Select' }, + { value: 'checkbox', label: 'Checkbox' }, +]; + +export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [isActive, setIsActive] = useState(true); + const [fields, setFields] = useState([{ ...DEFAULT_FIELD }]); + + useEffect(() => { + if (template) { + setName(template.name); + setDescription(template.description ?? ''); + setIsActive(template.isActive); + setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]); + } else { + setName(''); + setDescription(''); + setIsActive(true); + setFields([{ ...DEFAULT_FIELD }]); + } + }, [template, open]); + + const saveMutation = useMutation({ + mutationFn: () => { + const payload = { + name, + description: description || undefined, + fields, + isActive, + }; + if (template) { + return apiFetch(`/api/v1/admin/form-templates/${template.id}`, { + method: 'PATCH', + body: payload, + }); + } + return apiFetch('/api/v1/admin/form-templates', { + method: 'POST', + body: payload, + }); + }, + onSuccess: () => { + toast.success(template ? 'Template saved' : 'Template created'); + onSaved(); + onOpenChange(false); + }, + onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'), + }); + + function updateField(idx: number, patch: Partial) { + setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); + } + + function addField() { + setFields((prev) => [...prev, { ...DEFAULT_FIELD }]); + } + + function removeField(idx: number) { + setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx))); + } + + return ( + + + + {template ? 'Edit form template' : 'New form template'} + +
+
+ + setName(e.target.value)} /> +
+
+ +