# 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'`.