Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { relations } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
// Ports
|
|
|
|
|
import { ports } from './ports';
|
|
|
|
|
|
|
|
|
|
// Users
|
|
|
|
|
import { userProfiles, roles, portRoleOverrides, userPortRoles } from './users';
|
|
|
|
|
|
|
|
|
|
// Clients
|
|
|
|
|
import {
|
|
|
|
|
clients,
|
|
|
|
|
clientContacts,
|
|
|
|
|
clientRelationships,
|
|
|
|
|
clientNotes,
|
|
|
|
|
clientTags,
|
|
|
|
|
clientMergeLog,
|
2026-04-14 12:44:11 -04:00
|
|
|
clientAddresses,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} from './clients';
|
|
|
|
|
|
|
|
|
|
// Interests
|
2026-05-05 02:22:11 +02:00
|
|
|
import { interests, interestNotes, interestTags, interestBerths } from './interests';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-04-23 18:02:22 +02:00
|
|
|
// Yachts
|
|
|
|
|
import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
|
|
|
|
|
|
|
|
|
|
// Companies
|
|
|
|
|
import {
|
|
|
|
|
companies,
|
|
|
|
|
companyMemberships,
|
|
|
|
|
companyAddresses,
|
|
|
|
|
companyNotes,
|
|
|
|
|
companyTags,
|
|
|
|
|
} from './companies';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// Berths
|
|
|
|
|
import {
|
|
|
|
|
berths,
|
|
|
|
|
berthMapData,
|
|
|
|
|
berthRecommendations,
|
|
|
|
|
berthWaitingList,
|
|
|
|
|
berthMaintenanceLog,
|
|
|
|
|
berthTags,
|
feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
|
|
|
berthPdfVersions,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} from './berths';
|
|
|
|
|
|
2026-04-23 18:02:22 +02:00
|
|
|
// Reservations
|
|
|
|
|
import { berthReservations } from './reservations';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// Documents
|
|
|
|
|
import {
|
|
|
|
|
files,
|
|
|
|
|
documents,
|
|
|
|
|
documentSigners,
|
|
|
|
|
documentEvents,
|
|
|
|
|
documentTemplates,
|
2026-04-28 02:12:05 +02:00
|
|
|
documentWatchers,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
formTemplates,
|
|
|
|
|
formSubmissions,
|
|
|
|
|
} from './documents';
|
|
|
|
|
|
feat(emails): sales send-out flows + brochures + email-from settings
Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7,
§5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for
per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP
config + body templates, and the supporting admin UI.
Migration: 0031_brochures_and_document_sends.sql
Schema additions:
- brochures (port-wide, with isDefault marker + archive)
- brochure_versions (versioned uploads, storageKey per §4.7a)
- document_sends (audit log of every rep-initiated send; failures
captured with failedAt + errorReason). berthPdfVersionId is a plain
text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions
so the two phases stay independent.
§14.7 critical mitigations:
- Body XSS: rep-authored markdown goes through renderEmailBody()
(HTML-escape first, then a tight allowlist of bold/italic/code/link
rules). https:// + mailto: only — javascript:/data: URLs stripped.
Tested against script/img/iframe/svg/onerror polyglots.
- Recipient typo: strict email regex + two-step confirm modal that
shows the exact recipient before send.
- Unresolved merge fields: pre-send dry-run /preview endpoint blocks
submission until findUnresolvedTokens() returns empty.
- SMTP failure: every transport rejection writes a document_sends row
with failedAt + errorReason; UI surfaces the message.
- Hourly per-user rate limit: 50 sends/user/hour via existing
checkRateLimit().
- Size threshold fallback (§11.1): files above
email_attach_threshold_mb (default 15) ship as a 24h signed-URL
download link in the body instead of an attachment. Storage stream
flows directly to nodemailer to avoid buffering 20MB+.
§14.10 critical mitigation:
- SMTP/IMAP passwords encrypted at rest via the existing
EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/
sales-config GET endpoint never returns the decrypted value — only
a *PassIsSet boolean. PATCH treats empty string as "leave unchanged"
and explicit null as "clear", so the masked-placeholder UI round-
trips without forcing re-entry on every save.
system_settings keys (per-port unless noted):
- sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted}
- sales_imap_{host,port,user,pass_encrypted}
- sales_auth_method (default app_password)
- noreply_from_address
- email_template_send_berth_pdf_body, email_template_send_brochure_body
- brochure_max_upload_mb (default 50)
- email_attach_threshold_mb (default 15)
UI surfaces (per §5.7, §5.8, §5.9):
- <SendDocumentDialog> shared 2-step compose+confirm flow.
- <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton>
wrappers per detail page.
- /[portSlug]/admin/brochures: list, upload (direct-to-storage
presigned PUT for the 20MB+ files per §11.1), default toggle,
archive.
- /[portSlug]/admin/email extended with <SalesEmailConfigCard>:
SMTP + IMAP creds, body templates, threshold/max settings.
Storage: every upload + download goes through getStorageBackend() —
no direct minio imports, per Phase 6a contract.
Tests: 1145 vitest passing (+ 50 new in
markdown-email-sanitization.test.ts, document-sends-validators.test.ts,
sales-email-config-validators.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
|
|
|
// Brochures + send-outs (Phase 7)
|
|
|
|
|
import { brochures, brochureVersions, documentSends } from './brochures';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// Financial
|
|
|
|
|
import { expenses, invoices, invoiceLineItems, invoiceExpenses } from './financial';
|
|
|
|
|
|
|
|
|
|
// Email
|
|
|
|
|
import { emailAccounts, emailThreads, emailMessages } from './email';
|
|
|
|
|
|
|
|
|
|
// Operations
|
|
|
|
|
import {
|
|
|
|
|
reminders,
|
|
|
|
|
googleCalendarCache,
|
|
|
|
|
googleCalendarTokens,
|
|
|
|
|
notifications,
|
|
|
|
|
scheduledReports,
|
|
|
|
|
reportRecipients,
|
|
|
|
|
generatedReports,
|
|
|
|
|
} from './operations';
|
|
|
|
|
|
|
|
|
|
// System
|
|
|
|
|
import {
|
|
|
|
|
auditLogs,
|
|
|
|
|
tags,
|
|
|
|
|
webhooks,
|
|
|
|
|
webhookDeliveries,
|
|
|
|
|
systemSettings,
|
|
|
|
|
savedViews,
|
|
|
|
|
scratchpadNotes,
|
|
|
|
|
userNotificationPreferences,
|
|
|
|
|
customFieldDefinitions,
|
|
|
|
|
customFieldValues,
|
|
|
|
|
} from './system';
|
feat(platform): residential module + admin UI + reliability fixes
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 <a href>)
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) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
import { residentialClients, residentialInterests } from './residential';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// ─── Ports ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const portsRelations = relations(ports, ({ many }) => ({
|
|
|
|
|
userPortRoles: many(userPortRoles),
|
|
|
|
|
portRoleOverrides: many(portRoleOverrides),
|
|
|
|
|
clients: many(clients),
|
|
|
|
|
interests: many(interests),
|
2026-04-23 18:02:22 +02:00
|
|
|
yachts: many(yachts),
|
|
|
|
|
companies: many(companies),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berths: many(berths),
|
2026-04-23 18:02:22 +02:00
|
|
|
berthReservations: many(berthReservations),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
documents: many(documents),
|
|
|
|
|
documentTemplates: many(documentTemplates),
|
|
|
|
|
formTemplates: many(formTemplates),
|
|
|
|
|
expenses: many(expenses),
|
|
|
|
|
invoices: many(invoices),
|
|
|
|
|
emailAccounts: many(emailAccounts),
|
|
|
|
|
emailThreads: many(emailThreads),
|
|
|
|
|
reminders: many(reminders),
|
|
|
|
|
notifications: many(notifications),
|
|
|
|
|
scheduledReports: many(scheduledReports),
|
|
|
|
|
auditLogs: many(auditLogs),
|
|
|
|
|
tags: many(tags),
|
|
|
|
|
files: many(files),
|
|
|
|
|
webhooks: many(webhooks),
|
|
|
|
|
systemSettings: many(systemSettings),
|
|
|
|
|
savedViews: many(savedViews),
|
|
|
|
|
userNotificationPreferences: many(userNotificationPreferences),
|
|
|
|
|
customFieldDefinitions: many(customFieldDefinitions),
|
feat(platform): residential module + admin UI + reliability fixes
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 <a href>)
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) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
residentialClients: many(residentialClients),
|
|
|
|
|
residentialInterests: many(residentialInterests),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthMaintenanceLogs: many(berthMaintenanceLog),
|
|
|
|
|
clientMergeLogs: many(clientMergeLog),
|
|
|
|
|
clientRelationships: many(clientRelationships),
|
2026-04-14 12:44:11 -04:00
|
|
|
clientAddresses: many(clientAddresses),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Users ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const userProfilesRelations = relations(userProfiles, ({ many }) => ({
|
|
|
|
|
userPortRoles: many(userPortRoles),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const rolesRelations = relations(roles, ({ many }) => ({
|
|
|
|
|
userPortRoles: many(userPortRoles),
|
|
|
|
|
portRoleOverrides: many(portRoleOverrides),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const portRoleOverridesRelations = relations(portRoleOverrides, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [portRoleOverrides.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
role: one(roles, {
|
|
|
|
|
fields: [portRoleOverrides.roleId],
|
|
|
|
|
references: [roles.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const userPortRolesRelations = relations(userPortRoles, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [userPortRoles.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
role: one(roles, {
|
|
|
|
|
fields: [userPortRoles.roleId],
|
|
|
|
|
references: [roles.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Clients ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const clientsRelations = relations(clients, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [clients.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
contacts: many(clientContacts),
|
|
|
|
|
notes: many(clientNotes),
|
|
|
|
|
tags: many(clientTags),
|
|
|
|
|
interests: many(interests),
|
|
|
|
|
relationships_a: many(clientRelationships, { relationName: 'client_a' }),
|
|
|
|
|
relationships_b: many(clientRelationships, { relationName: 'client_b' }),
|
|
|
|
|
mergeLogsAsSurvivor: many(clientMergeLog),
|
|
|
|
|
documents: many(documents),
|
|
|
|
|
emailThreads: many(emailThreads),
|
|
|
|
|
reminders: many(reminders),
|
|
|
|
|
files: many(files),
|
|
|
|
|
waitingListEntries: many(berthWaitingList),
|
|
|
|
|
scratchpadNotes: many(scratchpadNotes),
|
|
|
|
|
formSubmissions: many(formSubmissions),
|
2026-04-14 12:44:11 -04:00
|
|
|
addresses: many(clientAddresses),
|
2026-04-23 18:02:22 +02:00
|
|
|
companyMemberships: many(companyMemberships),
|
|
|
|
|
berthReservations: many(berthReservations),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [clientContacts.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-14 12:44:11 -04:00
|
|
|
export const clientAddressesRelations = relations(clientAddresses, ({ one }) => ({
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [clientAddresses.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [clientAddresses.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export const clientRelationshipsRelations = relations(clientRelationships, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [clientRelationships.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
clientA: one(clients, {
|
|
|
|
|
fields: [clientRelationships.clientAId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
relationName: 'client_a',
|
|
|
|
|
}),
|
|
|
|
|
clientB: one(clients, {
|
|
|
|
|
fields: [clientRelationships.clientBId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
relationName: 'client_b',
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const clientNotesRelations = relations(clientNotes, ({ one }) => ({
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [clientNotes.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const clientTagsRelations = relations(clientTags, ({ one }) => ({
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [clientTags.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
tag: one(tags, {
|
|
|
|
|
fields: [clientTags.tagId],
|
|
|
|
|
references: [tags.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const clientMergeLogRelations = relations(clientMergeLog, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [clientMergeLog.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
survivingClient: one(clients, {
|
|
|
|
|
fields: [clientMergeLog.survivingClientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Interests ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const interestsRelations = relations(interests, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [interests.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [interests.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
2026-04-23 18:02:22 +02:00
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [interests.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
notes: many(interestNotes),
|
|
|
|
|
tags: many(interestTags),
|
|
|
|
|
documents: many(documents),
|
|
|
|
|
reminders: many(reminders),
|
|
|
|
|
berthRecommendations: many(berthRecommendations),
|
|
|
|
|
formSubmissions: many(formSubmissions),
|
2026-05-05 02:22:11 +02:00
|
|
|
interestBerths: many(interestBerths),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const interestBerthsRelations = relations(interestBerths, ({ one }) => ({
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [interestBerths.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [interestBerths.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const interestNotesRelations = relations(interestNotes, ({ one }) => ({
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [interestNotes.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const interestTagsRelations = relations(interestTags, ({ one }) => ({
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [interestTags.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
tag: one(tags, {
|
|
|
|
|
fields: [interestTags.tagId],
|
|
|
|
|
references: [tags.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-23 18:02:22 +02:00
|
|
|
// ─── Yachts ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const yachtsRelations = relations(yachts, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [yachts.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
ownershipHistory: many(yachtOwnershipHistory),
|
|
|
|
|
notes: many(yachtNotes),
|
|
|
|
|
tags: many(yachtTags),
|
|
|
|
|
interests: many(interests),
|
|
|
|
|
reservations: many(berthReservations),
|
|
|
|
|
documents: many(documents),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const yachtOwnershipHistoryRelations = relations(yachtOwnershipHistory, ({ one }) => ({
|
|
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [yachtOwnershipHistory.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const yachtNotesRelations = relations(yachtNotes, ({ one }) => ({
|
|
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [yachtNotes.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const yachtTagsRelations = relations(yachtTags, ({ one }) => ({
|
|
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [yachtTags.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
tag: one(tags, {
|
|
|
|
|
fields: [yachtTags.tagId],
|
|
|
|
|
references: [tags.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Companies ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const companiesRelations = relations(companies, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [companies.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
memberships: many(companyMemberships),
|
|
|
|
|
addresses: many(companyAddresses),
|
|
|
|
|
notes: many(companyNotes),
|
|
|
|
|
tags: many(companyTags),
|
|
|
|
|
documents: many(documents),
|
|
|
|
|
files: many(files),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const companyMembershipsRelations = relations(companyMemberships, ({ one }) => ({
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [companyMemberships.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [companyMemberships.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const companyAddressesRelations = relations(companyAddresses, ({ one }) => ({
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [companyAddresses.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [companyAddresses.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const companyNotesRelations = relations(companyNotes, ({ one }) => ({
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [companyNotes.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const companyTagsRelations = relations(companyTags, ({ one }) => ({
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [companyTags.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
|
|
|
|
tag: one(tags, {
|
|
|
|
|
fields: [companyTags.tagId],
|
|
|
|
|
references: [tags.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Berths ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const berthsRelations = relations(berths, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [berths.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
mapData: one(berthMapData),
|
|
|
|
|
recommendations: many(berthRecommendations),
|
|
|
|
|
waitingList: many(berthWaitingList),
|
|
|
|
|
maintenanceLogs: many(berthMaintenanceLog),
|
|
|
|
|
tags: many(berthTags),
|
2026-05-05 02:41:52 +02:00
|
|
|
interestBerths: many(interestBerths),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
reminders: many(reminders),
|
feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
|
|
|
pdfVersions: many(berthPdfVersions),
|
|
|
|
|
currentPdfVersion: one(berthPdfVersions, {
|
|
|
|
|
fields: [berths.currentPdfVersionId],
|
|
|
|
|
references: [berthPdfVersions.id],
|
|
|
|
|
relationName: 'berthCurrentPdfVersion',
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthPdfVersionsRelations = relations(berthPdfVersions, ({ one }) => ({
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthPdfVersions.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthMapDataRelations = relations(berthMapData, ({ one }) => ({
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthMapData.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthRecommendationsRelations = relations(berthRecommendations, ({ one }) => ({
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [berthRecommendations.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthRecommendations.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthWaitingListRelations = relations(berthWaitingList, ({ one }) => ({
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthWaitingList.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [berthWaitingList.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthMaintenanceLogRelations = relations(berthMaintenanceLog, ({ one }) => ({
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthMaintenanceLog.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [berthMaintenanceLog.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const berthTagsRelations = relations(berthTags, ({ one }) => ({
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthTags.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
tag: one(tags, {
|
|
|
|
|
fields: [berthTags.tagId],
|
|
|
|
|
references: [tags.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-23 18:02:22 +02:00
|
|
|
// ─── Berth Reservations ───────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-28 02:12:05 +02:00
|
|
|
export const berthReservationsRelations = relations(berthReservations, ({ one, many }) => ({
|
2026-04-23 18:02:22 +02:00
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [berthReservations.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [berthReservations.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [berthReservations.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [berthReservations.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [berthReservations.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
contractFile: one(files, {
|
|
|
|
|
fields: [berthReservations.contractFileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
}),
|
2026-04-28 02:12:05 +02:00
|
|
|
documents: many(documents),
|
2026-04-23 18:02:22 +02:00
|
|
|
}));
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const filesRelations = relations(files, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [files.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [files.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
2026-04-23 18:02:22 +02:00
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [files.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [files.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
documentAsFile: many(documents, { relationName: 'file' }),
|
|
|
|
|
documentAsSignedFile: many(documents, { relationName: 'signed_file' }),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentsRelations = relations(documents, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [documents.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [documents.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [documents.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
file: one(files, {
|
|
|
|
|
fields: [documents.fileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
relationName: 'file',
|
|
|
|
|
}),
|
|
|
|
|
signedFile: one(files, {
|
|
|
|
|
fields: [documents.signedFileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
relationName: 'signed_file',
|
|
|
|
|
}),
|
2026-04-23 18:02:22 +02:00
|
|
|
yacht: one(yachts, {
|
|
|
|
|
fields: [documents.yachtId],
|
|
|
|
|
references: [yachts.id],
|
|
|
|
|
}),
|
|
|
|
|
company: one(companies, {
|
|
|
|
|
fields: [documents.companyId],
|
|
|
|
|
references: [companies.id],
|
|
|
|
|
}),
|
2026-04-28 02:12:05 +02:00
|
|
|
reservation: one(berthReservations, {
|
|
|
|
|
fields: [documents.reservationId],
|
|
|
|
|
references: [berthReservations.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
signers: many(documentSigners),
|
|
|
|
|
events: many(documentEvents),
|
2026-04-28 02:12:05 +02:00
|
|
|
watchers: many(documentWatchers),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentSignersRelations = relations(documentSigners, ({ one, many }) => ({
|
|
|
|
|
document: one(documents, {
|
|
|
|
|
fields: [documentSigners.documentId],
|
|
|
|
|
references: [documents.id],
|
|
|
|
|
}),
|
|
|
|
|
events: many(documentEvents),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentEventsRelations = relations(documentEvents, ({ one }) => ({
|
|
|
|
|
document: one(documents, {
|
|
|
|
|
fields: [documentEvents.documentId],
|
|
|
|
|
references: [documents.id],
|
|
|
|
|
}),
|
|
|
|
|
signer: one(documentSigners, {
|
|
|
|
|
fields: [documentEvents.signerId],
|
|
|
|
|
references: [documentSigners.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [documentTemplates.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
2026-04-28 02:12:05 +02:00
|
|
|
sourceFile: one(files, {
|
|
|
|
|
fields: [documentTemplates.sourceFileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentWatchersRelations = relations(documentWatchers, ({ one }) => ({
|
|
|
|
|
document: one(documents, {
|
|
|
|
|
fields: [documentWatchers.documentId],
|
|
|
|
|
references: [documents.id],
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const formTemplatesRelations = relations(formTemplates, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [formTemplates.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
submissions: many(formSubmissions),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const formSubmissionsRelations = relations(formSubmissions, ({ one }) => ({
|
|
|
|
|
formTemplate: one(formTemplates, {
|
|
|
|
|
fields: [formSubmissions.formTemplateId],
|
|
|
|
|
references: [formTemplates.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [formSubmissions.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [formSubmissions.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Financial ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const expensesRelations = relations(expenses, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [expenses.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
invoiceExpenses: many(invoiceExpenses),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [invoices.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
pdfFile: one(files, {
|
|
|
|
|
fields: [invoices.pdfFileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
}),
|
|
|
|
|
lineItems: many(invoiceLineItems),
|
|
|
|
|
invoiceExpenses: many(invoiceExpenses),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const invoiceLineItemsRelations = relations(invoiceLineItems, ({ one }) => ({
|
|
|
|
|
invoice: one(invoices, {
|
|
|
|
|
fields: [invoiceLineItems.invoiceId],
|
|
|
|
|
references: [invoices.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const invoiceExpensesRelations = relations(invoiceExpenses, ({ one }) => ({
|
|
|
|
|
invoice: one(invoices, {
|
|
|
|
|
fields: [invoiceExpenses.invoiceId],
|
|
|
|
|
references: [invoices.id],
|
|
|
|
|
}),
|
|
|
|
|
expense: one(expenses, {
|
|
|
|
|
fields: [invoiceExpenses.expenseId],
|
|
|
|
|
references: [expenses.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const emailAccountsRelations = relations(emailAccounts, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [emailAccounts.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const emailThreadsRelations = relations(emailThreads, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [emailThreads.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [emailThreads.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
messages: many(emailMessages),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const emailMessagesRelations = relations(emailMessages, ({ one }) => ({
|
|
|
|
|
thread: one(emailThreads, {
|
|
|
|
|
fields: [emailMessages.threadId],
|
|
|
|
|
references: [emailThreads.id],
|
|
|
|
|
}),
|
|
|
|
|
rawFile: one(files, {
|
|
|
|
|
fields: [emailMessages.rawFileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── Operations ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const remindersRelations = relations(reminders, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [reminders.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [reminders.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [reminders.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [reminders.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
calendarCacheEntries: many(googleCalendarCache),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const googleCalendarTokensRelations = relations(googleCalendarTokens, ({ many }) => ({
|
|
|
|
|
cacheEntries: many(googleCalendarCache),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const googleCalendarCacheRelations = relations(googleCalendarCache, ({ one }) => ({
|
|
|
|
|
reminder: one(reminders, {
|
|
|
|
|
fields: [googleCalendarCache.reminderId],
|
|
|
|
|
references: [reminders.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [notifications.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const scheduledReportsRelations = relations(scheduledReports, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [scheduledReports.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
recipients: many(reportRecipients),
|
|
|
|
|
generatedReports: many(generatedReports),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const reportRecipientsRelations = relations(reportRecipients, ({ one }) => ({
|
|
|
|
|
report: one(scheduledReports, {
|
|
|
|
|
fields: [reportRecipients.reportId],
|
|
|
|
|
references: [scheduledReports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const generatedReportsRelations = relations(generatedReports, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [generatedReports.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
scheduledReport: one(scheduledReports, {
|
|
|
|
|
fields: [generatedReports.scheduledReportId],
|
|
|
|
|
references: [scheduledReports.id],
|
|
|
|
|
}),
|
|
|
|
|
file: one(files, {
|
|
|
|
|
fields: [generatedReports.fileId],
|
|
|
|
|
references: [files.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ─── System ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [auditLogs.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
revertOfLog: one(auditLogs, {
|
|
|
|
|
fields: [auditLogs.revertOf],
|
|
|
|
|
references: [auditLogs.id],
|
|
|
|
|
relationName: 'revert_of',
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const tagsRelations = relations(tags, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [tags.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
clientTags: many(clientTags),
|
|
|
|
|
interestTags: many(interestTags),
|
|
|
|
|
berthTags: many(berthTags),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const webhooksRelations = relations(webhooks, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [webhooks.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
deliveries: many(webhookDeliveries),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const webhookDeliveriesRelations = relations(webhookDeliveries, ({ one }) => ({
|
|
|
|
|
webhook: one(webhooks, {
|
|
|
|
|
fields: [webhookDeliveries.webhookId],
|
|
|
|
|
references: [webhooks.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const systemSettingsRelations = relations(systemSettings, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [systemSettings.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const savedViewsRelations = relations(savedViews, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [savedViews.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const scratchpadNotesRelations = relations(scratchpadNotes, ({ one }) => ({
|
|
|
|
|
linkedClient: one(clients, {
|
|
|
|
|
fields: [scratchpadNotes.linkedClientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const userNotificationPreferencesRelations = relations(
|
|
|
|
|
userNotificationPreferences,
|
|
|
|
|
({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [userNotificationPreferences.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const customFieldDefinitionsRelations = relations(
|
|
|
|
|
customFieldDefinitions,
|
|
|
|
|
({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [customFieldDefinitions.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
values: many(customFieldValues),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const customFieldValuesRelations = relations(customFieldValues, ({ one }) => ({
|
|
|
|
|
definition: one(customFieldDefinitions, {
|
|
|
|
|
fields: [customFieldValues.fieldId],
|
|
|
|
|
references: [customFieldDefinitions.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
feat(platform): residential module + admin UI + reliability fixes
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 <a href>)
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) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
|
|
|
|
|
// ─── Residential ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const residentialClientsRelations = relations(residentialClients, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [residentialClients.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
interests: many(residentialInterests),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const residentialInterestsRelations = relations(residentialInterests, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [residentialInterests.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(residentialClients, {
|
|
|
|
|
fields: [residentialInterests.residentialClientId],
|
|
|
|
|
references: [residentialClients.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
feat(emails): sales send-out flows + brochures + email-from settings
Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7,
§5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for
per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP
config + body templates, and the supporting admin UI.
Migration: 0031_brochures_and_document_sends.sql
Schema additions:
- brochures (port-wide, with isDefault marker + archive)
- brochure_versions (versioned uploads, storageKey per §4.7a)
- document_sends (audit log of every rep-initiated send; failures
captured with failedAt + errorReason). berthPdfVersionId is a plain
text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions
so the two phases stay independent.
§14.7 critical mitigations:
- Body XSS: rep-authored markdown goes through renderEmailBody()
(HTML-escape first, then a tight allowlist of bold/italic/code/link
rules). https:// + mailto: only — javascript:/data: URLs stripped.
Tested against script/img/iframe/svg/onerror polyglots.
- Recipient typo: strict email regex + two-step confirm modal that
shows the exact recipient before send.
- Unresolved merge fields: pre-send dry-run /preview endpoint blocks
submission until findUnresolvedTokens() returns empty.
- SMTP failure: every transport rejection writes a document_sends row
with failedAt + errorReason; UI surfaces the message.
- Hourly per-user rate limit: 50 sends/user/hour via existing
checkRateLimit().
- Size threshold fallback (§11.1): files above
email_attach_threshold_mb (default 15) ship as a 24h signed-URL
download link in the body instead of an attachment. Storage stream
flows directly to nodemailer to avoid buffering 20MB+.
§14.10 critical mitigation:
- SMTP/IMAP passwords encrypted at rest via the existing
EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/
sales-config GET endpoint never returns the decrypted value — only
a *PassIsSet boolean. PATCH treats empty string as "leave unchanged"
and explicit null as "clear", so the masked-placeholder UI round-
trips without forcing re-entry on every save.
system_settings keys (per-port unless noted):
- sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted}
- sales_imap_{host,port,user,pass_encrypted}
- sales_auth_method (default app_password)
- noreply_from_address
- email_template_send_berth_pdf_body, email_template_send_brochure_body
- brochure_max_upload_mb (default 50)
- email_attach_threshold_mb (default 15)
UI surfaces (per §5.7, §5.8, §5.9):
- <SendDocumentDialog> shared 2-step compose+confirm flow.
- <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton>
wrappers per detail page.
- /[portSlug]/admin/brochures: list, upload (direct-to-storage
presigned PUT for the 20MB+ files per §11.1), default toggle,
archive.
- /[portSlug]/admin/email extended with <SalesEmailConfigCard>:
SMTP + IMAP creds, body templates, threshold/max settings.
Storage: every upload + download goes through getStorageBackend() —
no direct minio imports, per Phase 6a contract.
Tests: 1145 vitest passing (+ 50 new in
markdown-email-sanitization.test.ts, document-sends-validators.test.ts,
sales-email-config-validators.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
|
|
|
|
|
|
|
|
// ─── Brochures + send-outs (Phase 7) ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const brochuresRelations = relations(brochures, ({ one, many }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [brochures.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
versions: many(brochureVersions),
|
|
|
|
|
sends: many(documentSends),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const brochureVersionsRelations = relations(brochureVersions, ({ one, many }) => ({
|
|
|
|
|
brochure: one(brochures, {
|
|
|
|
|
fields: [brochureVersions.brochureId],
|
|
|
|
|
references: [brochures.id],
|
|
|
|
|
}),
|
|
|
|
|
sends: many(documentSends),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export const documentSendsRelations = relations(documentSends, ({ one }) => ({
|
|
|
|
|
port: one(ports, {
|
|
|
|
|
fields: [documentSends.portId],
|
|
|
|
|
references: [ports.id],
|
|
|
|
|
}),
|
|
|
|
|
client: one(clients, {
|
|
|
|
|
fields: [documentSends.clientId],
|
|
|
|
|
references: [clients.id],
|
|
|
|
|
}),
|
|
|
|
|
interest: one(interests, {
|
|
|
|
|
fields: [documentSends.interestId],
|
|
|
|
|
references: [interests.id],
|
|
|
|
|
}),
|
|
|
|
|
berth: one(berths, {
|
|
|
|
|
fields: [documentSends.berthId],
|
|
|
|
|
references: [berths.id],
|
|
|
|
|
}),
|
|
|
|
|
brochure: one(brochures, {
|
|
|
|
|
fields: [documentSends.brochureId],
|
|
|
|
|
references: [brochures.id],
|
|
|
|
|
}),
|
|
|
|
|
brochureVersion: one(brochureVersions, {
|
|
|
|
|
fields: [documentSends.brochureVersionId],
|
|
|
|
|
references: [brochureVersions.id],
|
|
|
|
|
}),
|
|
|
|
|
}));
|