2026-05-11 13:53:10 +02:00
|
|
|
import { and, eq, count, inArray, isNull, desc, or, sql } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
feat(portal): replace magic-link with email/password + admin-initiated activation
The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.
Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):
- `portal_users` — one per client account, separate from the CRM
`users` table (better-auth) so the auth realms stay isolated. Email
is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
only the SHA-256 hash so a DB compromise never leaks live tokens.
Services:
- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
resendActivation, activateAccount, signIn (timing-safe),
requestPasswordReset, resetPassword. Auth failures throw the new
UnauthorizedError (401); enumeration-safe behaviour everywhere.
Routes:
- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).
UI:
- /portal/login — replaced email-only magic-link form with email +
password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.
Email send:
- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
pinning it to `noreply@${SMTP_HOST}`.
Tests:
- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
match the post-refactor client + invoice forms (drop companyName,
use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.
Drops the dead `requestMagicLink` from portal.service.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
2026-05-05 02:41:52 +02:00
|
|
|
import { getPrimaryBerthsForInterests } from '@/lib/services/interest-berths.service';
|
2026-03-26 12:06:18 +01:00
|
|
|
import { documents, files } from '@/lib/db/schema/documents';
|
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 { invoices } from '@/lib/db/schema/financial';
|
|
|
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
2026-04-24 14:43:12 +02:00
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
|
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
|
|
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
fix(storage): route every file op through getStorageBackend()
Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction. Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc. Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.
Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend. A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).
Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:02 +02:00
|
|
|
import { presignDownloadUrl } from '@/lib/storage';
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
import { getCountryName } from '@/lib/i18n/countries';
|
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
|
|
|
|
|
|
|
|
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalDashboard {
|
|
|
|
|
client: {
|
|
|
|
|
id: string;
|
|
|
|
|
fullName: string;
|
2026-04-24 14:43:12 +02:00
|
|
|
nationality: string | null;
|
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
|
|
|
};
|
|
|
|
|
port: {
|
|
|
|
|
name: string;
|
|
|
|
|
logoUrl: string | null;
|
|
|
|
|
};
|
|
|
|
|
counts: {
|
|
|
|
|
interests: number;
|
|
|
|
|
documents: number;
|
|
|
|
|
invoices: number;
|
2026-04-24 14:43:12 +02:00
|
|
|
yachts: number;
|
|
|
|
|
memberships: number;
|
|
|
|
|
activeReservations: number;
|
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 async function getPortalDashboard(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalDashboard | null> {
|
2026-04-24 14:43:12 +02:00
|
|
|
const [client, port, interestCount, documentCount, yachtList, membershipList, reservationList] =
|
|
|
|
|
await Promise.all([
|
|
|
|
|
db.query.clients.findFirst({
|
|
|
|
|
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
|
|
|
|
with: { contacts: true },
|
|
|
|
|
}),
|
|
|
|
|
db.query.ports.findFirst({
|
|
|
|
|
where: eq(ports.id, portId),
|
|
|
|
|
}),
|
|
|
|
|
db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(interests)
|
|
|
|
|
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
|
|
|
|
db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(documents)
|
|
|
|
|
.where(and(eq(documents.clientId, clientId), eq(documents.portId, portId))),
|
|
|
|
|
getPortalUserYachts(clientId, portId),
|
|
|
|
|
getPortalUserMemberships(clientId, portId),
|
|
|
|
|
getPortalUserReservations(clientId, portId),
|
|
|
|
|
]);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
if (!client || !port) return null;
|
|
|
|
|
|
|
|
|
|
// Count invoices matched by client's billing email addresses
|
|
|
|
|
const emailContacts = (client.contacts ?? [])
|
|
|
|
|
.filter((c) => c.channel === 'email')
|
|
|
|
|
.map((c) => c.value.toLowerCase());
|
|
|
|
|
|
|
|
|
|
let invoiceCount = 0;
|
|
|
|
|
if (emailContacts.length > 0) {
|
|
|
|
|
const allPortInvoices = await db
|
|
|
|
|
.select({ billingEmail: invoices.billingEmail })
|
|
|
|
|
.from(invoices)
|
|
|
|
|
.where(eq(invoices.portId, portId));
|
|
|
|
|
invoiceCount = allPortInvoices.filter(
|
|
|
|
|
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
|
|
|
|
|
).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
client: {
|
|
|
|
|
id: client.id,
|
|
|
|
|
fullName: client.fullName,
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
|
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
|
|
|
},
|
|
|
|
|
port: {
|
|
|
|
|
name: port.name,
|
|
|
|
|
logoUrl: port.logoUrl ?? null,
|
|
|
|
|
},
|
|
|
|
|
counts: {
|
|
|
|
|
interests: interestCount[0]?.value ?? 0,
|
|
|
|
|
documents: documentCount[0]?.value ?? 0,
|
|
|
|
|
invoices: invoiceCount,
|
2026-04-24 14:43:12 +02:00
|
|
|
yachts: yachtList.length,
|
|
|
|
|
memberships: membershipList.length,
|
|
|
|
|
activeReservations: reservationList.length,
|
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
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Interests ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalInterest {
|
|
|
|
|
id: string;
|
|
|
|
|
pipelineStage: string;
|
|
|
|
|
leadCategory: string | null;
|
|
|
|
|
berthMooringNumber: string | null;
|
|
|
|
|
berthArea: string | null;
|
|
|
|
|
eoiStatus: string | null;
|
|
|
|
|
contractStatus: string | null;
|
|
|
|
|
dateFirstContact: Date | null;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getClientInterests(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalInterest[]> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
pipelineStage: interests.pipelineStage,
|
|
|
|
|
leadCategory: interests.leadCategory,
|
|
|
|
|
eoiStatus: interests.eoiStatus,
|
|
|
|
|
contractStatus: interests.contractStatus,
|
|
|
|
|
dateFirstContact: interests.dateFirstContact,
|
|
|
|
|
createdAt: interests.createdAt,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
2026-04-24 14:43:12 +02:00
|
|
|
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
|
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
|
|
|
.orderBy(interests.createdAt);
|
|
|
|
|
|
2026-05-05 02:41:52 +02:00
|
|
|
// Resolve each interest's primary berth via the junction (plan §3.4) -
|
|
|
|
|
// single round-trip for the whole list.
|
|
|
|
|
const primaryBerthMap = await getPrimaryBerthsForInterests(rows.map((r) => r.id));
|
|
|
|
|
const primaryBerthIds = Array.from(
|
|
|
|
|
new Set(Array.from(primaryBerthMap.values(), (b) => b.berthId)),
|
|
|
|
|
);
|
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-05-05 02:41:52 +02:00
|
|
|
const berthMap = new Map<string, { mooringNumber: string; area: string | null }>();
|
|
|
|
|
if (primaryBerthIds.length > 0) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const berthRows = await db
|
|
|
|
|
.select({ id: berths.id, mooringNumber: berths.mooringNumber, area: berths.area })
|
|
|
|
|
.from(berths)
|
2026-05-05 02:41:52 +02:00
|
|
|
.where(and(eq(berths.portId, portId), inArray(berths.id, primaryBerthIds)));
|
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
|
|
|
for (const b of berthRows) {
|
|
|
|
|
berthMap.set(b.id, { mooringNumber: b.mooringNumber, area: b.area });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 02:41:52 +02:00
|
|
|
return rows.map((r) => {
|
|
|
|
|
const primary = primaryBerthMap.get(r.id);
|
|
|
|
|
const berthMeta = primary ? (berthMap.get(primary.berthId) ?? null) : null;
|
|
|
|
|
return {
|
|
|
|
|
id: r.id,
|
|
|
|
|
pipelineStage: r.pipelineStage,
|
|
|
|
|
leadCategory: r.leadCategory,
|
|
|
|
|
berthMooringNumber: berthMeta?.mooringNumber ?? null,
|
|
|
|
|
berthArea: berthMeta?.area ?? null,
|
|
|
|
|
eoiStatus: r.eoiStatus,
|
|
|
|
|
contractStatus: r.contractStatus,
|
|
|
|
|
dateFirstContact: r.dateFirstContact,
|
|
|
|
|
createdAt: r.createdAt,
|
|
|
|
|
};
|
|
|
|
|
});
|
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 interface PortalDocument {
|
|
|
|
|
id: string;
|
|
|
|
|
documentType: string;
|
|
|
|
|
title: string;
|
|
|
|
|
status: string;
|
|
|
|
|
isManualUpload: boolean;
|
|
|
|
|
hasSignedFile: boolean;
|
|
|
|
|
signers: Array<{
|
|
|
|
|
signerName: string;
|
|
|
|
|
signerEmail: string;
|
|
|
|
|
signerRole: string;
|
|
|
|
|
status: string;
|
|
|
|
|
}>;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getClientDocuments(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalDocument[]> {
|
|
|
|
|
const rows = await db.query.documents.findMany({
|
2026-04-24 14:43:12 +02:00
|
|
|
where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)),
|
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
|
|
|
with: {
|
|
|
|
|
signers: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: (docs, { desc }) => [desc(docs.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return rows.map((doc) => ({
|
|
|
|
|
id: doc.id,
|
|
|
|
|
documentType: doc.documentType,
|
|
|
|
|
title: doc.title,
|
|
|
|
|
status: doc.status,
|
|
|
|
|
isManualUpload: doc.isManualUpload,
|
|
|
|
|
hasSignedFile: doc.signedFileId != null,
|
|
|
|
|
signers: (doc.signers ?? []).map((s) => ({
|
|
|
|
|
signerName: s.signerName,
|
|
|
|
|
signerEmail: s.signerEmail,
|
|
|
|
|
signerRole: s.signerRole,
|
|
|
|
|
status: s.status,
|
|
|
|
|
})),
|
|
|
|
|
createdAt: doc.createdAt,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Invoices ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalInvoice {
|
|
|
|
|
id: string;
|
|
|
|
|
invoiceNumber: string;
|
|
|
|
|
status: string;
|
|
|
|
|
currency: string;
|
|
|
|
|
total: string;
|
|
|
|
|
dueDate: string;
|
|
|
|
|
paymentStatus: string | null;
|
|
|
|
|
paymentDate: string | null;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getClientInvoices(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalInvoice[]> {
|
|
|
|
|
// Look up the client to get billing email for invoice matching
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
|
|
|
|
with: {
|
|
|
|
|
contacts: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!client) return [];
|
|
|
|
|
|
|
|
|
|
// Get client's email addresses to match against billingEmail
|
|
|
|
|
const emailContacts = (client.contacts ?? [])
|
|
|
|
|
.filter((c) => c.channel === 'email')
|
|
|
|
|
.map((c) => c.value.toLowerCase());
|
|
|
|
|
|
2026-05-11 13:53:10 +02:00
|
|
|
// G-I5: the most common B2B pattern is "individual client buys through their
|
|
|
|
|
// company" — those invoices ship with billingEntityType='company' and the
|
|
|
|
|
// portal user (client) is just a director of that company. Filtering on
|
|
|
|
|
// billingEmail alone hides these invoices. Resolve director memberships
|
|
|
|
|
// through company_memberships (role='director', active = endDate IS NULL)
|
|
|
|
|
// and OR them into the predicate.
|
|
|
|
|
const directorMemberships = await db
|
|
|
|
|
.select({ companyId: companyMemberships.companyId })
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(companyMemberships.clientId, clientId),
|
|
|
|
|
eq(companyMemberships.role, 'director'),
|
|
|
|
|
isNull(companyMemberships.endDate),
|
|
|
|
|
eq(companies.portId, portId),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
const directorCompanyIds = directorMemberships.map((m) => m.companyId);
|
|
|
|
|
|
|
|
|
|
// If the portal user has neither billing emails on file nor any active
|
|
|
|
|
// director memberships, there's nothing this query could return.
|
|
|
|
|
if (emailContacts.length === 0 && directorCompanyIds.length === 0) return [];
|
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-05-11 13:53:10 +02:00
|
|
|
// Build the OR predicate: (billingEmail ∈ client emails) OR
|
|
|
|
|
// (billingEntityType='company' AND billingEntityId ∈ director company ids).
|
|
|
|
|
const emailPredicate =
|
|
|
|
|
emailContacts.length > 0
|
|
|
|
|
? inArray(sql`lower(${invoices.billingEmail})`, emailContacts)
|
|
|
|
|
: undefined;
|
|
|
|
|
const companyPredicate =
|
|
|
|
|
directorCompanyIds.length > 0
|
|
|
|
|
? and(
|
|
|
|
|
eq(invoices.billingEntityType, 'company'),
|
|
|
|
|
inArray(invoices.billingEntityId, directorCompanyIds),
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
const matchPredicate =
|
|
|
|
|
emailPredicate && companyPredicate
|
|
|
|
|
? or(emailPredicate, companyPredicate)
|
|
|
|
|
: (emailPredicate ?? companyPredicate);
|
|
|
|
|
|
|
|
|
|
// Fetch only the invoices matching any of the client's email addresses or
|
|
|
|
|
// company memberships. Without the predicate push-down here every portal
|
|
|
|
|
// invoice page-load full-scanned the invoices table and filtered in JS —
|
|
|
|
|
// by 12mo it would have been the worst portal endpoint in the platform.
|
|
|
|
|
// Defensive limit 100 caps the upper bound for clients with abnormally many
|
|
|
|
|
// invoices.
|
2026-05-05 20:41:23 +02:00
|
|
|
const clientInvoices = await db
|
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
|
|
|
.select()
|
|
|
|
|
.from(invoices)
|
2026-05-11 13:53:10 +02:00
|
|
|
.where(and(eq(invoices.portId, portId), matchPredicate))
|
2026-05-05 20:41:23 +02:00
|
|
|
.orderBy(invoices.createdAt)
|
|
|
|
|
.limit(100);
|
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
|
|
|
|
|
|
|
|
return clientInvoices.map((inv) => ({
|
|
|
|
|
id: inv.id,
|
|
|
|
|
invoiceNumber: inv.invoiceNumber,
|
|
|
|
|
status: inv.status,
|
|
|
|
|
currency: inv.currency,
|
|
|
|
|
total: inv.total,
|
|
|
|
|
dueDate: inv.dueDate,
|
|
|
|
|
paymentStatus: inv.paymentStatus ?? null,
|
|
|
|
|
paymentDate: inv.paymentDate ?? null,
|
|
|
|
|
createdAt: inv.createdAt,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Document Download ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getDocumentDownloadUrl(
|
|
|
|
|
clientId: string,
|
|
|
|
|
documentId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(documents.id, documentId),
|
|
|
|
|
eq(documents.clientId, clientId),
|
|
|
|
|
eq(documents.portId, portId),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!doc) return null;
|
|
|
|
|
|
|
|
|
|
// Prefer signed file, fall back to original file
|
|
|
|
|
const fileId = doc.signedFileId ?? doc.fileId;
|
|
|
|
|
if (!fileId) return null;
|
|
|
|
|
|
fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:
* mergeClients gains a callerPortId option; the route caller passes
ctx.portId. removeInterestBerth now requires portId and verifies
both the interest and the berth share it before deleting the
junction row. All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
form-templates update + delete, invoices.detectOverdue per-row
update, notifications.markRead, clients.deleteRelationship.
company-memberships uses an inArray sub-select against port
companies (no port_id column on the table itself), covering
updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
reports.getDownloadUrl (file presign), berth-reservations.activate
(contractFileId attach guard), and residential.getResidentialInterestById
(residentialClient join).
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
|
|
|
// Defense-in-depth: scope the file lookup to the same port. Today the
|
|
|
|
|
// doc → file relationship is tightly coupled (signedFileId/fileId are
|
|
|
|
|
// assigned in same-port flows), but a future Documenso-webhook bug or
|
|
|
|
|
// bulk-import drift could leave a foreign-port file id on a doc and
|
|
|
|
|
// this presign call would otherwise return a URL to those bytes.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const file = await db.query.files.findFirst({
|
fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:
* mergeClients gains a callerPortId option; the route caller passes
ctx.portId. removeInterestBerth now requires portId and verifies
both the interest and the berth share it before deleting the
junction row. All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
form-templates update + delete, invoices.detectOverdue per-row
update, notifications.markRead, clients.deleteRelationship.
company-memberships uses an inArray sub-select against port
companies (no port_id column on the table itself), covering
updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
reports.getDownloadUrl (file presign), berth-reservations.activate
(contractFileId attach guard), and residential.getResidentialInterestById
(residentialClient join).
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
|
|
|
where: and(eq(files.id, fileId), eq(files.portId, portId)),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!file) return null;
|
|
|
|
|
|
fix(storage): route every file op through getStorageBackend()
Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction. Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc. Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.
Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend. A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).
Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:02 +02:00
|
|
|
return presignDownloadUrl(file.storagePath);
|
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-24 14:43:12 +02:00
|
|
|
|
|
|
|
|
// ─── Yachts (direct + via company) ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalYacht {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
hullNumber: string | null;
|
|
|
|
|
registration: string | null;
|
|
|
|
|
flag: string | null;
|
|
|
|
|
yearBuilt: number | null;
|
|
|
|
|
lengthFt: string | null;
|
|
|
|
|
widthFt: string | null;
|
|
|
|
|
status: string;
|
|
|
|
|
ownerContext: 'direct' | 'company';
|
|
|
|
|
ownerCompanyId: string | null;
|
|
|
|
|
ownerCompanyName: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toPortalYacht(y: typeof yachts.$inferSelect): PortalYacht {
|
|
|
|
|
return {
|
|
|
|
|
id: y.id,
|
|
|
|
|
name: y.name,
|
|
|
|
|
hullNumber: y.hullNumber,
|
|
|
|
|
registration: y.registration,
|
|
|
|
|
flag: y.flag,
|
|
|
|
|
yearBuilt: y.yearBuilt,
|
|
|
|
|
lengthFt: y.lengthFt,
|
|
|
|
|
widthFt: y.widthFt,
|
|
|
|
|
status: y.status,
|
|
|
|
|
ownerContext: 'direct',
|
|
|
|
|
ownerCompanyId: null,
|
|
|
|
|
ownerCompanyName: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPortalUserYachts(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalYacht[]> {
|
|
|
|
|
// 1. Direct yachts
|
|
|
|
|
const directYachts = await db.query.yachts.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(yachts.portId, portId),
|
|
|
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
|
|
|
eq(yachts.currentOwnerId, clientId),
|
|
|
|
|
isNull(yachts.archivedAt),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Active company memberships
|
|
|
|
|
const memberships = await db
|
|
|
|
|
.select({ companyId: companyMemberships.companyId })
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(companyMemberships.clientId, clientId),
|
|
|
|
|
eq(companies.portId, portId),
|
|
|
|
|
isNull(companyMemberships.endDate),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const companyIds = memberships.map((m) => m.companyId);
|
|
|
|
|
|
|
|
|
|
// De-dup by yacht.id
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const result: PortalYacht[] = [];
|
|
|
|
|
|
|
|
|
|
for (const y of directYachts) {
|
|
|
|
|
if (seen.has(y.id)) continue;
|
|
|
|
|
seen.add(y.id);
|
|
|
|
|
result.push(toPortalYacht(y));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (companyIds.length === 0) {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Company-owned yachts
|
|
|
|
|
const companyYachts = await db
|
|
|
|
|
.select({
|
|
|
|
|
yacht: yachts,
|
|
|
|
|
company: { id: companies.id, name: companies.name },
|
|
|
|
|
})
|
|
|
|
|
.from(yachts)
|
|
|
|
|
.innerJoin(companies, eq(yachts.currentOwnerId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(yachts.portId, portId),
|
|
|
|
|
eq(yachts.currentOwnerType, 'company'),
|
|
|
|
|
inArray(yachts.currentOwnerId, companyIds),
|
|
|
|
|
isNull(yachts.archivedAt),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const row of companyYachts) {
|
|
|
|
|
if (seen.has(row.yacht.id)) continue;
|
|
|
|
|
seen.add(row.yacht.id);
|
|
|
|
|
result.push({
|
|
|
|
|
id: row.yacht.id,
|
|
|
|
|
name: row.yacht.name,
|
|
|
|
|
hullNumber: row.yacht.hullNumber,
|
|
|
|
|
registration: row.yacht.registration,
|
|
|
|
|
flag: row.yacht.flag,
|
|
|
|
|
yearBuilt: row.yacht.yearBuilt,
|
|
|
|
|
lengthFt: row.yacht.lengthFt,
|
|
|
|
|
widthFt: row.yacht.widthFt,
|
|
|
|
|
status: row.yacht.status,
|
|
|
|
|
ownerContext: 'company',
|
|
|
|
|
ownerCompanyId: row.company.id,
|
|
|
|
|
ownerCompanyName: row.company.name,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Memberships ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalMembership {
|
|
|
|
|
membershipId: string;
|
|
|
|
|
role: string;
|
|
|
|
|
isPrimary: boolean;
|
|
|
|
|
startDate: Date;
|
|
|
|
|
company: {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
legalName: string | null;
|
|
|
|
|
status: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPortalUserMemberships(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalMembership[]> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
membershipId: companyMemberships.id,
|
|
|
|
|
role: companyMemberships.role,
|
|
|
|
|
isPrimary: companyMemberships.isPrimary,
|
|
|
|
|
startDate: companyMemberships.startDate,
|
|
|
|
|
company: {
|
|
|
|
|
id: companies.id,
|
|
|
|
|
name: companies.name,
|
|
|
|
|
legalName: companies.legalName,
|
|
|
|
|
status: companies.status,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(companyMemberships.clientId, clientId),
|
|
|
|
|
eq(companies.portId, portId),
|
|
|
|
|
isNull(companyMemberships.endDate),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Reservations ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PortalReservation {
|
|
|
|
|
id: string;
|
|
|
|
|
berthId: string;
|
|
|
|
|
berthMooringNumber: string | null;
|
|
|
|
|
yachtId: string;
|
|
|
|
|
yachtName: string | null;
|
|
|
|
|
status: 'pending' | 'active' | 'ended' | 'cancelled';
|
|
|
|
|
startDate: Date;
|
|
|
|
|
endDate: Date | null;
|
|
|
|
|
tenureType: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getPortalUserReservations(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
): Promise<PortalReservation[]> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: berthReservations.id,
|
|
|
|
|
berthId: berthReservations.berthId,
|
|
|
|
|
berthMooringNumber: berths.mooringNumber,
|
|
|
|
|
yachtId: berthReservations.yachtId,
|
|
|
|
|
yachtName: yachts.name,
|
|
|
|
|
status: berthReservations.status,
|
|
|
|
|
startDate: berthReservations.startDate,
|
|
|
|
|
endDate: berthReservations.endDate,
|
|
|
|
|
tenureType: berthReservations.tenureType,
|
|
|
|
|
})
|
|
|
|
|
.from(berthReservations)
|
|
|
|
|
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
|
|
|
|
|
.innerJoin(yachts, eq(berthReservations.yachtId, yachts.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(berthReservations.clientId, clientId),
|
|
|
|
|
eq(berthReservations.portId, portId),
|
|
|
|
|
inArray(berthReservations.status, ['pending', 'active']),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(berthReservations.createdAt));
|
|
|
|
|
return rows as PortalReservation[];
|
|
|
|
|
}
|