diff --git a/docs/superpowers/plans/2026-04-14-inquiry-notifications.md b/docs/superpowers/plans/2026-04-14-inquiry-notifications.md new file mode 100644 index 0000000..bbcca9e --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-inquiry-notifications.md @@ -0,0 +1,1119 @@ +# Inquiry Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a client registers interest via the website, send them a branded confirmation email and notify the sales team via in-app notifications and email. + +**Architecture:** Extend the existing `POST /api/public/interests` endpoint to accept all website form fields (including address), resolve berths by mooring number, then queue emails and fire notifications asynchronously via BullMQ. Sales team targeting uses role-based permission checks; delivery preferences use the existing `user_notification_preferences` table. Two new system settings control external recipients and contact email. + +**Tech Stack:** Drizzle ORM (schema + migration), Zod (validation), BullMQ (job queue), Nodemailer (SMTP), React (settings UI) + +--- + +### Task 1: Add `clientAddresses` Table to DB Schema + +**Files:** + +- Modify: `src/lib/db/schema/clients.ts:139-149` +- Modify: `src/lib/db/schema/relations.ts:140-159` + +- [ ] **Step 1: Add the `clientAddresses` table definition to `src/lib/db/schema/clients.ts`** + +Add after the `clientMergeLog` table (before the type exports at line 140): + +```typescript +export const clientAddresses = pgTable( + 'client_addresses', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + clientId: text('client_id') + .notNull() + .references(() => clients.id, { onDelete: 'cascade' }), + portId: text('port_id') + .notNull() + .references(() => ports.id, { onDelete: 'cascade' }), + label: text('label').notNull().default('Primary'), + streetAddress: text('street_address'), + city: text('city'), + stateProvince: text('state_province'), + postalCode: text('postal_code'), + country: text('country'), + isPrimary: boolean('is_primary').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('idx_ca_client').on(table.clientId), index('idx_ca_port').on(table.portId)], +); +``` + +Add the type exports alongside the existing ones: + +```typescript +export type ClientAddress = typeof clientAddresses.$inferSelect; +export type NewClientAddress = typeof clientAddresses.$inferInsert; +``` + +- [ ] **Step 2: Add relations for `clientAddresses` in `src/lib/db/schema/relations.ts`** + +Import `clientAddresses` from `./clients` (add to the existing clients import block at line 11). + +Add `addresses: many(clientAddresses)` to the `clientsRelations` definition (inside the `({ one, many })` callback, after `formSubmissions: many(formSubmissions)` at line 158). + +Add a new relations block after `clientContactsRelations` (after line 166): + +```typescript +export const clientAddressesRelations = relations(clientAddresses, ({ one }) => ({ + client: one(clients, { + fields: [clientAddresses.clientId], + references: [clients.id], + }), + port: one(ports, { + fields: [clientAddresses.portId], + references: [ports.id], + }), +})); +``` + +Add `clientAddresses: many(clientAddresses)` to `portsRelations` (after `clientMergeLogs` at line 101). + +- [ ] **Step 3: Verify the schema compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors related to `clientAddresses`. + +- [ ] **Step 4: Generate the Drizzle migration** + +Run: `cd C:/repos/new-pn-crm && pnpm db:generate` +Expected: A new migration file in `src/lib/db/migrations/` creating the `client_addresses` table. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/db/schema/clients.ts src/lib/db/schema/relations.ts src/lib/db/migrations/ +git commit -m "feat: add client_addresses table for multi-address storage" +``` + +--- + +### Task 2: Expand Public Interest Validator + +**Files:** + +- Modify: `src/lib/validators/interests.ts:67-79` + +- [ ] **Step 1: Expand the `publicInterestSchema` in `src/lib/validators/interests.ts`** + +Replace the existing `publicInterestSchema` (lines 67-79) with: + +```typescript +const addressSchema = z.object({ + street: z.string().max(500).optional(), + city: z.string().max(200).optional(), + stateProvince: z.string().max(200).optional(), + postalCode: z.string().max(50).optional(), + country: z.string().max(100).optional(), +}); + +export const publicInterestSchema = z + .object({ + // New: first/last split + firstName: z.string().min(1).max(100).optional(), + lastName: z.string().min(1).max(100).optional(), + // Backward compat + fullName: z.string().min(1).max(200).optional(), + email: z.string().email(), + phone: z.string().min(1), + preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(), + mooringNumber: z.string().max(50).optional(), + companyName: z.string().optional(), + yachtName: z.string().optional(), + yachtLengthFt: z.coerce.number().positive().optional(), + yachtWidthFt: z.coerce.number().positive().optional(), + yachtDraftFt: z.coerce.number().positive().optional(), + preferredBerthSize: z.string().optional(), + source: z.literal('website').default('website'), + notes: z.string().max(2000).optional(), + address: addressSchema.optional(), + }) + .refine((data) => data.fullName || (data.firstName && data.lastName), { + message: 'Either fullName or both firstName and lastName are required', + path: ['fullName'], + }); +``` + +Update the `PublicInterestInput` type export (line 95) to match: + +```typescript +export type PublicInterestInput = z.infer; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. The route handler at `src/app/api/public/interests/route.ts` will have type errors because it references `data.fullName` as non-optional — that's expected and will be fixed in Task 6. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/validators/interests.ts +git commit -m "feat: expand public interest schema with name split, address, berth" +``` + +--- + +### Task 3: Extend `sendEmail` to Support Plain-Text Fallback + +**Files:** + +- Modify: `src/lib/email/index.ts:36-57` + +- [ ] **Step 1: Add `text` parameter to `sendEmail`** + +The current `sendEmail` function signature is: + +```typescript +export async function sendEmail( + to: string | string[], + subject: string, + html: string, + from?: string, +): Promise; +``` + +Change it to accept an optional `text` parameter: + +```typescript +export async function sendEmail( + to: string | string[], + subject: string, + html: string, + from?: string, + text?: string, +): Promise { + const transporter = createTransporter(); + + const info = await transporter.sendMail({ + from: from ?? `Port Nimara CRM `, + to: Array.isArray(to) ? to.join(', ') : to, + subject, + html, + ...(text ? { text } : {}), + }); + + logger.debug({ messageId: info.messageId, to, subject }, 'Email sent'); + + return info; +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. Existing callers pass 3-4 args and are unaffected by the new optional 5th param. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/email/index.ts +git commit -m "feat: add optional plain-text fallback to sendEmail" +``` + +--- + +### Task 4: Create Email Templates + +**Files:** + +- Create: `src/lib/email/templates/inquiry-client-confirmation.ts` +- Create: `src/lib/email/templates/inquiry-sales-notification.ts` + +- [ ] **Step 1: Create the templates directory** + +Run: `mkdir -p C:/repos/new-pn-crm/src/lib/email/templates` + +- [ ] **Step 2: Create the client confirmation email template** + +Create `src/lib/email/templates/inquiry-client-confirmation.ts`: + +```typescript +export interface InquiryClientConfirmationData { + firstName: string; + mooringNumber: string | null; + contactEmail: string; +} + +export function inquiryClientConfirmation(data: InquiryClientConfirmationData) { + const { firstName, mooringNumber, contactEmail } = data; + + const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Port Nimara Berth'; + + const subject = mooringNumber + ? `Thank You for Your Interest in Berth ${mooringNumber}` + : 'Thank You for Your Interest in a Port Nimara Berth'; + + const html = ` + + + + + ${subject} + + + + + + + +
+ + + + +
+
+ Port Nimara Logo +
+

