feat(platform): residential module + admin UI + reliability fixes
Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
|
||||
118
src/lib/services/crm-invite.service.ts
Normal file
118
src/lib/services/crm-invite.service.ts
Normal 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 };
|
||||
}
|
||||
121
src/lib/services/form-templates.service.ts
Normal file
121
src/lib/services/form-templates.service.ts
Normal 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 ?? '',
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
328
src/lib/services/residential.service.ts
Normal file
328
src/lib/services/residential.service.ts
Normal 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 });
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user