Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
57 KiB
L3: Operations & Features — Competing Plan (Claude Code)
Duration: Days 17–22 (6 days — baseline allocates 4 which is tight)
Parallelism: 4 streams
Depends on: L2 complete (interests, documents, expenses/invoices, files)
References: 06-MASTER-FEATURE-SPEC.md §7–14, 08-API-ENDPOINT-CATALOG.md §10–14, 09-BUSINESS-RULES.md BR-050–BR-082, 11-REALTIME-AND-BACKGROUND-JOBS.md
1. Baseline Critique
Errors (continuing from L0–L2)
-
Wrong routes again (4th layer in a row). Every UI path uses
(crm)/instead of(dashboard)/[portSlug]/. Every component usesdomain/subdirectory. At this point these errors would cascade through the entire codebase if not caught. -
Wrong pipeline stage names in revenue forecast. Uses
new_inquiry,initial_contact,site_visit,negotiation— the same incorrect names from L2. Must use BR-010 stages:open,details_sent,in_communication,visited,signed_eoi_nda,deposit_10pct,contract,completed. -
Wrong env var for encryption key. Baseline uses
CREDENTIALS_ENCRYPTION_KEYbut Security Guidelines specifyEMAIL_CREDENTIAL_KEYas the environment variable name. -
Missing reminder
interest_idfield support. Baseline's follow-up auto-reminder queries interests but creates reminders without linking viainterest_id— the schema has this column and it should be populated.
Design Issues
-
4 days for 4 complex streams is unrealistic. Stream A (Email) alone covers SMTP/IMAP setup, sync logic, thread viewer, TipTap composer, and 11 MJML templates. Stream B covers reminders, Google Calendar OAuth with two-way sync, and the entire notification system. Stream D builds a full analytics dashboard with 6+ chart widgets. Each of these could easily take 2 days. My plan allocates 6 days.
-
cmdknot in locked tech stack. The baseline specifiescmdkfor the command palette but14-TECHNICAL-DECISIONS.mddoesn't list it. This is a lightweight, focused package (no heavy deps), so it's a reasonable addition — but it should be flagged as a tech-stack addition like@dnd-kitin L2. -
Search only uses tsvector. The baseline builds full-text search exclusively with PostgreSQL
tsvector. This handles word-based search well but fails for partial matches (typing "Joh" to find "Johnson") and typos. Since L0 already enabledpg_trgm, we should use trigram similarity for fuzzy matching alongside tsvector for ranked full-text search. -
Missing email body sanitization. The thread viewer renders email HTML but the baseline only mentions DOMPurify in passing. Email HTML is one of the most dangerous XSS vectors — it needs explicit DOMPurify configuration with a strict allowlist.
-
Dashboard page route unclear. The baseline puts the dashboard at
(crm)/dashboard/page.tsx, but per13-UI-PAGE-MAP.mdthe dashboard is the landing page after login. With the locked route structure(dashboard)/[portSlug]/, the dashboard page should besrc/app/(dashboard)/[portSlug]/page.tsx— the root of the port context. -
Missing activity timeline service. The baseline's activity feed reads from
audit_logsbut doesn't define a reusable timeline service. This is needed on the dashboard AND on every entity detail page (client, interest, berth). Should be a shared service with entity-type filtering.
What's Good
- The notification creator pattern (centralized function called by all systems) is well-designed. I'll adopt it.
- The Google Calendar sync strategy with three triggers (poll, login, navigation) is thorough and matches the spec.
- BullMQ job definitions are consistently structured.
- The bulk operations pattern with progress tracking via Socket.io is solid.
2. Implementation Plan
Stream A: Email System (Days 1–3)
Day 1: Email Account Setup + Encryption + IMAP Sync
Encryption helper: src/lib/utils/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
import { env } from '@/lib/env';
const ALGORITHM = 'aes-256-gcm';
/**
* Encrypt plaintext using AES-256-GCM.
* Returns: IV (12 bytes) + auth tag (16 bytes) + ciphertext as a single Buffer.
* Key source: EMAIL_CREDENTIAL_KEY env var (32-byte hex string).
* @param plaintext - String to encrypt
* @returns Encrypted buffer
*/
export function encrypt(plaintext: string): Buffer {
const key = Buffer.from(env.EMAIL_CREDENTIAL_KEY, 'hex');
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]);
}
/**
* Decrypt buffer encrypted by encrypt().
* @param ciphertext - Buffer containing IV + auth tag + encrypted data
* @returns Decrypted string
* @throws {Error} If decryption fails (wrong key, tampered data)
*/
export function decrypt(ciphertext: Buffer): string {
const key = Buffer.from(env.EMAIL_CREDENTIAL_KEY, 'hex');
const iv = ciphertext.subarray(0, 12);
const authTag = ciphertext.subarray(12, 28);
const encrypted = ciphertext.subarray(28);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return decipher.update(encrypted) + decipher.final('utf8');
}
Service: src/lib/services/email-accounts.ts
/**
* Create email account with encrypted credentials.
* Provider presets auto-fill SMTP/IMAP config.
* Credentials encrypted with AES-256-GCM before DB storage (BR-131).
* @throws {ValidationError} If connection test fails (optional)
*/
export async function createAccount(
userId: string,
portId: string,
data: CreateEmailAccountInput,
): Promise<EmailAccount>;
/**
* Test SMTP and IMAP connections for an email account.
* Decrypts credentials at point of use, never logs them.
* @returns Connection test results for both protocols
*/
export async function testConnection(
accountId: string,
): Promise<{ smtp: boolean; imap: boolean; errors: string[] }>;
/**
* List user's email accounts (credentials never returned in response).
*/
export async function listAccounts(userId: string, portId: string): Promise<EmailAccountSummary[]>;
/**
* Update account settings. Re-encrypts if credentials changed.
* BR-131: Failed IMAP/SMTP after 3 consecutive attempts → disable + notify.
*/
export async function updateAccount(
userId: string,
accountId: string,
data: UpdateEmailAccountInput,
): Promise<EmailAccount>;
/**
* Delete email account. Stops sync jobs, removes encrypted credentials.
*/
export async function deleteAccount(userId: string, accountId: string): Promise<void>;
IMAP sync service: src/lib/services/email-sync.ts
import { ImapFlow } from 'imapflow';
/**
* Sync an email account: fetch new messages since last sync.
* Steps:
* 1. Decrypt credentials (only at connection time)
* 2. Connect IMAP, select INBOX + Sent
* 3. Fetch messages since last_sync_at
* 4. For each message: extract headers, match to client (via client_contacts),
* thread by In-Reply-To/References/Subject, store in email_messages
* 5. Handle attachments: download to MinIO, link file IDs
* 6. Update last_sync_at
*
* @param accountId - Email account UUID
* @throws {ExternalServiceError} If IMAP connection fails
*/
export async function syncAccount(accountId: string): Promise<SyncResult>;
/**
* Match email address to client by checking client_contacts where channel='email'.
* @param portId - Port UUID for scoping
* @param emailAddress - Email to match
* @returns Client ID or null
*/
export async function matchToClient(portId: string, emailAddress: string): Promise<string | null>;
BullMQ job:
// src/jobs/processors/email-sync.ts
{
name: 'email-sync',
queue: 'email',
concurrency: 3, // sync up to 3 accounts simultaneously
repeat: { every: 900_000 }, // 15 minutes
processor: async (job) => {
const accounts = await getActiveEmailAccounts();
for (const account of accounts) {
try {
await syncAccount(account.id);
} catch (error) {
// Increment failure count, disable after 3 consecutive failures (BR-131)
await handleSyncFailure(account.id, error);
}
}
},
}
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/email/accounts |
src/app/api/v1/email/accounts/route.ts |
| POST | /api/v1/email/accounts |
src/app/api/v1/email/accounts/route.ts |
| PATCH | /api/v1/email/accounts/[id] |
src/app/api/v1/email/accounts/[id]/route.ts |
| DELETE | /api/v1/email/accounts/[id] |
src/app/api/v1/email/accounts/[id]/route.ts |
| POST | /api/v1/email/accounts/[id]/test |
src/app/api/v1/email/accounts/[id]/test/route.ts |
| POST | /api/v1/email/sync |
src/app/api/v1/email/sync/route.ts |
| GET | /api/v1/email/threads |
src/app/api/v1/email/threads/route.ts |
| GET | /api/v1/email/threads/[id] |
src/app/api/v1/email/threads/[id]/route.ts |
UI:
src/app/(dashboard)/[portSlug]/settings/email/page.tsx— email account managementsrc/components/email/email-account-form.tsx— provider presets + manual configsrc/components/email/connection-test-dialog.tsx— live SMTP/IMAP test
Day 2: Thread Viewer + Composer + Client Email Tab
Email thread viewer:
src/components/email/email-thread-viewer.tsx
<EmailThreadViewer thread={thread}>
{messages.map(msg => (
<EmailMessage
key={msg.id}
direction={msg.direction} — inbound=left, outbound=right alignment
from={msg.fromAddress}
to={msg.toAddresses}
sentAt={msg.sentAt}
body={sanitizedHtml} — DOMPurify with strict email allowlist
attachments={msg.attachments} — download via presigned URLs
/>
))}
<EmailReplyComposer threadId={thread.id} />
</EmailThreadViewer>
DOMPurify config for email HTML (critical — email is a major XSS vector):
// src/lib/utils/sanitize-email.ts
import DOMPurify from 'dompurify';
const EMAIL_ALLOWED_TAGS = [
'p',
'br',
'div',
'span',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'blockquote',
'pre',
'code',
'hr',
];
const EMAIL_ALLOWED_ATTRS = [
'href',
'src',
'alt',
'title',
'style',
'class',
'width',
'height',
'colspan',
'rowspan',
'align',
'valign',
];
export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: EMAIL_ALLOWED_TAGS,
ALLOWED_ATTR: EMAIL_ALLOWED_ATTRS,
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'], // for links
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'],
WHOLE_DOCUMENT: false,
});
}
Email composer (TipTap — email context):
src/components/email/email-composer.tsx
<EmailComposer>
<div className="space-y-3">
<Select label="From" options={userAccounts} />
<ComboboxMulti label="To" suggestions={clientContacts} />
<ComboboxMulti label="CC" suggestions={clientContacts} />
<Input label="Subject" />
<TipTapEditor
context="email" — email toolbar: bold, italic, underline, link, lists, heading
extensions={[MergeFieldExtension]} — {{client.full_name}} dropdown insertion
/>
<FileUploadZone label="Attachments" />
<Button onClick={handleSend}>Send</Button>
</div>
</EmailComposer>
Email send service: src/lib/services/email-send.ts
/**
* Send email via user's SMTP account.
* 1. Resolve merge fields in body
* 2. Sanitize HTML (DOMPurify)
* 3. Decrypt SMTP credentials
* 4. Send via Nodemailer
* 5. Store outbound message in email_messages
* 6. Update thread (or create new)
* 7. Audit log
* @throws {ExternalServiceError} If SMTP connection fails
*/
export async function sendEmail(
userId: string,
accountId: string,
data: SendEmailInput,
): Promise<EmailMessage>;
Client email tab:
src/components/clients/client-emails-tab.tsx — add to client detail page
- Shows email threads matched to this client
- Thread list → click → thread viewer
- "Compose" button to start new email to client
API routes for email send/compose:
| Method | Path | File |
|---|---|---|
| POST | /api/v1/email/send |
src/app/api/v1/email/send/route.ts |
| GET | /api/v1/email/threads |
(already defined above) |
Day 3: MJML System Templates
Template directory: src/emails/ (MJML files)
| Template | File | Variables | Trigger |
|---|---|---|---|
| Password Set | password-set.mjml |
userName, setPasswordUrl, expiresIn |
User created |
| Password Reset | password-reset.mjml |
userName, resetUrl, expiresIn |
Reset requested |
| EOI Ready to Sign | eoi-signing.mjml |
clientName, berthNumber, signingUrl |
EOI sent |
| Signer Completed | signer-completed.mjml |
signerName, berthNumber, remainingSigners |
Signer signed |
| EOI Completed | eoi-completed.mjml |
clientName, berthNumber, downloadUrl |
All signed |
| Signing Reminder | signing-reminder.mjml |
signerName, documentTitle, signingUrl, daysPending |
Reminder job |
| Invoice Sent | invoice-sent.mjml |
clientName, invoiceNumber, total, currency, dueDate |
Invoice emailed |
| Invoice Overdue | invoice-overdue.mjml |
clientName, invoiceNumber, daysPastDue |
Overdue job |
| Follow-up Reminder | follow-up.mjml |
userName, clientName, interestSummary, crmLink |
Auto-reminder |
| New Registration | new-registration.mjml |
clientName, email, vesselName, berthPref, crmLink |
Public API |
| Waiting List Alert | waiting-list.mjml |
clientName, berthNumber, position |
Berth available |
Template renderer: src/lib/services/email-renderer.ts
import mjml2html from 'mjml';
/**
* Render an MJML email template with variable substitution.
* All templates share a layout wrapper with Port Nimara branding.
* @param templateName - Template file name (without .mjml)
* @param variables - Key-value pairs for {{variable}} replacement
* @returns Rendered HTML string
*/
export async function renderTemplate(
templateName: string,
variables: Record<string, string>,
): Promise<string>;
Shared MJML layout wrapper (src/emails/_layout.mjml):
- Port Nimara logo header (navy #1e2844 background)
- Content section with clean Inter typography
- Footer with port contact info, unsubscribe note for marketing emails
- All templates include this wrapper
Refactor: Replace inline HTML strings in L0–L2 email sends (password set, password reset, EOI notifications, invoice send) with renderTemplate() calls. This is a targeted find-and-replace across:
src/lib/services/auth.ts— password set/reset emailssrc/lib/services/eoi.ts— EOI signing notificationsrc/lib/services/invoices.ts— invoice sendsrc/lib/services/document-reminders.ts— signing reminders
Stream B: Reminders, Calendar & Notifications (Days 1–4)
Day 1: Reminder CRUD + Service
Service: src/lib/services/reminders.ts
/**
* Create reminder with optional entity linking and calendar sync.
* If syncToCalendar=true and user has Google Calendar connected: pushes event.
*/
export async function createReminder(
portId: string,
userId: string,
data: CreateReminderInput,
): Promise<Reminder>;
/**
* List reminders with filtering by assignee, status, priority, due range, entity.
* Supports view_own vs view_all permission check.
*/
export async function listReminders(
portId: string,
userId: string,
query: ListRemindersInput,
): Promise<ReminderListResult>;
/**
* Complete a reminder. Sets status=completed, completed_at=now().
* Updates Google Calendar event if synced.
*/
export async function completeReminder(
portId: string,
userId: string,
reminderId: string,
): Promise<Reminder>;
/**
* Snooze a reminder (BR-062).
* Sets status=snoozed, snoozed_until=specified time.
* Updates Google Calendar event if synced.
*/
export async function snoozeReminder(
portId: string,
userId: string,
reminderId: string,
snoozedUntil: Date,
): Promise<Reminder>;
/**
* Dismiss a reminder. Sets status=dismissed.
*/
export async function dismissReminder(
portId: string,
userId: string,
reminderId: string,
): Promise<Reminder>;
/**
* Get upcoming items: unified CRM reminders + Google Calendar events.
* Merges both sources into a chronological list for next N days.
*/
export async function getUpcomingItems(
userId: string,
portId: string,
days?: number,
): Promise<UpcomingItem[]>;
Validators: src/lib/validators/reminders.ts
export const createReminderSchema = z.object({
title: z.string().min(1).max(200),
note: z.string().max(2000).optional(),
dueAt: z.string().datetime(),
priority: z.enum(['low', 'medium', 'high', 'urgent']),
assignedTo: z.string().optional(), // defaults to current user
clientId: z.string().uuid().optional(),
interestId: z.string().uuid().optional(),
berthId: z.string().uuid().optional(),
syncToCalendar: z.boolean().default(false),
});
export const snoozeReminderSchema = z.object({
snoozedUntil: z.string().datetime(),
});
export const listRemindersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
assignedTo: z.string().optional(),
status: z.enum(['pending', 'snoozed', 'completed', 'dismissed']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
dueBefore: z.string().datetime().optional(),
dueAfter: z.string().datetime().optional(),
clientId: z.string().uuid().optional(),
interestId: z.string().uuid().optional(),
berthId: z.string().uuid().optional(),
myOnly: z.coerce.boolean().default(false),
});
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/reminders |
src/app/api/v1/reminders/route.ts |
| POST | /api/v1/reminders |
src/app/api/v1/reminders/route.ts |
| GET | /api/v1/reminders/[id] |
src/app/api/v1/reminders/[id]/route.ts |
| PATCH | /api/v1/reminders/[id] |
src/app/api/v1/reminders/[id]/route.ts |
| DELETE | /api/v1/reminders/[id] |
src/app/api/v1/reminders/[id]/route.ts |
| POST | /api/v1/reminders/[id]/complete |
src/app/api/v1/reminders/[id]/complete/route.ts |
| POST | /api/v1/reminders/[id]/snooze |
src/app/api/v1/reminders/[id]/snooze/route.ts |
| POST | /api/v1/reminders/[id]/dismiss |
src/app/api/v1/reminders/[id]/dismiss/route.ts |
| GET | /api/v1/reminders/upcoming |
src/app/api/v1/reminders/upcoming/route.ts |
UI:
src/app/(dashboard)/[portSlug]/reminders/page.tsx
- Table view with columns: Title, Due, Priority (badge), Status (badge), Linked Entity, Assigned To
- Filter tabs: All, Pending, Overdue, Completed
- Quick actions in each row: Complete, Snooze, Dismiss
src/components/reminders/reminder-form-dialog.tsx
- Entity linking: searchable comboboxes for client/interest/berth
- Calendar sync toggle (only shown if user has Google Calendar connected)
- Priority selector
src/components/reminders/snooze-popover.tsx
- Quick options: 1 hour, 4 hours, Tomorrow 9 AM, Next Monday, Custom date/time
src/components/reminders/reminder-card.tsx
- Compact card for dashboard widget and entity detail sidebar
- Shows: title, due time, priority badge, quick complete/snooze buttons
Day 2: Auto-Reminders + Google Calendar
Follow-up auto-reminders (BR-060):
// src/jobs/processors/follow-up-reminder.ts
{
name: 'follow-up-reminder-check',
queue: 'notifications',
concurrency: 1,
repeat: { every: 3_600_000 }, // hourly
processor: async () => {
// 1. Query interests where:
// - archived_at IS NULL, pipeline_stage NOT IN ('completed')
// - reminder_enabled = true, reminder_days IS NOT NULL
// - No pending auto-generated reminder exists for this interest
// 2. For each: check last activity date (latest of: notes, stage changes, emails)
// 3. If no activity in reminder_days: create reminder
// - title: "Follow up with {client.full_name}"
// - auto_generated = true
// - interest_id = interest.id (link to the interest!)
// - assigned_to = interest owner or port default
// - due_at = now + 1 day
// 4. Create notification for assignee
// 5. Emit socket: reminder:created
},
}
Overdue checks:
// src/jobs/processors/reminder-overdue-check.ts
{
name: 'reminder-overdue-check',
queue: 'notifications',
concurrency: 1,
repeat: { every: 900_000 }, // 15 minutes
processor: async () => {
// Query: status IN ('pending', 'snoozed') AND
// (due_at < now OR (status = 'snoozed' AND snoozed_until < now))
// AND no overdue notification in last 24h for this reminder
// For each: create notification (reminder_overdue), emit socket
},
}
// src/jobs/processors/tenure-expiry-check.ts
{
name: 'tenure-expiry-check',
queue: 'maintenance',
concurrency: 1,
repeat: { pattern: '0 8 * * *' }, // daily at 8 AM
processor: async () => {
// Query berths: tenure_end_date BETWEEN now AND now + 6 months
// No tenure_expiring notification in last 30 days for this berth
// Create notification for admin users
},
}
Google Calendar service: src/lib/services/google-calendar.ts
import { google } from 'googleapis';
/**
* Generate OAuth 2.0 authorization URL for Google Calendar.
* Scopes: calendar.events (read/write), calendar.readonly, calendarlist.readonly
*/
export function getAuthUrl(userId: string, redirectUri: string): string;
/**
* Handle OAuth callback. Exchange code for tokens, encrypt, store.
* Tokens encrypted with pgcrypto before DB storage.
*/
export async function handleCallback(userId: string, code: string): Promise<void>;
/**
* Sync events from Google Calendar for next 14 days.
* Upserts into google_calendar_cache.
* Detects CRM-pushed events that were deleted/moved in Google → updates CRM reminder.
*/
export async function syncEvents(userId: string): Promise<SyncResult>;
/**
* Push a CRM reminder to Google Calendar as an event.
* Stores google_calendar_event_id back on the reminder record.
*/
export async function pushEvent(userId: string, reminder: Reminder): Promise<string>; // returns Google Calendar event ID
/**
* Update a Google Calendar event from a CRM reminder.
*/
export async function updateEvent(
userId: string,
reminderId: string,
data: Partial<Reminder>,
): Promise<void>;
/**
* Delete a Google Calendar event.
*/
export async function deleteEvent(userId: string, googleEventId: string): Promise<void>;
/**
* Disconnect Google Calendar. Revoke tokens, clear cache.
*/
export async function disconnect(userId: string): Promise<void>;
/**
* Get connection status for a user.
*/
export async function getStatus(userId: string): Promise<CalendarConnectionStatus>;
BullMQ job — calendar sync:
{
name: 'calendar-sync',
queue: 'maintenance',
concurrency: 2,
repeat: { every: 1_800_000 }, // 30 minutes
processor: async () => {
const connectedUsers = await getConnectedCalendarUsers();
for (const user of connectedUsers) {
try {
// Refresh token if expired
await refreshTokenIfNeeded(user.id);
await syncEvents(user.id);
} catch (error) {
if (isTokenRevoked(error)) {
await markDisconnected(user.id);
await createNotification({ type: 'system_alert', userId: user.id,
title: 'Google Calendar disconnected' });
}
}
}
},
}
Additional sync triggers (inline, not BullMQ):
- On user login: if
last_sync_at > 5 min ago→ enqueue immediate sync job - On navigation to reminders/dashboard: if
last_sync_at > 5 min ago→ enqueue sync
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/calendar/auth-url |
src/app/api/v1/calendar/auth-url/route.ts |
| GET | /api/v1/calendar/callback |
src/app/api/v1/calendar/callback/route.ts |
| GET | /api/v1/calendar/status |
src/app/api/v1/calendar/status/route.ts |
| GET | /api/v1/calendar/calendars |
src/app/api/v1/calendar/calendars/route.ts |
| PATCH | /api/v1/calendar/settings |
src/app/api/v1/calendar/settings/route.ts |
| POST | /api/v1/calendar/disconnect |
src/app/api/v1/calendar/disconnect/route.ts |
| POST | /api/v1/calendar/sync |
src/app/api/v1/calendar/sync/route.ts |
UI:
src/app/(dashboard)/[portSlug]/settings/calendar/page.tsx— Google Calendar connectionsrc/components/calendar/calendar-connect-button.tsx— OAuth flow triggersrc/components/calendar/calendar-status.tsx— connection status, last sync, calendar selector
Day 3: Notification System
Notification creator (centralized): src/lib/services/notifications.ts
export const NOTIFICATION_TYPES = [
'reminder_due',
'reminder_overdue',
'new_registration',
'eoi_signed',
'eoi_completed',
'email_received',
'duplicate_alert',
'invoice_overdue',
'waiting_list',
'system_alert',
'follow_up_created',
'tenure_expiring',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
/**
* Create an in-app notification.
* 1. Check user_notification_preferences — skip if disabled for this type
* (system_alert to super_admin always delivered, never suppressible)
* 2. Check cooldown (BR-052) — same type+entity throttled for 1 hour
* 3. Insert into notifications table
* 4. Emit Socket.io: notification:new to user:{userId} room
* 5. If email preference enabled for this type: enqueue email notification
*/
export async function createNotification(params: {
portId: string;
userId: string;
type: NotificationType;
title: string;
description?: string;
link?: string;
entityType?: string;
entityId?: string;
}): Promise<void>;
/**
* List notifications for a user with pagination.
*/
export async function listNotifications(
userId: string,
portId: string,
query: { page?: number; limit?: number; unreadOnly?: boolean },
): Promise<NotificationListResult>;
/**
* Get unread notification count.
*/
export async function getUnreadCount(userId: string, portId: string): Promise<number>;
/**
* Mark notification as read.
*/
export async function markAsRead(userId: string, notificationId: string): Promise<void>;
/**
* Mark all notifications as read for a user in a port.
*/
export async function markAllAsRead(userId: string, portId: string): Promise<void>;
/**
* Get user's notification preferences (per-type × per-channel matrix).
*/
export async function getPreferences(userId: string): Promise<NotificationPreferences>;
/**
* Update notification preferences.
*/
export async function updatePreferences(
userId: string,
data: UpdatePreferencesInput,
): Promise<NotificationPreferences>;
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/notifications |
src/app/api/v1/notifications/route.ts |
| GET | /api/v1/notifications/unread-count |
src/app/api/v1/notifications/unread-count/route.ts |
| PATCH | /api/v1/notifications/[id]/read |
src/app/api/v1/notifications/[id]/read/route.ts |
| POST | /api/v1/notifications/mark-all-read |
src/app/api/v1/notifications/mark-all-read/route.ts |
| GET | /api/v1/notifications/preferences |
src/app/api/v1/notifications/preferences/route.ts |
| PATCH | /api/v1/notifications/preferences |
src/app/api/v1/notifications/preferences/route.ts |
UI:
src/components/shared/notification-bell.tsx (in topbar, stubbed in L0):
- Bell icon with red badge showing unread count
- Click → dropdown panel
- Live updates via Socket.io
notification:new→ increment badge, prepend to list
src/components/notifications/notification-dropdown.tsx:
- Scrollable list of recent 20 notifications
- Each: type icon, title, description, time ago, unread dot
- Click → navigate to
linkURL + mark as read - "Mark all as read" button
- "View all" → full notifications page
src/app/(dashboard)/[portSlug]/notifications/page.tsx:
- Full notification list, paginated
- Filter by type, read/unread
src/app/(dashboard)/[portSlug]/settings/notifications/page.tsx:
- Grid: notification types (rows) × delivery methods (columns: in-app, email)
- Toggle switches at each intersection
Wiring notification triggers into L1–L2 features:
This is a critical integration step. Insert createNotification() calls into:
src/lib/services/interests.ts— public registration →new_registrationsrc/lib/services/documents.ts— signer completed →eoi_signed, all completed →eoi_completedsrc/lib/services/invoices.ts— overdue →invoice_overdue(already in L2 BullMQ job)src/lib/services/berth-waiting-list.ts— berth available →waiting_listsrc/lib/services/clients.ts— duplicate detected →duplicate_alertsrc/lib/services/email-sync.ts— new email received →email_received- All BullMQ job failures →
system_alertto super_admin
Day 4: Notification Email Delivery + Reminder UI on Entity Pages
Email notification delivery (BullMQ job):
// src/jobs/processors/notification-email.ts
{
name: 'notification-email',
queue: 'email',
concurrency: 5,
processor: async (job) => {
const { userId, notificationType, title, description, link } = job.data;
const user = await getUserWithEmail(userId);
// Use MJML template for notification emails
const html = await renderTemplate('notification', {
userName: user.displayName,
notificationType,
title,
description: description || '',
actionUrl: link || env.APP_URL,
});
// Send via Poste.io system SMTP (noreply@portnimara.com)
await systemMailer.sendMail({
to: user.email,
subject: title,
html,
});
},
}
Reminder sidebar on entity detail pages:
Add a "Reminders" section to client, interest, and berth detail pages:
src/components/reminders/entity-reminders.tsx— reusable component- Props:
entityType: 'client' | 'interest' | 'berth',entityId: string - Shows linked reminders (pending first, then completed)
- Quick create button: pre-fills entity link
- Quick complete/snooze actions
Wire into:
- Client detail page → Reminders tab or sidebar section
- Interest detail page → Reminders tab
- Berth detail page → Reminders section in detail panel
Stream C: Search & Views (Days 2–4)
Day 2: Global Search Backend
Service: src/lib/services/search.ts
export interface SearchResult {
entityType: 'client' | 'interest' | 'berth' | 'expense' | 'invoice' | 'document';
id: string;
primaryText: string; // name, number, title
secondaryText: string; // company, status, client name
rank: number;
url: string; // link to entity detail page
}
export interface SearchResponse {
results: SearchResult[];
grouped: Record<string, SearchResult[]>;
totalCount: number;
}
/**
* Global search across all entity types.
* Strategy:
* - Queries >= 3 chars: PostgreSQL full-text search (tsvector + plainto_tsquery)
* with ts_rank for relevance scoring
* - Queries < 3 chars: pg_trgm similarity for prefix/fuzzy matching
* - All queries port-scoped
* - Archived records included but flagged
* - Results grouped by entity type, sorted by rank
* - Redis cached for 60 seconds
*
* @param portId - Port UUID
* @param query - Search term (sanitized via sanitizeSearchTerm)
* @param options - Optional filters: entity types, include archived
* @returns Grouped search results
*/
export async function globalSearch(
portId: string,
query: string,
options?: { entityTypes?: string[]; includeArchived?: boolean; limit?: number },
): Promise<SearchResponse>;
Search implementation pattern:
// For full-text (query >= 3 chars):
const sanitized = sanitizeSearchTerm(query);
const tsQuery = sql`plainto_tsquery('english', ${sanitized})`;
const clientResults = await db
.select({
id: clients.id,
entityType: sql<string>`'client'`,
primaryText: clients.fullName,
secondaryText: clients.companyName,
rank: sql<number>`ts_rank(${clients.searchVector}, ${tsQuery})`,
})
.from(clients)
.where(and(eq(clients.portId, portId), sql`${clients.searchVector} @@ ${tsQuery}`))
.orderBy(desc(sql`ts_rank(${clients.searchVector}, ${tsQuery})`))
.limit(10);
// For short queries (< 3 chars) — trigram similarity:
const trigramResults = await db
.select({
id: clients.id,
primaryText: clients.fullName,
rank: sql<number>`similarity(${clients.fullName}, ${query})`,
})
.from(clients)
.where(
and(
eq(clients.portId, portId),
sql`${clients.fullName} % ${query}`, // trigram similarity operator
),
)
.orderBy(desc(sql`similarity(${clients.fullName}, ${query})`))
.limit(10);
Database migration for tsvector columns:
-- Clients
ALTER TABLE clients ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_clients_search ON clients USING GIN(search_vector);
CREATE OR REPLACE FUNCTION clients_search_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.full_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.company_name, '')), 'B') ||
setweight(to_tsvector('english', coalesce(NEW.yacht_name, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER clients_search_trigger BEFORE INSERT OR UPDATE ON clients
FOR EACH ROW EXECUTE FUNCTION clients_search_update();
-- Repeat for: interests (join client name), berths (mooring_number, area),
-- expenses (establishment_name, description), invoices (invoice_number, client_name)
Also add trigram indexes:
CREATE INDEX IF NOT EXISTS idx_clients_name_trgm ON clients USING GIN(full_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_berths_mooring_trgm ON berths USING GIN(mooring_number gin_trgm_ops);
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/search |
src/app/api/v1/search/route.ts |
Day 3: Search UI (Cmd+K) + Saved Views
Command palette:
src/components/shared/command-palette.tsx
Built on cmdk (flag as tech-stack addition):
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Search clients, berths, interests..."
value={query}
onValueChange={handleSearch} — debounced 300ms
/>
<CommandList>
{isLoading && <CommandLoading />}
<CommandEmpty>No results found.</CommandEmpty>
{Object.entries(grouped).map(([entityType, results]) => (
<CommandGroup key={entityType} heading={entityType}>
{results.map(result => (
<CommandItem
key={result.id}
onSelect={() => router.push(result.url)}
>
<EntityIcon type={entityType} />
<div>
<p className="font-medium">{result.primaryText}</p>
<p className="text-xs text-muted-foreground">{result.secondaryText}</p>
</div>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
Keyboard shortcut registration:
// In topbar or layout:
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setOpen(true);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
Topbar update: add search trigger with ⌘K hint badge.
Saved views:
src/lib/services/saved-views.ts
/**
* Create a saved view (filter + sort + column config).
*/
export async function createSavedView(
userId: string,
portId: string,
data: CreateSavedViewInput,
): Promise<SavedView>;
/**
* List user's saved views + shared views for an entity type.
*/
export async function listSavedViews(
userId: string,
portId: string,
entityType: string,
): Promise<SavedView[]>;
/**
* Set a saved view as the default for an entity type.
*/
export async function setDefaultView(
userId: string,
portId: string,
entityType: string,
viewId: string | null,
): Promise<void>;
Saved view bar component:
src/components/shared/saved-view-bar.tsx
- Horizontal bar above list tables
- Tabs for each saved view, active view highlighted
- "Save current view" button (appears when filters differ from saved)
- Dropdown per tab: Rename, Share/Unshare, Set as Default, Delete
Integrate into all 5 list pages: clients, interests, berths, expenses, invoices.
Day 4: Bulk Operations
Service: src/lib/services/bulk-operations.ts
/**
* Enqueue a bulk operation as a BullMQ job.
* Returns a job ID for progress tracking via Socket.io.
*/
export async function enqueueBulkOperation(
portId: string,
userId: string,
operation: BulkOperationInput,
): Promise<{ jobId: string }>;
BullMQ job:
// src/jobs/processors/bulk-operation.ts
{
name: 'bulk-operation',
queue: 'bulk',
concurrency: 2,
processor: async (job) => {
const { portId, userId, entityType, entityIds, action, actionData } = job.data;
let succeeded = 0, failed = 0;
for (let i = 0; i < entityIds.length; i += 50) {
const batch = entityIds.slice(i, i + 50);
for (const entityId of batch) {
try {
switch (action) {
case 'status_change': await changeStatus(portId, userId, entityType, entityId, actionData.newStatus); break;
case 'tag_assign': await assignTags(portId, userId, entityType, entityId, actionData.tagIds); break;
case 'tag_remove': await removeTags(portId, userId, entityType, entityId, actionData.tagIds); break;
case 'archive': await archiveEntity(portId, userId, entityType, entityId); break;
}
succeeded++;
} catch { failed++; }
}
// Report progress via Socket.io
const progress = Math.round(((i + batch.length) / entityIds.length) * 100);
await job.updateProgress(progress);
emitToUser(userId, 'bulk:progress', { jobId: job.id, progress, succeeded, failed });
}
emitToUser(userId, 'bulk:completed', { jobId: job.id, succeeded, failed });
// Audit log the bulk operation
await auditLog({ portId, userId, action: `bulk_${action}`, entityType,
metadata: { count: entityIds.length, succeeded, failed } });
},
}
API routes:
| Method | Path | File |
|---|---|---|
| POST | /api/v1/bulk/status-change |
src/app/api/v1/bulk/status-change/route.ts |
| POST | /api/v1/bulk/tag-assign |
src/app/api/v1/bulk/tag-assign/route.ts |
| POST | /api/v1/bulk/tag-remove |
src/app/api/v1/bulk/tag-remove/route.ts |
| POST | /api/v1/bulk/archive |
src/app/api/v1/bulk/archive/route.ts |
| POST | /api/v1/bulk/export |
src/app/api/v1/bulk/export/route.ts |
UI:
src/components/shared/bulk-action-bar.tsx
- Sticky bar at bottom of table when rows selected
- Shows: "{N} selected" + action buttons: Change Status, Add Tags, Remove Tags, Export, Archive
- Confirmation dialog for destructive actions
- Progress bar during execution (Socket.io driven)
Update all list table components to support:
- Checkbox column
- "Select all" / "Select all matching" toggle
- Show
<BulkActionBar>when selection > 0
Stream D: Dashboard & Analytics (Days 3–6)
Day 3: Dashboard Layout + KPI Cards
Dashboard page: src/app/(dashboard)/[portSlug]/page.tsx
This IS the root page of the port context — the landing page after login.
Layout:
<DashboardPage>
<DashboardHeader> — "Dashboard" title, date range selector
<div className="grid grid-cols-4 gap-4 mb-6">
<KpiCard title="Occupancy" value="67%" trend={+5} icon={Anchor} />
<KpiCard title="Active Clients" value={42} trend={+3} icon={Users} />
<KpiCard title="Pipeline Value" value="$2.4M" trend={-2} icon={TrendingUp} />
<KpiCard title="Open Interests" value={18} trend={+1} icon={Target} />
</div>
<div className="grid grid-cols-2 gap-6">
<PipelineWidget />
<BerthOccupancyWidget />
<ActivityFeedWidget />
<UpcomingWidget />
<ExpenseSummaryWidget />
<OverdueWidget />
</div>
</DashboardPage>
Dashboard data service: src/lib/services/dashboard.ts
/**
* Aggregate all dashboard data in parallel queries.
* Each widget's data is independently cached in Redis (5 min TTL).
*/
export async function getDashboardData(portId: string, userId: string): Promise<DashboardData>;
export async function getKpiData(portId: string): Promise<KpiData>;
export async function getPipelineSummary(portId: string): Promise<PipelineSummaryData>;
export async function getBerthOccupancy(portId: string): Promise<OccupancyData>;
export async function getExpenseSummary(portId: string): Promise<ExpenseSummaryData>;
export async function getRevenueForecast(portId: string): Promise<RevenueForecastData>;
KPI card component: src/components/dashboard/kpi-card.tsx
- Icon, metric value (large), label, trend indicator (↑/↓ with color), vs last period
Day 4: Pipeline + Occupancy + Revenue Widgets
Pipeline widget: src/components/dashboard/pipeline-widget.tsx
- Horizontal bar chart (Recharts
<BarChart>) with 8 bars — one per stage - Each bar: count label, color from PIPELINE_STAGE_COLORS
- Click on bar → navigate to interests list filtered by that stage
- Footer: total pipeline value, conversion rate
Stage probability weights for revenue forecast (using correct stage names):
export const STAGE_PROBABILITIES: Record<PipelineStage, number> = {
open: 0.05,
details_sent: 0.1,
in_communication: 0.25,
visited: 0.4,
signed_eoi_nda: 0.6,
deposit_10pct: 0.8,
contract: 0.9,
completed: 1.0,
};
Berth occupancy widget: src/components/dashboard/berth-occupancy-widget.tsx
- Donut chart (Recharts
<PieChart>) showing available/under_offer/sold counts - Legend with percentages
- Mini simplified berth map below (reuse SVG from berth explorer, simplified)
Revenue forecast widget: src/components/dashboard/revenue-forecast-widget.tsx
- Line chart (Recharts
<LineChart>): projected vs actual revenue over 6 months - Projected = sum of (interest value × stage probability) per month
- Actual = sum of completed interest values / paid invoices
Day 5: Activity Feed + Upcoming + Overdue Widgets
Activity timeline service (reusable): src/lib/services/activity-timeline.ts
/**
* Get activity timeline from audit_logs.
* Reusable across: dashboard feed, client detail timeline, interest detail timeline, berth detail timeline.
*
* @param portId - Port UUID
* @param options - Filter by entity type/id, limit, offset
* @returns Formatted timeline entries
*/
export async function getTimeline(
portId: string,
options: {
entityType?: string;
entityId?: string;
limit?: number;
offset?: number;
},
): Promise<TimelineEntry[]>;
Activity feed widget: src/components/dashboard/activity-feed-widget.tsx
- Recent 20 actions from audit_logs (across all entities)
- Each entry: user avatar, action description, entity link, time ago
- Auto-refresh via Socket.io port-level events
Upcoming widget: src/components/dashboard/upcoming-widget.tsx
- Unified list: CRM reminders + Google Calendar events for next 14 days
- Each item: source icon (bell for CRM, calendar for Google), title, time, entity link, priority badge
- Quick actions: complete, snooze (for CRM reminders only)
Overdue widget: src/components/dashboard/overdue-widget.tsx
- Sections: Overdue Reminders, Overdue Invoices, Expired Documents
- Severity coloring: 1-3 days amber, 4-7 days orange, 7+ days red
- Count badge in header
Day 6: Analytics Pages + Dashboard Auto-Refresh
Dedicated analytics pages:
src/app/(dashboard)/[portSlug]/reports/page.tsx — analytics hub with tabs:
- Pipeline Analytics: funnel chart, stage duration averages, conversion rates over time
- Berth Analytics: occupancy trends, most popular areas/sizes, price distribution
- Expense Analytics: monthly trends by category, payer breakdown, currency distribution
- Revenue Analytics: weighted pipeline, forecasts, actual vs projected
API routes:
| Method | Path | File |
|---|---|---|
| GET | /api/v1/dashboard |
src/app/api/v1/dashboard/route.ts |
| GET | /api/v1/analytics/pipeline |
src/app/api/v1/analytics/pipeline/route.ts |
| GET | /api/v1/analytics/berths |
src/app/api/v1/analytics/berths/route.ts |
| GET | /api/v1/analytics/expenses |
src/app/api/v1/analytics/expenses/route.ts |
| GET | /api/v1/analytics/revenue |
src/app/api/v1/analytics/revenue/route.ts |
Dashboard auto-refresh strategy:
- Each widget has its own TanStack Query key with appropriate stale time
- Socket.io events invalidate relevant queries:
interest:*→ invalidate pipeline, KPIsberth:*→ invalidate occupancyexpense:*,invoice:*→ invalidate expense summary, KPIsreminder:*→ invalidate upcoming, overdue
- Redis cache TTL: 5 minutes per widget. Invalidated on Socket.io events.
TanStack Query keys:
| Key | Stale Time |
|---|---|
['dashboard', portId, 'kpis'] |
5 min |
['dashboard', portId, 'pipeline'] |
5 min |
['dashboard', portId, 'occupancy'] |
5 min |
['dashboard', portId, 'expenses'] |
5 min |
['dashboard', portId, 'revenue'] |
5 min |
['dashboard', portId, 'activity'] |
1 min |
['dashboard', portId, userId, 'upcoming'] |
2 min |
['dashboard', portId, 'overdue'] |
2 min |
3. Code-Ready Details
Middleware Chain per Route Group
| Route Group | Middleware Chain |
|---|---|
/api/v1/email/* |
withAuth → withPortScope → withPermission('email', action) → handler |
/api/v1/reminders/* |
withAuth → withPortScope → withPermission('reminders', action) → handler |
/api/v1/calendar/* |
withAuth → handler (calendar is per-user, not port-scoped) |
/api/v1/notifications/* |
withAuth → handler (user-scoped) |
/api/v1/search |
withAuth → withPortScope → handler |
/api/v1/saved-views/* |
withAuth → withPortScope → handler |
/api/v1/bulk/* |
withAuth → withPortScope → withPermission(entityType, 'edit') → handler |
/api/v1/dashboard |
withAuth → withPortScope → handler |
/api/v1/analytics/* |
withAuth → withPortScope → withPermission('reports', 'view') → handler |
shadcn/ui Components Used in L3
| Component | Where Used |
|---|---|
Command, CommandDialog, CommandInput, CommandList, CommandGroup, CommandItem |
Command palette (Cmd+K) |
Popover, PopoverContent, PopoverTrigger |
Snooze picker, notification bell dropdown |
Switch |
Notification preference toggles |
Checkbox |
Bulk selection in tables |
Progress |
Bulk operation progress |
Separator |
Widget dividers on dashboard |
Skeleton |
Dashboard widget loading states |
ScrollArea |
Notification dropdown, activity feed |
Zustand Slices
// src/lib/stores/notification-store.ts
interface NotificationStore {
unreadCount: number;
setUnreadCount: (count: number) => void;
incrementUnread: () => void;
decrementUnread: () => void;
}
// src/lib/stores/search-store.ts
interface SearchStore {
isOpen: boolean;
setOpen: (open: boolean) => void;
recentSearches: string[];
addRecentSearch: (query: string) => void;
}
4. Acceptance Criteria
Email System (Stream A)
- Email account CRUD with provider presets (Google, Outlook, Custom)
- Credentials encrypted with AES-256-GCM using EMAIL_CREDENTIAL_KEY env var
- SMTP/IMAP connection test with live feedback
- IMAP sync: fetch new messages, match to clients via email, thread by In-Reply-To
- Email thread viewer with DOMPurify-sanitized HTML rendering
- Email composer with TipTap (email context), merge fields, attachment upload
- Email send via user's SMTP account, stored in thread, audited
- 11 MJML system email templates rendering with port branding
- All L0–L2 inline HTML emails refactored to use MJML templates
- BR-131: auto-disable account after 3 consecutive IMAP/SMTP failures
Reminders & Calendar (Stream B)
- Reminder CRUD with entity linking (client, interest, berth)
- Reminder complete, snooze (BR-062), dismiss actions
- Follow-up auto-reminders per BR-060: hourly check, inactivity threshold, auto_generated=true
- Auto-reminders link to interest via interest_id
- Overdue reminder notifications (BR-050)
- Berth tenure expiry notifications (BR-003) at 6-month warning
- Google Calendar OAuth 2.0 connect/disconnect
- Calendar selection from user's Google Calendar list
- Two-way sync: push CRM reminders → Google Calendar events
- Two-way sync: detect Google Calendar changes → update CRM reminders
- Sync triggers: 30-min poll, on login, on navigation (if > 5 min since last sync)
- Token refresh with automatic revocation detection
- Unified upcoming view: CRM reminders + Google Calendar events merged chronologically
Notifications (Stream B continued)
- Notification center: bell dropdown with unread count, live Socket.io updates
- Full notification list page with type/read filtering
- Notification preferences: per-type × per-channel (in-app, email) toggles
- System alerts to super_admin always delivered (never suppressible)
- Notification cooldown: same type+entity throttled for 1 hour (BR-052)
- Email notifications delivered via MJML templates through Poste.io
- All L1–L2 trigger points wired to notification creator
Search & Views (Stream C)
- Global search: tsvector full-text + pg_trgm fuzzy matching
- Search across clients, interests, berths, expenses, invoices, documents
- tsvector columns + GIN indexes + search triggers on all searchable tables
- Trigram indexes for fuzzy/prefix matching on key fields
- Command palette (Cmd+K / Ctrl+K) with grouped results, keyboard navigation
- Recent searches remembered per user
- Saved views: create, edit, delete, share, set as default per entity type
- Saved view bar integrated on all 5 list pages
- Bulk operations: status change, tag assign/remove, export, archive
- Bulk progress tracking via Socket.io with completion summary
- Bulk action bar appears on row selection in all list tables
Dashboard (Stream D)
- Dashboard as landing page at
/(dashboard)/[portSlug]/(root) - 4 KPI cards with trend indicators
- Pipeline widget with correct 8-stage names from BR-010
- Berth occupancy donut chart
- Revenue forecast using correct stage probability weights
- Expense summary bar chart with category breakdown
- Activity feed with real-time Socket.io updates
- Upcoming items widget (unified reminders + Google Calendar)
- Overdue widget (reminders, invoices, expired docs) with severity coloring
- All widgets: Redis cached (5 min), auto-invalidate on relevant events
- Analytics pages: pipeline, berth, expense, revenue with Recharts visualizations
5. Self-Review Checklist
- All route paths use
(dashboard)/[portSlug]/— zero instances of(crm)/ - All component paths use
src/components/{entity}/— nodomain/subdirectory - Dashboard is the root page of port context:
src/app/(dashboard)/[portSlug]/page.tsx - Pipeline stage names match BR-010 everywhere (constants, labels, colors, probabilities)
- Encryption key env var is
EMAIL_CREDENTIAL_KEYper Security Guidelines - Email HTML rendered with DOMPurify strict allowlist (no script, style, iframe, form)
- Google Calendar token encryption uses pgcrypto
- Auto-generated reminders set
interest_idto link back to the source interest - Notification cooldown (BR-052) prevents spam: 1 hour per type+entity
- System alerts to super_admin always delivered regardless of preferences
- Search uses both tsvector (full-text) and pg_trgm (fuzzy) for comprehensive coverage
cmdkpackage flagged as tech-stack addition- All MJML templates use shared layout with port branding
- All L0–L2 inline email HTML replaced with MJML template calls
- Activity timeline service is reusable across dashboard and entity detail pages
- Revenue forecast uses correct stage probability weights with correct stage names
- BullMQ jobs follow established patterns: queue name, concurrency, retry config from 11-REALTIME spec
- All API endpoints have Zod validation, port scoping, permission checks
- Credentials never logged, never returned in API responses
- Dashboard widgets each have independent TanStack Query keys with appropriate stale times
Codex Addenda — Merged from Competing Plan Review
1. Recent Searches in Redis (Not a Table)
Recent searches are stored in Redis sorted sets, not a database table:
- Key pattern:
recent-search:{userId}:{portId} - Max entries: 10
- TTL: 30 days
- Cleared automatically when the user loses port access (key is port-scoped)
2. Exact Bulk API Endpoint Paths
The locked catalog defines exactly four bulk endpoints. Use these paths only:
/api/bulk/status-change/api/bulk/tag/api/bulk/export/api/bulk/delete
Do not invent /api/bulk/archive or /api/bulk/tag-assign. The /api/bulk/delete endpoint performs soft delete where the entity supports archived_at; it does not hard-delete primary business records.
3. Expression GIN Indexes (Not Schema Columns)
Do not add search_vector columns to the locked schema. Instead, use expression GIN indexes:
CREATE INDEX idx_clients_search_expr ON clients
USING gin (to_tsvector('simple', coalesce(full_name,'') || ' ' || coalesce(company_name,'')));
Build equivalent expression indexes for berths, expenses, and invoices.
4. Dashboard Pipeline Weights in System Settings
Store pipeline weight configuration in system_settings rather than hardcoding stage percentages in UI code. This allows admin adjustment without code changes.
5. Widget-Level Failure Degradation
Dashboard widgets should degrade individually and show retry affordances. The whole dashboard page should not fail if one analytics service is down. Empty dashboard data must render sensible zero states, not blank chart canvases.
6. Email Threading Determinism
Thread matching order must be deterministic:
In-Reply-ToorReferenceshit existingmessage_id_header- Same normalized subject within active window
- New thread
Add: CREATE UNIQUE INDEX idx_email_message_header ON email_messages (message_id_header) WHERE message_id_header IS NOT NULL;
7. Notification Cooldown
Notification cooldown checks should use notifications.metadata entity identifiers rather than brittle title matching. Add index: idx_notifications_user_type on (user_id, type, created_at DESC).
8. Search Minimum Length
Search queries shorter than 2 characters return empty grouped results rather than expensive fuzzy scans.