Dear ${escapeHtml(firstName)},

+

+ Thank you for expressing interest in ${escapeHtml(berthText)}. + Our team has registered your interest, and we will reach out to you very shortly + by your preferred method of contact with more information. +

+

+ If you have any questions, please feel free to reach out to us at + ${escapeHtml(contactEmail)}. +

+

+ Best regards,
+ The Port Nimara Sales Team +

+
+
+ +`; + + const text = [ + `Dear ${firstName},`, + '', + `Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`, + '', + `If you have any questions, please feel free to reach out to us at ${contactEmail}.`, + '', + 'Best regards,', + 'The Port Nimara Sales Team', + ].join('\n'); + + return { subject, html, text }; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +``` + +- [ ] **Step 3: Create the sales notification email template** + +Create `src/lib/email/templates/inquiry-sales-notification.ts`: + +```typescript +export interface InquirySalesNotificationData { + fullName: string; + email: string; + phone: string; + mooringNumber: string | null; + crmUrl: string; +} + +export function inquirySalesNotification(data: InquirySalesNotificationData) { + const { fullName, email, phone, mooringNumber, crmUrl } = data; + const mooringDisplay = mooringNumber || 'None'; + + const subject = 'New Interest - Port Nimara'; + + const html = ` + + + + + New Interest - Port Nimara + + + + + + + +
+ + + + +
+
+ Port Nimara Logo +
+

Dear Administrator,

+

