feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped

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>
This commit is contained in:
Matt Ciaccio
2026-04-27 21:54:32 +02:00
parent fac8021156
commit e8d61c91c4
121 changed files with 34105 additions and 1016 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
@@ -8,6 +8,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
@@ -59,7 +60,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
if (sort === 'fullName') sortColumn = clients.fullName;
else if (sort === 'createdAt') sortColumn = clients.createdAt;
const result = await buildListQuery({
const result = await buildListQuery<typeof clients.$inferSelect>({
table: clients,
portIdColumn: clients.portId,
portId,
@@ -75,7 +76,41 @@ export async function listClients(portId: string, query: ListClientsInput) {
archivedAtColumn: clients.archivedAt,
});
return result;
if (result.data.length === 0) return result;
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts] = await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
inArray(yachts.currentOwnerId, ids),
isNull(yachts.archivedAt),
),
)
.groupBy(yachts.currentOwnerId),
db
.select({ clientId: companyMemberships.clientId, count: count() })
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
return {
...result,
data: result.data.map((row) => ({
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
})),
};
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
@@ -157,6 +192,8 @@ export async function getClientById(id: string, portId: string) {
},
});
const portalEnabled = await isPortalEnabledForPort(portId);
return {
...client,
contacts,
@@ -164,6 +201,7 @@ export async function getClientById(id: string, portId: string) {
yachts: yachtRows,
companies: membershipRows,
activeReservations,
clientPortalEnabled: portalEnabled,
};
}

View File

