Files
pn-new-crm/docs/superpowers/plans/2026-04-14-inquiry-notifications.md
Matt f659073b8f Add inquiry notifications implementation plan
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>
2026-04-14 12:38:25 -04:00

36 KiB

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):

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:

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):

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
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:

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:

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
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:

export async function sendEmail(
  to: string | string[],
  subject: string,
  html: string,
  from?: string,
): Promise<nodemailer.SentMessageInfo>;

Change it to accept an optional text parameter:

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
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:

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:

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
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
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
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:

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
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:

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
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:

type: 'boolean' | 'number' | 'json';

to:

type: 'boolean' | 'number' | 'json' | 'string';

Add the two new settings to the KNOWN_SETTINGS array (after the berth_rules entry, before the closing ]):

  {
    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):

{
  /* 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
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

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

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