${escapeHtml(fullName)} has expressed their interest in Port Nimara. Here are their details:

+

Name: ${escapeHtml(fullName)}

+

Email: ${escapeHtml(email)}

+

Telephone: ${escapeHtml(phone)}

+

Berths Selected: ${escapeHtml(mooringDisplay)}

+

Please visit the Port Nimara CRM to view more information.

+

Thank you,
Port Nimara CRM

+
+
+ +`; + + const text = [ + 'Dear Administrator,', + '', + `${fullName} has expressed their interest in Port Nimara. Here are their details:`, + '', + `Name: ${fullName}`, + `Email: ${email}`, + `Telephone: ${phone}`, + `Berths Selected: ${mooringDisplay}`, + '', + `Please visit the Port Nimara CRM (${crmUrl}) to view more information.`, + '', + 'Thank you', + 'Port Nimara CRM', + ].join('\n'); + + return { subject, html, text }; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +``` + +- [ ] **Step 4: Verify templates compile** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/email/templates/ +git commit -m "feat: add inquiry email templates for client confirmation and sales notification" +``` + +--- + +### Task 5: Create Inquiry Notification Service + +**Files:** + +- Create: `src/lib/services/inquiry-notifications.service.ts` + +This service handles the logic for finding users with interests permissions and firing notifications + external emails. + +- [ ] **Step 1: Create `src/lib/services/inquiry-notifications.service.ts`** + +```typescript +import { eq, and } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { userPortRoles, roles, user } from '@/lib/db/schema/users'; +import { systemSettings } from '@/lib/db/schema/system'; +import type { RolePermissions } from '@/lib/db/schema/users'; +import { createNotification } from '@/lib/services/notifications.service'; +import { getSetting } from '@/lib/services/settings.service'; +import { getQueue } from '@/lib/queue'; +import { logger } from '@/lib/logger'; + +interface InquiryNotificationParams { + portId: string; + portSlug: string; + interestId: string; + clientFullName: string; + clientEmail: string; + clientPhone: string; + mooringNumber: string | null; + firstName: string; +} + +/** + * Sends inquiry notifications to all relevant parties: + * 1. Confirmation email to the client + * 2. In-app + email notifications to CRM users with interests.view permission + * 3. Email to any external recipients configured in system settings + * + * All operations are fire-and-forget (errors are logged, not thrown). + */ +export async function sendInquiryNotifications(params: InquiryNotificationParams): Promise { + const { + portId, + portSlug, + interestId, + clientFullName, + clientEmail, + clientPhone, + mooringNumber, + firstName, + } = params; + + // 1. Queue client confirmation email + try { + const contactEmailSetting = await getSetting('inquiry_contact_email', portId); + const contactEmail = + typeof contactEmailSetting?.value === 'string' + ? contactEmailSetting.value + : 'sales@portnimara.com'; + + const emailQueue = getQueue('email'); + await emailQueue.add('send-inquiry-confirmation', { + to: clientEmail, + firstName, + mooringNumber, + contactEmail, + }); + } catch (err) { + logger.error({ err, interestId }, 'Failed to queue client confirmation email'); + } + + // 2. Notify CRM users with interests.view permission on this port + try { + const usersWithAccess = await findUsersWithInterestsPermission(portId); + const crmUrl = `/${portSlug}/interests/${interestId}`; + + for (const userId of usersWithAccess) { + try { + await createNotification({ + portId, + userId, + type: 'new_registration', + title: 'New Interest Registered', + description: `${clientFullName} has registered interest${mooringNumber ? ` in Berth ${mooringNumber}` : ''} via the website`, + link: crmUrl, + entityType: 'interest', + entityId: interestId, + dedupeKey: `inquiry-${interestId}`, + }); + } catch (err) { + logger.error({ err, userId, interestId }, 'Failed to create notification for user'); + } + } + } catch (err) { + logger.error({ err, interestId }, 'Failed to notify CRM users'); + } + + // 3. Notify external recipients + try { + const recipientsSetting = await getSetting('inquiry_notification_recipients', portId); + const externalEmails: string[] = Array.isArray(recipientsSetting?.value) + ? recipientsSetting.value + : []; + + if (externalEmails.length > 0) { + const emailQueue = getQueue('email'); + const appUrl = process.env.APP_URL ?? ''; + const crmUrl = `${appUrl}/${portSlug}/interests/${interestId}`; + + for (const externalEmail of externalEmails) { + await emailQueue.add('send-inquiry-sales-notification', { + to: externalEmail, + fullName: clientFullName, + email: clientEmail, + phone: clientPhone, + mooringNumber, + crmUrl, + }); + } + } + } catch (err) { + logger.error({ err, interestId }, 'Failed to notify external recipients'); + } +} + +/** + * Finds all user IDs on a port whose role grants `interests.view` permission. + * Checks the base role permissions (does not evaluate port-level overrides for simplicity — + * if a role has interests.view, all users with that role on this port are included). + */ +async function findUsersWithInterestsPermission(portId: string): Promise { + // Get all role assignments for this port with their role permissions + const assignments = await db + .select({ + userId: userPortRoles.userId, + permissions: roles.permissions, + }) + .from(userPortRoles) + .innerJoin(roles, eq(userPortRoles.roleId, roles.id)) + .where(eq(userPortRoles.portId, portId)); + + const userIds = new Set(); + for (const row of assignments) { + const perms = row.permissions as RolePermissions | null; + if (perms?.interests?.view) { + userIds.add(row.userId); + } + } + + return Array.from(userIds); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/services/inquiry-notifications.service.ts +git commit -m "feat: add inquiry notification service for sales team targeting" +``` + +--- + +### Task 6: Add Email Worker Job Handlers + +**Files:** + +- Modify: `src/lib/queue/workers/email.ts` + +- [ ] **Step 1: Add `send-inquiry-confirmation` and `send-inquiry-sales-notification` handlers** + +Replace the contents of `src/lib/queue/workers/email.ts` with: + +```typescript +import { Worker, type Job } from 'bullmq'; + +import type { ConnectionOptions } from 'bullmq'; +import { logger } from '@/lib/logger'; +import { QUEUE_CONFIGS } from '@/lib/queue'; + +export const emailWorker = new Worker( + 'email', + async (job: Job) => { + logger.info({ jobId: job.id, jobName: job.name }, 'Processing email job'); + switch (job.name) { + case 'inbox-sync': { + const { accountId } = job.data as { accountId: string }; + const { syncInbox } = await import('@/lib/services/email-threads.service'); + await syncInbox(accountId); + break; + } + case 'send-inquiry-confirmation': { + const { to, firstName, mooringNumber, contactEmail } = job.data as { + to: string; + firstName: string; + mooringNumber: string | null; + contactEmail: string; + }; + const { inquiryClientConfirmation } = + await import('@/lib/email/templates/inquiry-client-confirmation'); + const { sendEmail } = await import('@/lib/email/index'); + const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail }); + await sendEmail(to, email.subject, email.html, undefined, email.text); + break; + } + case 'send-inquiry-sales-notification': { + const { to, fullName, email, phone, mooringNumber, crmUrl } = job.data as { + to: string; + fullName: string; + email: string; + phone: string; + mooringNumber: string | null; + crmUrl: string; + }; + const { inquirySalesNotification } = + await import('@/lib/email/templates/inquiry-sales-notification'); + const { sendEmail } = await import('@/lib/email/index'); + const notification = inquirySalesNotification({ + fullName, + email, + phone, + mooringNumber, + crmUrl, + }); + await sendEmail(to, notification.subject, notification.html, undefined, notification.text); + break; + } + default: + logger.warn({ jobName: job.name }, 'Unknown email job'); + } + }, + { + connection: { url: process.env.REDIS_URL! } as ConnectionOptions, + concurrency: QUEUE_CONFIGS.email.concurrency, + }, +); + +emailWorker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Email job failed'); +}); +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/queue/workers/email.ts +git commit -m "feat: add email worker handlers for inquiry confirmation and sales notification" +``` + +--- + +### Task 7: Update Public Interest API Route + +**Files:** + +- Modify: `src/app/api/public/interests/route.ts` + +This is the main wiring task. The route handler needs to: + +1. Accept the expanded schema (firstName/lastName/fullName backward compat) +2. Resolve `mooringNumber` to a `berth_id` +3. Store the address in `client_addresses` +4. Set `preferred_contact_method` on the client +5. Fire notifications asynchronously + +- [ ] **Step 1: Rewrite the route handler** + +Replace `src/app/api/public/interests/route.ts` with: + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { interests } from '@/lib/db/schema/interests'; +import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients'; +import { berths } from '@/lib/db/schema/berths'; +import { ports } from '@/lib/db/schema/ports'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse, RateLimitError } from '@/lib/errors'; +import { publicInterestSchema } from '@/lib/validators/interests'; +import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service'; + +// ─── Simple in-memory rate limiter ─────────────────────────────────────────── +// Max 5 requests per hour per IP + +const ipHits = new Map(); +const WINDOW_MS = 60 * 60 * 1000; // 1 hour +const MAX_HITS = 5; + +function checkRateLimit(ip: string): void { + const now = Date.now(); + const entry = ipHits.get(ip); + + if (!entry || now > entry.resetAt) { + ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return; + } + + if (entry.count >= MAX_HITS) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + throw new RateLimitError(retryAfter); + } + + entry.count += 1; +} + +// POST /api/public/interests — unauthenticated public interest registration +export async function POST(req: NextRequest) { + try { + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; + checkRateLimit(ip); + + const body = await req.json(); + const data = publicInterestSchema.parse(body); + + // Resolve portId from query param or header (public endpoints need explicit port) + const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id'); + if (!portId) { + return NextResponse.json({ error: 'Port context required' }, { status: 400 }); + } + + // Resolve the full name + const fullName = + data.firstName && data.lastName + ? `${data.firstName} ${data.lastName}` + : (data.fullName ?? 'Unknown'); + + const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; + + // Resolve berth by mooring number (if provided) + let berthId: string | null = null; + let resolvedMooringNumber: string | null = data.mooringNumber ?? null; + + if (data.mooringNumber) { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)), + }); + if (berth) { + berthId = berth.id; + resolvedMooringNumber = berth.mooringNumber; + } + } + + // Find or create client by email + let clientId: string; + + const existingContact = await db.query.clientContacts.findFirst({ + where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)), + }); + + if (existingContact) { + const existingClient = await db.query.clients.findFirst({ + where: eq(clients.id, existingContact.clientId), + }); + if (existingClient && existingClient.portId === portId) { + clientId = existingClient.id; + // Update preferred contact method if provided + if (data.preferredContactMethod) { + await db + .update(clients) + .set({ preferredContactMethod: data.preferredContactMethod }) + .where(eq(clients.id, clientId)); + } + } else { + clientId = await createNewClient(portId, fullName, data); + } + } else { + clientId = await createNewClient(portId, fullName, data); + } + + // Store address if provided + if (data.address && Object.values(data.address).some(Boolean)) { + await db.insert(clientAddresses).values({ + clientId, + portId, + label: 'Primary', + streetAddress: data.address.street ?? null, + city: data.address.city ?? null, + stateProvince: data.address.stateProvince ?? null, + postalCode: data.address.postalCode ?? null, + country: data.address.country ?? null, + isPrimary: true, + }); + } + + // Create the interest + const [interest] = await db + .insert(interests) + .values({ + portId, + clientId, + berthId, + source: 'website', + pipelineStage: 'open', + notes: data.notes, + }) + .returning(); + + void createAuditLog({ + userId: null as unknown as string, + portId, + action: 'create', + entityType: 'interest', + entityId: interest!.id, + newValue: { clientId, source: 'website', pipelineStage: 'open', berthId }, + metadata: { type: 'public_registration', ip }, + ipAddress: ip, + userAgent: req.headers.get('user-agent') ?? 'unknown', + }); + + // Fire notifications asynchronously (non-blocking) + const port = await db.query.ports.findFirst({ + where: eq(ports.id, portId), + columns: { slug: true }, + }); + + void sendInquiryNotifications({ + portId, + portSlug: port?.slug ?? portId, + interestId: interest!.id, + clientFullName: fullName, + clientEmail: data.email, + clientPhone: data.phone, + mooringNumber: resolvedMooringNumber, + firstName, + }); + + return NextResponse.json( + { data: { id: interest!.id, message: 'Interest registered successfully' } }, + { status: 201 }, + ); + } catch (error) { + return errorResponse(error); + } +} + +async function createNewClient( + portId: string, + fullName: string, + data: { + email: string; + phone: string; + companyName?: string; + yachtName?: string; + yachtLengthFt?: number; + yachtWidthFt?: number; + yachtDraftFt?: number; + preferredBerthSize?: string; + preferredContactMethod?: string; + }, +): Promise { + const [newClient] = await db + .insert(clients) + .values({ + portId, + fullName, + companyName: data.companyName, + yachtName: data.yachtName, + yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined, + yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined, + yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined, + berthSizeDesired: data.preferredBerthSize, + preferredContactMethod: data.preferredContactMethod, + source: 'website', + }) + .returning(); + const clientId = newClient!.id; + + await db.insert(clientContacts).values({ + clientId, + channel: 'email', + value: data.email, + isPrimary: true, + }); + + await db.insert(clientContacts).values({ + clientId, + channel: 'phone', + value: data.phone, + isPrimary: false, + }); + + return clientId; +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/public/interests/route.ts +git commit -m "feat: wire inquiry notifications into public interest endpoint" +``` + +--- + +### Task 8: Register New Settings in Admin UI + +**Files:** + +- Modify: `src/components/admin/settings/settings-manager.tsx` + +The settings manager currently supports `boolean`, `number`, and `json` types. We need to add `string` type support and register the two new inquiry settings. + +- [ ] **Step 1: Add `string` to the `KNOWN_SETTINGS` type and add the two new settings** + +In `src/components/admin/settings/settings-manager.tsx`, update the `KNOWN_SETTINGS` type to include `'string'`: + +Change line 30 from: + +```typescript +type: 'boolean' | 'number' | 'json'; +``` + +to: + +```typescript +type: 'boolean' | 'number' | 'json' | 'string'; +``` + +Add the two new settings to the `KNOWN_SETTINGS` array (after the `berth_rules` entry, before the closing `]`): + +```typescript + { + key: 'inquiry_contact_email', + label: 'Inquiry Contact Email', + description: 'Reply-to email shown in client confirmation emails when a new interest is registered', + type: 'string', + defaultValue: 'sales@portnimara.com', + }, + { + key: 'inquiry_notification_recipients', + label: 'External Notification Recipients', + description: 'Additional email addresses that receive sales notifications for new interests (JSON array)', + type: 'json', + defaultValue: [], + }, +``` + +- [ ] **Step 2: Add the string setting renderer in the JSX** + +Add a new card section for string settings. Insert after the Feature Flags card (after line 188, before the Numeric Settings card): + +```tsx +{ + /* String Settings */ +} +{ + KNOWN_SETTINGS.some((s) => s.type === 'string') && ( + + + Inquiry Settings + Configure inquiry notification behavior + + + {KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => ( +
+
+ +

{setting.description}

+
+
+ + setValues((prev) => ({ + ...prev, + [setting.key]: e.target.value, + })) + } + /> + +
+
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/admin/settings/settings-manager.tsx +git commit -m "feat: add inquiry notification settings to admin settings UI" +``` + +--- + +### Task 9: Build Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run the full TypeScript check** + +Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty` +Expected: No errors. + +- [ ] **Step 2: Run the linter** + +Run: `cd C:/repos/new-pn-crm && pnpm lint` +Expected: No errors. + +- [ ] **Step 3: Run the production build** + +Run: `cd C:/repos/new-pn-crm && SKIP_ENV_VALIDATION=1 pnpm build 2>&1 | tail -20` +Expected: Build completes successfully. + +- [ ] **Step 4: Generate the Drizzle migration (if not done in Task 1)** + +Run: `cd C:/repos/new-pn-crm && pnpm db:generate` +Expected: Migration for `client_addresses` table is present. + +- [ ] **Step 5: Verify the settings UI renders** + +Start the dev server (`pnpm dev`) and navigate to `/{portSlug}/admin/settings`. Confirm: + +- The "Inquiry Settings" card appears with the contact email input +- The "Advanced Configuration" section shows the `inquiry_notification_recipients` JSON setting +- Values can be saved and persisted + +- [ ] **Step 6: Test the public endpoint with curl** + +```bash +curl -X POST http://localhost:3000/api/public/interests?portId= \ + -H "Content-Type: application/json" \ + -d '{ + "firstName": "Test", + "lastName": "User", + "email": "test@example.com", + "phone": "+1234567890", + "mooringNumber": "A3", + "preferredContactMethod": "email", + "address": { + "street": "123 Marina Way", + "city": "Anguilla", + "country": "AI" + } + }' +``` + +Expected: `201` response with interest ID. Check the database for: + +- Client record with `full_name = 'Test User'`, `preferred_contact_method = 'email'` +- `client_addresses` record with the address data and `is_primary = true` +- `interests` record with the correct `berth_id` (if berth A3 exists) +- Notification records in the `notifications` table (if users with interests.view exist) +- BullMQ jobs in the email queue (check via admin queue UI at `/{portSlug}/admin/queues`) + +- [ ] **Step 7: Test backward compatibility** + +```bash +curl -X POST http://localhost:3000/api/public/interests?portId= \ + -H "Content-Type: application/json" \ + -d '{ + "fullName": "Legacy User", + "email": "legacy@example.com", + "phone": "+1234567890" + }' +``` + +Expected: `201` response. Client created with `full_name = 'Legacy User'`.