@@ -106,9 +106,18 @@ export async function createCompany(portId: string, data: CreateCompanyInput, me
export async function getCompanyById(id: string, portId: string) {
const company = await db.query.companies.findFirst({
where: and(eq(companies.id, id), eq(companies.portId, portId)),
with: {
tags: { with: { tag: true } },
},
});
if (!company) throw new NotFoundError('Company');
return company;
const { tags: tagJoins, ...rest } = company as typeof company & {
tags: Array<{ tag: { id: string; name: string; color: string } }>;
};
return {
...rest,
tags: tagJoins.map((t) => t.tag),
};
}
// ─── Update ──────────────────────────────────────────────────────────────────
@@ -297,3 +306,32 @@ export async function upsertByName(portId: string, name: string, meta: AuditMeta
}
});
}
export async function setCompanyTags(
companyId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) });
if (!company || company.portId !== portId) throw new NotFoundError('Company');
await db.delete(companyTags).where(eq(companyTags.companyId, companyId));
if (tagIds.length > 0) {
await db.insert(companyTags).values(tagIds.map((tagId) => ({ companyId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'company',
entityId: companyId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] });
}

View File

@@ -0,0 +1,118 @@
import { and, eq, gt, isNull } from 'drizzle-orm';
import postgres from 'postgres';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
import { userProfiles } from '@/lib/db/schema/users';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';
const INVITE_TTL_HOURS = 72;
const MIN_PASSWORD_LENGTH = 9;
export async function createCrmInvite(args: {
email: string;
name?: string;
isSuperAdmin?: boolean;
}): Promise<{ inviteId: string; link: string }> {
const email = args.email.toLowerCase().trim();
const isSuperAdmin = args.isSuperAdmin ?? false;
// Reject if there's already a better-auth user with this email — they
// should reset their password instead.
const sql = postgres(env.DATABASE_URL);
try {
const existing = await sql<{ id: string }[]>`
SELECT id FROM "user" WHERE email = ${email} LIMIT 1
`;
if (existing.length > 0) {
throw new ConflictError(`A CRM user already exists for ${email}`);
}
} finally {
await sql.end();
}
const { raw, hash } = mintToken();
const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000);
const [row] = await db
.insert(crmUserInvites)
.values({
email,
name: args.name ?? null,
tokenHash: hash,
isSuperAdmin,
expiresAt,
})
.returning({ id: crmUserInvites.id });
if (!row) throw new Error('Failed to create CRM invite');
const link = `${env.APP_URL}/set-password?token=${raw}`;
const { subject, html, text } = crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
recipientName: args.name,
isSuperAdmin,
});
await sendEmail(email, subject, html, undefined, text);
return { inviteId: row.id, link };
}
export async function consumeCrmInvite(args: {
token: string;
password: string;
}): Promise<{ userId: string; email: string }> {
if (args.password.length < MIN_PASSWORD_LENGTH) {
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenHash = hashToken(args.token);
const invite = await db.query.crmUserInvites.findFirst({
where: and(
eq(crmUserInvites.tokenHash, tokenHash),
isNull(crmUserInvites.usedAt),
gt(crmUserInvites.expiresAt, new Date()),
),
});
if (!invite) {
throw new NotFoundError('Invite link is invalid or has expired');
}
// Create the better-auth user with the chosen password.
const result = await auth.api.signUpEmail({
body: {
email: invite.email,
password: args.password,
name: invite.name ?? invite.email.split('@')[0] ?? 'User',
},
});
const userId = result.user.id;
// Create the matching user_profiles extension row.
await db
.insert(userProfiles)
.values({
id: crypto.randomUUID(),
userId,
displayName: invite.name ?? invite.email,
isSuperAdmin: invite.isSuperAdmin,
isActive: true,
preferences: {},
})
.onConflictDoNothing();
await db
.update(crmUserInvites)
.set({ usedAt: new Date() })
.where(eq(crmUserInvites.id, invite.id));
return { userId, email: invite.email };
}

View File

@@ -0,0 +1,121 @@
import { and, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formTemplates } from '@/lib/db/schema/documents';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import type {
CreateFormTemplateInput,
UpdateFormTemplateInput,
} from '@/lib/validators/form-templates';
interface AuditMeta {
userId: string;
portId: string;
ipAddress?: string;
userAgent?: string;
}
export async function listFormTemplates(portId: string) {
return db
.select()
.from(formTemplates)
.where(eq(formTemplates.portId, portId))
.orderBy(desc(formTemplates.updatedAt));
}
export async function getFormTemplateById(id: string, portId: string) {
const tpl = await db.query.formTemplates.findFirst({
where: and(eq(formTemplates.id, id), eq(formTemplates.portId, portId)),
});
if (!tpl) throw new NotFoundError('Form template');
return tpl;
}
export async function createFormTemplate(
portId: string,
data: CreateFormTemplateInput,
meta: AuditMeta,
) {
const [tpl] = await db
.insert(formTemplates)
.values({
portId,
name: data.name,
description: data.description ?? null,
fields: data.fields,
branding: data.branding ?? {},
isActive: data.isActive ?? true,
createdBy: meta.userId,
})
.returning();
if (!tpl) throw new Error('Insert failed');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'form_template',
entityId: tpl.id,
newValue: { name: data.name },
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return tpl;
}
export async function updateFormTemplate(
id: string,
portId: string,
data: UpdateFormTemplateInput,
meta: AuditMeta,
) {
const existing = await getFormTemplateById(id, portId);
const [updated] = await db
.update(formTemplates)
.set({
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description ?? null }),
...(data.fields !== undefined && { fields: data.fields }),
...(data.branding !== undefined && { branding: data.branding }),
...(data.isActive !== undefined && { isActive: data.isActive }),
updatedAt: new Date(),
})
.where(eq(formTemplates.id, id))
.returning();
if (!updated) throw new NotFoundError('Form template');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'form_template',
entityId: id,
oldValue: { name: existing.name },
newValue: data,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
return updated;
}
export async function deleteFormTemplate(id: string, portId: string, meta: AuditMeta) {
await getFormTemplateById(id, portId);
await db.delete(formTemplates).where(eq(formTemplates.id, id));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'form_template',
entityId: id,
ipAddress: meta.ipAddress ?? '',
userAgent: meta.userAgent ?? '',
});
}

