feat(platform): residential module + admin UI + reliability fixes
Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
src/app/api/public/residential-inquiries/route.ts
Normal file
176
src/app/api/public/residential-inquiries/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
|
||||
|
||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
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) {
|
||||
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
|
||||
}
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
||||
* the public website's residential interest form. Creates a
|
||||
* `residential_clients` row and an opening `residential_interests` row in a
|
||||
* single transaction.
|
||||
*
|
||||
* Required: `portId` query param or `X-Port-Id` header.
|
||||
*/
|
||||
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 = publicResidentialInquirySchema.parse(body);
|
||||
|
||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||
if (!portId) {
|
||||
throw new ValidationError('portId is required');
|
||||
}
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) {
|
||||
throw new ValidationError('Unknown port');
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [client] = await tx
|
||||
.insert(residentialClients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
source: 'website',
|
||||
status: 'prospect',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
if (!client) throw new Error('Failed to create residential client');
|
||||
|
||||
const [interest] = await tx
|
||||
.insert(residentialInterests)
|
||||
.values({
|
||||
portId,
|
||||
residentialClientId: client.id,
|
||||
pipelineStage: 'new',
|
||||
source: 'website',
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
})
|
||||
.returning();
|
||||
if (!interest) throw new Error('Failed to create residential interest');
|
||||
|
||||
return { clientId: client.id, interestId: interest.id };
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
||||
|
||||
// Send notification emails (non-blocking — failures shouldn't 500 the
|
||||
// public form).
|
||||
void sendResidentialNotifications({
|
||||
portId,
|
||||
data,
|
||||
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
||||
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
||||
|
||||
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResidentialNotifications(args: {
|
||||
portId: string;
|
||||
data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
};
|
||||
crmDeepLink: string;
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation({
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
});
|
||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||
|
||||
// Sales-team alert — pull recipients from system_settings if configured;
|
||||
// fall back to the inquiry_contact_email if available.
|
||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||
where: and(
|
||||
eq(systemSettings.key, 'residential_notification_recipients'),
|
||||
eq(systemSettings.portId, portId),
|
||||
),
|
||||
});
|
||||
const fallbackRow = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
|
||||
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
|
||||
const fallback =
|
||||
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
|
||||
? [fallbackRow.value]
|
||||
: [];
|
||||
const recipients = configured.length > 0 ? configured : fallback;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
logger.warn(
|
||||
{ portId },
|
||||
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert({
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
crmDeepLink,
|
||||
});
|
||||
|
||||
await sendEmail(recipients, alert.subject, alert.html);
|
||||
}
|
||||
Reference in New Issue
Block a user