Files
pn-new-crm/src/lib/services/portal.service.ts

502 lines
15 KiB
TypeScript
Raw Normal View History

import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm';
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';
import { interests } from '@/lib/db/schema/interests';
import { documents, files } from '@/lib/db/schema/documents';
import { invoices } from '@/lib/db/schema/financial';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { berthReservations } from '@/lib/db/schema/reservations';
import { getPresignedUrl } from '@/lib/minio';
import { getCountryName } from '@/lib/i18n/countries';
// ─── Dashboard ────────────────────────────────────────────────────────────────
export interface PortalDashboard {
client: {
id: string;
fullName: string;
nationality: string | null;
};
port: {
name: string;
logoUrl: string | null;
};
counts: {
interests: number;
documents: number;
invoices: number;
yachts: number;
memberships: number;
activeReservations: number;
};
}
export async function getPortalDashboard(
clientId: string,
portId: string,
): Promise<PortalDashboard | null> {
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),
]);
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,
nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
},
port: {
name: port.name,
logoUrl: port.logoUrl ?? null,
},
counts: {
interests: interestCount[0]?.value ?? 0,
documents: documentCount[0]?.value ?? 0,
invoices: invoiceCount,
yachts: yachtList.length,
memberships: membershipList.length,
activeReservations: reservationList.length,
},
};
}
// ─── 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,
berthId: interests.berthId,
eoiStatus: interests.eoiStatus,
contractStatus: interests.contractStatus,
dateFirstContact: interests.dateFirstContact,
createdAt: interests.createdAt,
})
.from(interests)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
.orderBy(interests.createdAt);
// Fetch berth details for interests that have a berth
const berthIds = rows.flatMap((r) => (r.berthId ? [r.berthId] : []));
const berthMap = new Map<string, { mooringNumber: string; area: string | null }>();
if (berthIds.length > 0) {
const berthRows = await db
.select({ id: berths.id, mooringNumber: berths.mooringNumber, area: berths.area })
.from(berths)
.where(eq(berths.portId, portId));
for (const b of berthRows) {
berthMap.set(b.id, { mooringNumber: b.mooringNumber, area: b.area });
}
}
return rows.map((r) => ({
id: r.id,
pipelineStage: r.pipelineStage,
leadCategory: r.leadCategory,
berthMooringNumber: r.berthId ? (berthMap.get(r.berthId)?.mooringNumber ?? null) : null,
berthArea: r.berthId ? (berthMap.get(r.berthId)?.area ?? null) : null,
eoiStatus: r.eoiStatus,
contractStatus: r.contractStatus,
dateFirstContact: r.dateFirstContact,
createdAt: r.createdAt,
}));
}
// ─── 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({
where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)),
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());
if (emailContacts.length === 0) return [];
// Fetch invoices matching any of the client's email addresses
const allInvoices = await db
.select()
.from(invoices)
.where(eq(invoices.portId, portId))
.orderBy(invoices.createdAt);
const clientInvoices = allInvoices.filter(
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
);
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;
const file = await db.query.files.findFirst({
where: eq(files.id, fileId),
});
if (!file) return null;
return getPresignedUrl(file.storagePath);
}
// ─── 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[];
}