View File

@@ -3,13 +3,15 @@ import { eq, and, desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
import { userProfiles } from '@/lib/db/schema/users';
import { NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
type EntityType = 'clients' | 'interests';
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -19,33 +21,43 @@ async function verifyParentBelongsToPort(
portId: string,
): Promise<void> {
if (entityType === 'clients') {
const client = await db
const r = await db
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
.limit(1);
if (!client.length) throw new NotFoundError('Client');
} else {
const interest = await db
if (!r.length) throw new NotFoundError('Client');
} else if (entityType === 'interests') {
const r = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
.limit(1);
if (!interest.length) throw new NotFoundError('Interest');
if (!r.length) throw new NotFoundError('Interest');
} else if (entityType === 'yachts') {
const r = await db
.select({ id: yachts.id })
.from(yachts)
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Yacht');
} else {
const r = await db
.select({ id: companies.id })
.from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Company');
}
}
// ─── Service ─────────────────────────────────────────────────────────────────
export async function listForEntity(
portId: string,
entityType: EntityType,
entityId: string,
) {
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'clients') {
const rows = await db
return db
.select({
id: clientNotes.id,
clientId: clientNotes.clientId,
@@ -61,9 +73,8 @@ export async function listForEntity(
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
.where(eq(clientNotes.clientId, entityId))
.orderBy(desc(clientNotes.createdAt));
return rows;
} else {
const rows = await db
} else if (entityType === 'interests') {
return db
.select({
id: interestNotes.id,
interestId: interestNotes.interestId,
@@ -79,7 +90,40 @@ export async function listForEntity(
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
.where(eq(interestNotes.interestId, entityId))
.orderBy(desc(interestNotes.createdAt));
return rows;
} else if (entityType === 'yachts') {
return db
.select({
id: yachtNotes.id,
yachtId: yachtNotes.yachtId,
authorId: yachtNotes.authorId,
content: yachtNotes.content,
mentions: yachtNotes.mentions,
isLocked: yachtNotes.isLocked,
createdAt: yachtNotes.createdAt,
updatedAt: yachtNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(yachtNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt));
} else {
return db
.select({
id: companyNotes.id,
companyId: companyNotes.companyId,
authorId: companyNotes.authorId,
content: companyNotes.content,
mentions: companyNotes.mentions,
isLocked: companyNotes.isLocked,
createdAt: companyNotes.createdAt,
updatedAt: companyNotes.createdAt,
authorName: userProfiles.displayName,
})
.from(companyNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt));
}
}
@@ -92,6 +136,32 @@ export async function create(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [note] = await db
.insert(yachtNotes)
.values({ yachtId: entityId, authorId, content: data.content })
.returning();
if (!note) throw new Error('Insert failed');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'companies') {
const [note] = await db
.insert(companyNotes)
.values({ companyId: entityId, authorId, content: data.content })
.returning();
if (!note) throw new Error('Insert failed');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null, updatedAt: note.createdAt };
}
if (entityType === 'clients') {
const [note] = await db
.insert(clientNotes)
@@ -165,6 +235,7 @@ export async function create(
return { ...note, authorName };
}
throw new Error(`Unsupported entityType: ${entityType as string}`);
}
export async function update(
@@ -176,6 +247,56 @@ export async function update(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [existing] = await db
.select()
.from(yachtNotes)
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(yachtNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(yachtNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'companies') {
const [existing] = await db
.select()
.from(companyNotes)
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(companyNotes)
.set({ content: data.content })
.where(eq(companyNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return {
...updated,
authorName: profile[0]?.displayName ?? null,
updatedAt: updated.createdAt,
};
}
if (entityType === 'clients') {
const [existing] = await db
.select()
@@ -241,6 +362,32 @@ export async function deleteNote(
) {
await verifyParentBelongsToPort(entityType, entityId, portId);
if (entityType === 'yachts') {
const [existing] = await db
.select()
.from(yachtNotes)
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(yachtNotes).where(eq(yachtNotes.id, noteId));
return existing;
}
if (entityType === 'companies') {
const [existing] = await db
.select()
.from(companyNotes)
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(companyNotes).where(eq(companyNotes.id, noteId));
return existing;
}
if (entityType === 'clients') {
const [existing] = await db
.select()

View File

@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal';
import { systemSettings } from '@/lib/db/schema/system';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
@@ -15,6 +16,19 @@ import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal
const ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30;
const MIN_PASSWORD_LENGTH = 9;
const PORTAL_ENABLED_KEY = 'client_portal_enabled';
/**
* Per-port toggle for the client portal feature. Default-on so existing
* deployments behave the way they did before this setting existed.
*/
export async function isPortalEnabledForPort(portId: string): Promise<boolean> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, PORTAL_ENABLED_KEY), eq(systemSettings.portId, portId)),
});
if (!row) return true;
return row.value === true || row.value === 'true';
}
// ─── Admin-side: invite a client to the portal ───────────────────────────────
@@ -32,6 +46,10 @@ export async function createPortalUser(args: {
});
if (!client) throw new NotFoundError('Client');
if (!(await isPortalEnabledForPort(args.portId))) {
throw new ConflictError('Client portal is disabled for this port');
}
// Email uniqueness check is enforced at the DB level too, but we do a
// friendlier preflight so the admin sees a clear conflict error.
const existing = await db.query.portalUsers.findFirst({
@@ -96,6 +114,9 @@ async function issueActivationToken(
}
export async function resendActivation(portalUserId: string, portId: string): Promise<void> {
if (!(await isPortalEnabledForPort(portId))) {
throw new ConflictError('Client portal is disabled for this port');
}
const user = await db.query.portalUsers.findFirst({
where: and(eq(portalUsers.id, portalUserId), eq(portalUsers.portId, portId)),
});
@@ -113,6 +134,13 @@ export async function activateAccount(rawToken: string, password: string): Promi
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenRow = await consumeToken(rawToken, 'activation');
const portalUser = await db.query.portalUsers.findFirst({
where: eq(portalUsers.id, tokenRow.portalUserId),
});
if (!portalUser) throw new ValidationError('Invalid or expired token');
if (!(await isPortalEnabledForPort(portalUser.portId))) {
throw new ValidationError('Client portal is disabled for this port');
}
const passwordHash = await hashPassword(password);
await db
.update(portalUsers)
@@ -147,6 +175,13 @@ export async function signIn(args: {
throw new UnauthorizedError('Invalid email or password');
}
// Disabled-port check happens AFTER the credential check so that a wrong
// password on a disabled-port account still surfaces "invalid email or
// password" — we never leak which ports have the portal turned off.
if (!(await isPortalEnabledForPort(user.portId))) {
throw new UnauthorizedError('Invalid email or password');
}
const token = await createPortalToken({
clientId: user.clientId,
portId: user.portId,
@@ -174,6 +209,13 @@ export async function requestPasswordReset(email: string): Promise<void> {
return;
}
// Same silent no-op when the port has the portal disabled — keeps the
// disabled-state from leaking through the public reset endpoint.
if (!(await isPortalEnabledForPort(user.portId))) {
logger.debug({ portId: user.portId }, 'Password reset on disabled-portal port');
return;
}
const { raw, hash } = mintToken();
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000);
@@ -206,6 +248,13 @@ export async function resetPassword(rawToken: string, password: string): Promise
throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const tokenRow = await consumeToken(rawToken, 'reset');
const portalUser = await db.query.portalUsers.findFirst({
where: eq(portalUsers.id, tokenRow.portalUserId),
});
if (!portalUser) throw new ValidationError('Invalid or expired token');
if (!(await isPortalEnabledForPort(portalUser.portId))) {
throw new ValidationError('Client portal is disabled for this port');
}
const passwordHash = await hashPassword(password);
await db
.update(portalUsers)

View File

@@ -0,0 +1,328 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import type {
CreateResidentialClientInput,
CreateResidentialInterestInput,
ListResidentialClientsInput,
ListResidentialInterestsInput,
UpdateResidentialClientInput,
UpdateResidentialInterestInput,
} from '@/lib/validators/residential';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Residential clients ─────────────────────────────────────────────────────
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
const { page, limit, sort, order, search, includeArchived, status, source } = query;
const filters = [];
if (status) filters.push(eq(residentialClients.status, status));
if (source) filters.push(eq(residentialClients.source, source));
return buildListQuery({
table: residentialClients,
portIdColumn: residentialClients.portId,
portId,
idColumn: residentialClients.id,
updatedAtColumn: residentialClients.updatedAt,
filters,
sort: sort
? {
column:
(residentialClients[sort as keyof typeof residentialClients] as never) ??
residentialClients.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [
residentialClients.fullName,
residentialClients.email,
residentialClients.phone,
residentialClients.placeOfResidence,
],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialClients.archivedAt,
});
}
export async function getResidentialClientById(id: string, portId: string) {
const client = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!client) throw new NotFoundError('Residential client');
const interests = await db.query.residentialInterests.findMany({
where: eq(residentialInterests.residentialClientId, id),
orderBy: (t, { desc }) => [desc(t.updatedAt)],
});
return { ...client, interests };
}
export async function createResidentialClient(
portId: string,
data: CreateResidentialClientInput,
meta: AuditMeta,
) {
const [row] = await db
.insert(residentialClients)
.values({ portId, ...data })
.returning();
if (!row) throw new Error('Failed to create residential client');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_client',
entityId: row.id,
newValue: { fullName: row.fullName, email: row.email ?? undefined },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id });
return row;
}
export async function updateResidentialClient(
id: string,
portId: string,
data: UpdateResidentialClientInput,
meta: AuditMeta,
) {
const before = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!before) throw new NotFoundError('Residential client');
const [updated] = await db
.update(residentialClients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential client');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_client',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:updated', { id });
return updated;
}
export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await softDelete(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:archived', { id });
}
export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialClients.findFirst({
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential client');
await restore(residentialClients, residentialClients.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'residential_client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_client:restored', { id });
}
// ─── Residential interests ───────────────────────────────────────────────────
export async function listResidentialInterests(
portId: string,
query: ListResidentialInterestsInput,
) {
const {
page,
limit,
sort,
order,
search,
includeArchived,
pipelineStage,
assignedTo,
residentialClientId,
} = query;
const filters = [];
if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage));
if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
if (residentialClientId)
filters.push(eq(residentialInterests.residentialClientId, residentialClientId));
return buildListQuery({
table: residentialInterests,
portIdColumn: residentialInterests.portId,
portId,
idColumn: residentialInterests.id,
updatedAtColumn: residentialInterests.updatedAt,
filters,
sort: sort
? {
column:
(residentialInterests[sort as keyof typeof residentialInterests] as never) ??
residentialInterests.updatedAt,
direction: order ?? 'desc',
}
: undefined,
page,
pageSize: limit,
searchColumns: [residentialInterests.notes, residentialInterests.preferences],
searchTerm: search,
includeArchived,
archivedAtColumn: residentialInterests.archivedAt,
});
}
export async function getResidentialInterestById(id: string, portId: string) {
const interest = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!interest) throw new NotFoundError('Residential interest');
const client = await db.query.residentialClients.findFirst({
where: eq(residentialClients.id, interest.residentialClientId),
});
return { ...interest, client };
}
export async function createResidentialInterest(
portId: string,
data: CreateResidentialInterestInput,
meta: AuditMeta,
) {
// Validate the residential client belongs to this port — prevents
// cross-port linking.
const client = await db.query.residentialClients.findFirst({
where: and(
eq(residentialClients.id, data.residentialClientId),
eq(residentialClients.portId, portId),
),
});
if (!client) throw new NotFoundError('Residential client');
const [row] = await db
.insert(residentialInterests)
.values({ portId, ...data })
.returning();
if (!row) throw new Error('Failed to create residential interest');
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'residential_interest',
entityId: row.id,
newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });
return row;
}
export async function updateResidentialInterest(
id: string,
portId: string,
data: UpdateResidentialInterestInput,
meta: AuditMeta,
) {
const before = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!before) throw new NotFoundError('Residential interest');
const [updated] = await db
.update(residentialInterests)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Residential interest');
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'residential_interest',
entityId: id,
oldValue: diffEntity(before, updated) as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:updated', { id });
return updated;
}
export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.residentialInterests.findFirst({
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
});
if (!existing) throw new NotFoundError('Residential interest');
await softDelete(residentialInterests, residentialInterests.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'residential_interest',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'residential_interest:archived', { id });
}

View File

@@ -76,6 +76,7 @@ export async function getUser(userId: string, portId: string) {
avatarUrl: profile.avatarUrl,
preferences: profile.preferences,
role: { id: portRole.role.id, name: portRole.role.name },
residentialAccess: portRole.residentialAccess,
createdAt: profile.createdAt,
};
}
@@ -118,6 +119,7 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
userId: newUserId,
portId,
roleId: data.roleId,
residentialAccess: data.residentialAccess ?? false,
assignedBy: meta.userId,
});
@@ -167,16 +169,26 @@ export async function updateUser(
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
}
// Update role assignment
// Update role assignment + per-user toggles
const portRoleUpdates: Record<string, unknown> = {};
if (data.roleId && data.roleId !== portRole.roleId) {
const newRole = await db.query.roles.findFirst({
where: eq(roles.id, data.roleId),
});
if (!newRole) throw new ValidationError('Invalid role ID');
portRoleUpdates.roleId = data.roleId;
portRoleUpdates.assignedBy = meta.userId;
}
if (
data.residentialAccess !== undefined &&
data.residentialAccess !== portRole.residentialAccess
) {
portRoleUpdates.residentialAccess = data.residentialAccess;
}
if (Object.keys(portRoleUpdates).length > 0) {
await db
.update(userPortRoles)
.set({ roleId: data.roleId, assignedBy: meta.userId })
.set(portRoleUpdates)
.where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
}

View File

@@ -1,6 +1,6 @@
import { and, eq, ilike, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema';
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
import type { Yacht } from '@/lib/db/schema/yachts';
import { companies } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
@@ -102,9 +102,18 @@ export async function createYacht(portId: string, data: CreateYachtInput, meta:
export async function getYachtById(id: string, portId: string) {
const yacht = await db.query.yachts.findFirst({
where: and(eq(yachts.id, id), eq(yachts.portId, portId)),
with: {
tags: { with: { tag: true } },
},
});
if (!yacht) throw new NotFoundError('Yacht');
return yacht;
const { tags: tagJoins, ...rest } = yacht as typeof yacht & {
tags: Array<{ tag: { id: string; name: string; color: string } }>;
};
return {
...rest,
tags: tagJoins.map((t) => t.tag),
};
}
export async function updateYacht(
@@ -348,3 +357,32 @@ export async function autocomplete(portId: string, q: string) {
)
.limit(10);
}
export async function setYachtTags(
yachtId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht');
await db.delete(yachtTags).where(eq(yachtTags.yachtId, yachtId));
if (tagIds.length > 0) {
await db.insert(yachtTags).values(tagIds.map((tagId) => ({ yachtId, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'yacht',
entityId: yachtId,
newValue: { tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'yacht:updated', { yachtId, changedFields: ['tags'] });
}