9-task plan covering: DB schema, validator expansion, email templates, notification service, worker handlers, route wiring, and admin settings UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1120 lines
36 KiB
Markdown
1120 lines
36 KiB
Markdown
# 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, '&')
|
|
.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 = `<!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, '&')
|
|
.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<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'`.
|