Files
pn-new-crm/docs/superpowers/plans/2026-04-14-inquiry-notifications.md

1120 lines
36 KiB
Markdown
Raw Normal View History

# 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<typeof publicInterestSchema>;
```
- [ ] **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<nodemailer.SentMessageInfo>;
```
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<nodemailer.SentMessageInfo> {
const transporter = createTransporter();
const info = await transporter.sendMail({
from: from ?? `Port Nimara CRM <noreply@${env.SMTP_HOST}>`,
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 = `<!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>${subject}</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('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="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;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
<p style="margin-bottom:10px; font-size:16px;">
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.
</p>
<p style="margin-bottom:10px; font-size:16px;">
If you have any questions, please feel free to reach out to us at
<a href="mailto:${escapeHtml(contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
</p>
<p style="font-size:16px;">
Best regards,<br />
The Port Nimara Sales Team
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
```
- [ ] **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 = `<!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>New Interest - Port Nimara</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('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="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;">
<center>
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>Port Nimara</strong>. Here are their details:</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</p>
<p style="font-size:16px;">Thank you,<br/>Port Nimara CRM</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
```
- [ ] **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<void> {
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<string[]> {
// 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<string>();
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<string, { count: number; resetAt: number }>();
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<string> {
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') && (
<Card>
<CardHeader>
<CardTitle>Inquiry Settings</CardTitle>
<CardDescription>Configure inquiry notification behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-4">
<div className="flex-1">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
</div>
<div className="flex items-center gap-2">
<Input
type="text"
className="w-64"
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
onChange={(e) =>
setValues((prev) => ({
...prev,
[setting.key]: e.target.value,
}))
}
/>
<Button
size="sm"
variant="outline"
disabled={saving === setting.key}
onClick={() =>
saveSetting(setting.key, values[setting.key] ?? setting.defaultValue)
}
>
<Save className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
);
}
```
- [ ] **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=<your-port-id> \
-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=<your-port-id> \
-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'`.