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:
@@ -6,15 +6,44 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`.
|
||||
* Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand
|
||||
* store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */
|
||||
const slugToIdCache = new Map<string, string>();
|
||||
|
||||
async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
|
||||
const cached = slugToIdCache.get(slug);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch('/api/v1/admin/ports', { credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
|
||||
const port = body.data?.find((p) => p.slug === slug);
|
||||
if (!port) return null;
|
||||
slugToIdCache.set(slug, port.id);
|
||||
return port.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side fetch wrapper that attaches the `X-Port-Id` header from the
|
||||
* UI store to every request. Used by all queryFn/mutationFn callbacks.
|
||||
*
|
||||
* Falls back to extracting the port slug from `window.location.pathname` and
|
||||
* resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been
|
||||
* populated yet (fresh page load before `PortProvider`'s effect has fired).
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(
|
||||
url: string,
|
||||
opts: ApiFetchOptions = {},
|
||||
): Promise<T> {
|
||||
const portId = useUIStore.getState().currentPortId;
|
||||
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
|
||||
let portId = useUIStore.getState().currentPortId;
|
||||
|
||||
if (!portId && typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
portId = await resolvePortIdFromSlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(opts.headers);
|
||||
if (portId) {
|
||||
|
||||
@@ -156,6 +156,23 @@ export function withAuth(
|
||||
override.permissionOverrides as Record<string, unknown>,
|
||||
) as RolePermissions;
|
||||
}
|
||||
|
||||
// Per-user residential toggle — flips the residential domain on
|
||||
// top of whatever the role grants. We never use it to *revoke*
|
||||
// residential access from a role that already grants it.
|
||||
if (portRole.residentialAccess && permissions) {
|
||||
permissions = {
|
||||
...permissions,
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (profile.isSuperAdmin && portId) {
|
||||
// Super admin still needs portSlug for response context.
|
||||
const port = await db.query.ports.findFirst({
|
||||
|
||||
@@ -16,7 +16,7 @@ export const auth = betterAuth({
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 12,
|
||||
minPasswordLength: 9,
|
||||
// Accounts are admin-created only — no self-service email verification flow.
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
|
||||
13
src/lib/db/migrations/0010_brave_joshua_kane.sql
Normal file
13
src/lib/db/migrations/0010_brave_joshua_kane.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "crm_user_invites" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"name" text,
|
||||
"token_hash" text NOT NULL,
|
||||
"is_super_admin" boolean DEFAULT false NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_crm_invites_token_hash" ON "crm_user_invites" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX "idx_crm_invites_email" ON "crm_user_invites" USING btree ("email");
|
||||
43
src/lib/db/migrations/0011_red_cargill.sql
Normal file
43
src/lib/db/migrations/0011_red_cargill.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "residential_clients" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"email" text,
|
||||
"phone" text,
|
||||
"place_of_residence" text,
|
||||
"preferred_contact_method" text,
|
||||
"status" text DEFAULT 'prospect' NOT NULL,
|
||||
"source" text,
|
||||
"notes" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "residential_interests" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"residential_client_id" text NOT NULL,
|
||||
"pipeline_stage" text DEFAULT 'new' NOT NULL,
|
||||
"source" text,
|
||||
"notes" text,
|
||||
"preferences" text,
|
||||
"assigned_to" text,
|
||||
"date_first_contact" timestamp with time zone,
|
||||
"date_last_contact" timestamp with time zone,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "residential_clients" ADD CONSTRAINT "residential_clients_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "residential_interests" ADD CONSTRAINT "residential_interests_residential_client_id_residential_clients_id_fk" FOREIGN KEY ("residential_client_id") REFERENCES "public"."residential_clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_port" ON "residential_clients" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_email" ON "residential_clients" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_clients_archived" ON "residential_clients" USING btree ("port_id","archived_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_port" ON "residential_interests" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_client" ON "residential_interests" USING btree ("residential_client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_stage" ON "residential_interests" USING btree ("port_id","pipeline_stage");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_assigned" ON "residential_interests" USING btree ("assigned_to");--> statement-breakpoint
|
||||
CREATE INDEX "idx_residential_interests_archived" ON "residential_interests" USING btree ("port_id","archived_at");
|
||||
1
src/lib/db/migrations/0012_large_zarda.sql
Normal file
1
src/lib/db/migrations/0012_large_zarda.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_port_roles" ADD COLUMN "residential_access" boolean DEFAULT false NOT NULL;
|
||||
8868
src/lib/db/migrations/meta/0010_snapshot.json
Normal file
8868
src/lib/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9225
src/lib/db/migrations/meta/0011_snapshot.json
Normal file
9225
src/lib/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9232
src/lib/db/migrations/meta/0012_snapshot.json
Normal file
9232
src/lib/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,27 @@
|
||||
"when": 1777210206070,
|
||||
"tag": "0009_outgoing_rumiko_fujikawa",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1777303428222,
|
||||
"tag": "0010_brave_joshua_kane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1777307410311,
|
||||
"tag": "0011_red_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1777308900666,
|
||||
"tag": "0012_large_zarda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
32
src/lib/db/schema/crm-invites.ts
Normal file
32
src/lib/db/schema/crm-invites.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { pgTable, text, boolean, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Single-use admin-issued invites for CRM users (better-auth realm).
|
||||
*
|
||||
* `tokenHash` is a SHA-256 hash of the raw token sent in the email. Lookups
|
||||
* happen by hash so a DB compromise never leaks active tokens. The invite
|
||||
* is consumed at /set-password — the route creates the better-auth `user`
|
||||
* row + `account` credential and the matching `user_profiles` extension.
|
||||
*/
|
||||
export const crmUserInvites = pgTable(
|
||||
'crm_user_invites',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_crm_invites_token_hash').on(table.tokenHash),
|
||||
index('idx_crm_invites_email').on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export type CrmUserInvite = typeof crmUserInvites.$inferSelect;
|
||||
export type NewCrmUserInvite = typeof crmUserInvites.$inferInsert;
|
||||
@@ -34,6 +34,13 @@ export * from './email';
|
||||
// Portal (client-portal auth)
|
||||
export * from './portal';
|
||||
|
||||
// CRM admin invites (better-auth realm)
|
||||
export * from './crm-invites';
|
||||
|
||||
// Residential (parallel domain — separate clients & interests for the
|
||||
// external residential team)
|
||||
export * from './residential';
|
||||
|
||||
// Operations
|
||||
export * from './operations';
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
customFieldDefinitions,
|
||||
customFieldValues,
|
||||
} from './system';
|
||||
import { residentialClients, residentialInterests } from './residential';
|
||||
|
||||
// ─── Ports ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,6 +117,8 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
||||
savedViews: many(savedViews),
|
||||
userNotificationPreferences: many(userNotificationPreferences),
|
||||
customFieldDefinitions: many(customFieldDefinitions),
|
||||
residentialClients: many(residentialClients),
|
||||
residentialInterests: many(residentialInterests),
|
||||
berthMaintenanceLogs: many(berthMaintenanceLog),
|
||||
clientMergeLogs: many(clientMergeLog),
|
||||
clientRelationships: many(clientRelationships),
|
||||
@@ -819,3 +822,24 @@ export const customFieldValuesRelations = relations(customFieldValues, ({ one })
|
||||
references: [customFieldDefinitions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Residential ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const residentialClientsRelations = relations(residentialClients, ({ one, many }) => ({
|
||||
port: one(ports, {
|
||||
fields: [residentialClients.portId],
|
||||
references: [ports.id],
|
||||
}),
|
||||
interests: many(residentialInterests),
|
||||
}));
|
||||
|
||||
export const residentialInterestsRelations = relations(residentialInterests, ({ one }) => ({
|
||||
port: one(ports, {
|
||||
fields: [residentialInterests.portId],
|
||||
references: [ports.id],
|
||||
}),
|
||||
client: one(residentialClients, {
|
||||
fields: [residentialInterests.residentialClientId],
|
||||
references: [residentialClients.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
94
src/lib/db/schema/residential.ts
Normal file
94
src/lib/db/schema/residential.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
|
||||
/**
|
||||
* Residential clients — physically separated from `clients` because the
|
||||
* residential side is handled by an external team that should never see
|
||||
* marina-side data, and vice versa. The two domains share a port but no
|
||||
* tables, so the access boundary is enforced at the schema level.
|
||||
*/
|
||||
export const residentialClients = pgTable(
|
||||
'residential_clients',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
fullName: text('full_name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
placeOfResidence: text('place_of_residence'),
|
||||
preferredContactMethod: text('preferred_contact_method'), // email | phone
|
||||
/**
|
||||
* Lifecycle: prospect | active | inactive. Distinct from
|
||||
* pipeline_stage on residential_interests (which is per-inquiry).
|
||||
*/
|
||||
status: text('status').notNull().default('prospect'),
|
||||
source: text('source'), // website | manual | referral | broker
|
||||
notes: text('notes'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_residential_clients_port').on(table.portId),
|
||||
index('idx_residential_clients_email').on(table.email),
|
||||
index('idx_residential_clients_archived').on(table.portId, table.archivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Residential interests — one per inquiry/lead. A residential_client can
|
||||
* have multiple interests over time (e.g. inquired about a unit in 2025,
|
||||
* came back about a different unit in 2026).
|
||||
*
|
||||
* Pipeline stages: new | contacted | viewing_scheduled | offer_made |
|
||||
* offer_accepted | closed_won | closed_lost.
|
||||
*/
|
||||
export const residentialInterests = pgTable(
|
||||
'residential_interests',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
residentialClientId: text('residential_client_id')
|
||||
.notNull()
|
||||
.references(() => residentialClients.id, { onDelete: 'cascade' }),
|
||||
pipelineStage: text('pipeline_stage').notNull().default('new'),
|
||||
source: text('source'), // website | manual | referral | broker
|
||||
notes: text('notes'),
|
||||
/**
|
||||
* Free-text capture of unit-type / size / floor / budget preferences —
|
||||
* residential leads are exploratory and the external team uses notes
|
||||
* heavily. Schema can grow into structured columns later if needed.
|
||||
*/
|
||||
preferences: text('preferences'),
|
||||
/**
|
||||
* better-auth user id of the residential team member working this lead.
|
||||
*/
|
||||
assignedTo: text('assigned_to'),
|
||||
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
||||
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_residential_interests_port').on(table.portId),
|
||||
index('idx_residential_interests_client').on(table.residentialClientId),
|
||||
index('idx_residential_interests_stage').on(table.portId, table.pipelineStage),
|
||||
index('idx_residential_interests_assigned').on(table.assignedTo),
|
||||
index('idx_residential_interests_archived').on(table.portId, table.archivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type ResidentialClient = typeof residentialClients.$inferSelect;
|
||||
export type NewResidentialClient = typeof residentialClients.$inferInsert;
|
||||
export type ResidentialInterest = typeof residentialInterests.$inferSelect;
|
||||
export type NewResidentialInterest = typeof residentialInterests.$inferInsert;
|
||||
@@ -118,6 +118,19 @@ export type RolePermissions = {
|
||||
manage_tags: boolean;
|
||||
system_backup: boolean;
|
||||
};
|
||||
residential_clients: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
residential_interests: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
change_stage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
@@ -251,6 +264,13 @@ export const userPortRoles = pgTable(
|
||||
roleId: text('role_id')
|
||||
.notNull()
|
||||
.references(() => roles.id, { onDelete: 'cascade' }),
|
||||
/**
|
||||
* Per-user per-port toggle that grants full residential domain access
|
||||
* (residential_clients.* and residential_interests.*) on top of the
|
||||
* user's primary role. Lets admins flip residential access for sales
|
||||
* staff individually without minting a second role.
|
||||
*/
|
||||
residentialAccess: boolean('residential_access').notNull().default(false),
|
||||
assignedBy: text('assigned_by'), // user ID of who assigned this
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -91,6 +91,14 @@ const ALL_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: true,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
@@ -157,6 +165,14 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
@@ -223,6 +239,14 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
@@ -289,6 +313,14 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
@@ -355,6 +387,14 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
manage_tags: false,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Port Definitions ────────────────────────────────────────────────────────
|
||||
|
||||
101
src/lib/email/templates/crm-invite.ts
Normal file
101
src/lib/email/templates/crm-invite.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
interface InviteData {
|
||||
link: string;
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function crmInviteEmail(data: InviteData): {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
} {
|
||||
const subject = `You're invited to the Port Nimara CRM`;
|
||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
Welcome to the Port Nimara CRM
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||
You've been invited to the Port Nimara CRM as a ${role}. Click the
|
||||
button below to set your password and activate your account. The
|
||||
link expires in ${data.ttlHours} hours.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Set up your account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
<strong>Port Nimara CRM</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Welcome to the Port Nimara CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`Port Nimara CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
107
src/lib/email/templates/residential-inquiry.ts
Normal file
107
src/lib/email/templates/residential-inquiry.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
}
|
||||
|
||||
export function residentialClientConfirmation(data: ResidentialClientConfirmationData) {
|
||||
const subject = 'Thank You for Your Interest — Port Nimara Residences';
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
Welcome to Port Nimara
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
Dear ${escapeHtml(data.firstName)},
|
||||
</p>
|
||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||
Thank you for expressing interest in Port Nimara residences. Our residential
|
||||
sales team has received your inquiry and will reach out to you shortly with
|
||||
more information.
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
If you have any questions in the meantime, please reach us at
|
||||
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Best regards,<br />
|
||||
<strong>The Port Nimara Residential Team</strong>
|
||||
</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
}
|
||||
|
||||
export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
crmDeepLink?: string;
|
||||
}
|
||||
|
||||
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
||||
const subject = `New Residential Inquiry — ${data.fullName}`;
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
New residential inquiry
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
|
||||
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
|
||||
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
|
||||
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
|
||||
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
|
||||
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
|
||||
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
|
||||
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
||||
<p style="font-size:14px; color:#666;">— Port Nimara CRM</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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'] });
|
||||
}
|
||||
|
||||
@@ -70,6 +70,15 @@ export interface ServerToClientEvents {
|
||||
source: string;
|
||||
matchScore: number;
|
||||
}) => void;
|
||||
|
||||
// Residential events
|
||||
'residential_client:created': (payload: { id: string }) => void;
|
||||
'residential_client:updated': (payload: { id: string }) => void;
|
||||
'residential_client:archived': (payload: { id: string }) => void;
|
||||
'residential_client:restored': (payload: { id: string }) => void;
|
||||
'residential_interest:created': (payload: { id: string }) => void;
|
||||
'residential_interest:updated': (payload: { id: string }) => void;
|
||||
'residential_interest:archived': (payload: { id: string }) => void;
|
||||
'interest:leadCategoryChanged': (payload: {
|
||||
interestId: string;
|
||||
oldCategory: string;
|
||||
|
||||
24
src/lib/validators/form-templates.ts
Normal file
24
src/lib/validators/form-templates.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const formFieldSchema = z.object({
|
||||
key: z.string().min(1).max(80),
|
||||
label: z.string().min(1).max(200),
|
||||
type: z.enum(['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox']),
|
||||
required: z.boolean().optional().default(false),
|
||||
options: z.array(z.string()).optional(),
|
||||
helpText: z.string().optional(),
|
||||
});
|
||||
|
||||
export const createFormTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
fields: z.array(formFieldSchema).min(1, 'At least one field is required'),
|
||||
branding: z.record(z.unknown()).optional(),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const updateFormTemplateSchema = createFormTemplateSchema.partial();
|
||||
|
||||
export type FormField = z.infer<typeof formFieldSchema>;
|
||||
export type CreateFormTemplateInput = z.infer<typeof createFormTemplateSchema>;
|
||||
export type UpdateFormTemplateInput = z.infer<typeof updateFormTemplateSchema>;
|
||||
87
src/lib/validators/residential.ts
Normal file
87
src/lib/validators/residential.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
|
||||
// ─── Residential client ──────────────────────────────────────────────────────
|
||||
|
||||
export const createResidentialClientSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.or(z.literal('').transform(() => undefined)),
|
||||
phone: z.string().optional(),
|
||||
placeOfResidence: z.string().optional(),
|
||||
preferredContactMethod: z.enum(['email', 'phone']).optional(),
|
||||
status: z.enum(['prospect', 'active', 'inactive']).optional().default('prospect'),
|
||||
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateResidentialClientSchema = createResidentialClientSchema.partial();
|
||||
|
||||
export const listResidentialClientsSchema = baseListQuerySchema.extend({
|
||||
status: z.enum(['prospect', 'active', 'inactive']).optional(),
|
||||
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
|
||||
});
|
||||
|
||||
// ─── Residential interest ────────────────────────────────────────────────────
|
||||
|
||||
export const PIPELINE_STAGES = [
|
||||
'new',
|
||||
'contacted',
|
||||
'viewing_scheduled',
|
||||
'offer_made',
|
||||
'offer_accepted',
|
||||
'closed_won',
|
||||
'closed_lost',
|
||||
] as const;
|
||||
|
||||
export const createResidentialInterestSchema = z.object({
|
||||
residentialClientId: z.string().min(1),
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).optional().default('new'),
|
||||
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
|
||||
notes: z.string().optional(),
|
||||
preferences: z.string().optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateResidentialInterestSchema = createResidentialInterestSchema
|
||||
.omit({ residentialClientId: true })
|
||||
.partial();
|
||||
|
||||
export const listResidentialInterestsSchema = baseListQuerySchema.extend({
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
residentialClientId: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Public website inquiry ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shape posted by the public website's residential interest form. Coerces
|
||||
* to internal create-shapes inside the public route.
|
||||
*/
|
||||
export const publicResidentialInquirySchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1),
|
||||
placeOfResidence: z.string().optional(),
|
||||
preferredContactMethod: z.enum(['email', 'phone']).optional(),
|
||||
notes: z.string().optional(),
|
||||
preferences: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CreateResidentialClientInput = z.infer<typeof createResidentialClientSchema>;
|
||||
export type UpdateResidentialClientInput = z.infer<typeof updateResidentialClientSchema>;
|
||||
export type ListResidentialClientsInput = z.infer<typeof listResidentialClientsSchema>;
|
||||
|
||||
export type CreateResidentialInterestInput = z.infer<typeof createResidentialInterestSchema>;
|
||||
export type UpdateResidentialInterestInput = z.infer<typeof updateResidentialInterestSchema>;
|
||||
export type ListResidentialInterestsInput = z.infer<typeof listResidentialInterestsSchema>;
|
||||
|
||||
export type PublicResidentialInquiryInput = z.infer<typeof publicResidentialInquirySchema>;
|
||||
@@ -7,6 +7,7 @@ export const createUserSchema = z.object({
|
||||
displayName: z.string().min(1).max(200),
|
||||
phone: z.string().optional(),
|
||||
roleId: z.string().uuid(),
|
||||
residentialAccess: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
@@ -16,6 +17,7 @@ export const updateUserSchema = z.object({
|
||||
phone: z.string().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
roleId: z.string().uuid().optional(),
|
||||
residentialAccess: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
|
||||
Reference in New Issue
Block a user