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

1. **Wrong routes again (4th layer in a row).** Every UI path uses `(crm)/` instead of `(dashboard)/[portSlug]/`. Every component uses `domain/` subdirectory. At this point these errors would cascade through the entire codebase if not caught.

2. **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`.

3. **Wrong env var for encryption key.** Baseline uses `CREDENTIALS_ENCRYPTION_KEY` but Security Guidelines specify `EMAIL_CREDENTIAL_KEY` as the environment variable name.

4. **Missing reminder `interest_id` field support.** Baseline's follow-up auto-reminder queries interests but creates reminders without linking via `interest_id` — the schema has this column and it should be populated.

### Design Issues

5. **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.

6. **`cmdk` not in locked tech stack.** The baseline specifies `cmdk` for the command palette but `14-TECHNICAL-DECISIONS.md` doesn'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-kit` in L2.

7. **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 enabled `pg_trgm`, we should use trigram similarity for fuzzy matching alongside tsvector for ranked full-text search.

8. **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.

9. **Dashboard page route unclear.** The baseline puts the dashboard at `(crm)/dashboard/page.tsx`, but per `13-UI-PAGE-MAP.md` the dashboard is the landing page after login. With the locked route structure `(dashboard)/[portSlug]/`, the dashboard page should be `src/app/(dashboard)/[portSlug]/page.tsx` — the root of the port context.

10. **Missing activity timeline service.** The baseline's activity feed reads from `audit_logs` but 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

11. The notification creator pattern (centralized function called by all systems) is well-designed. I'll adopt it.
12. The Google Calendar sync strategy with three triggers (poll, login, navigation) is thorough and matches the spec.
13. BullMQ job definitions are consistently structured.
14. 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`

```typescript
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`

```typescript
/**
 * 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`

```typescript
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:**

```typescript
// 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 management
- `src/components/email/email-account-form.tsx` — provider presets + manual config
- `src/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):**

```typescript
// 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`

```typescript
/**
 * 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`

```typescript
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 emails
- `src/lib/services/eoi.ts` — EOI signing notification
- `src/lib/services/invoices.ts` — invoice send
- `src/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`

```typescript
/**
 * 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`

```typescript
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):**

```typescript
// 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:**

```typescript
// 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`

```typescript
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:**

```typescript
{
  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 connection
- `src/components/calendar/calendar-connect-button.tsx` — OAuth flow trigger
- `src/components/calendar/calendar-status.tsx` — connection status, last sync, calendar selector

#### Day 3: Notification System

**Notification creator (centralized):** `src/lib/services/notifications.ts`

```typescript
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 `link` URL + 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_registration`
- `src/lib/services/documents.ts` — signer completed → `eoi_signed`, all completed → `eoi_completed`
- `src/lib/services/invoices.ts` — overdue → `invoice_overdue` (already in L2 BullMQ job)
- `src/lib/services/berth-waiting-list.ts` — berth available → `waiting_list`
- `src/lib/services/clients.ts` — duplicate detected → `duplicate_alert`
- `src/lib/services/email-sync.ts` — new email received → `email_received`
- All BullMQ job failures → `system_alert` to super_admin

#### Day 4: Notification Email Delivery + Reminder UI on Entity Pages

**Email notification delivery (BullMQ job):**

```typescript
// 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`

```typescript
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:**

```typescript
// 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:**

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

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

```typescript
// 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`

```typescript
/**
 * 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`

```typescript
/**
 * 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:**

```typescript
// 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:

1. Checkbox column
2. "Select all" / "Select all matching" toggle
3. 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`

```typescript
/**
 * 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):**

```typescript
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`

```typescript
/**
 * 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, KPIs
  - `berth:*` → invalidate occupancy
  - `expense:*`, `invoice:*` → invalidate expense summary, KPIs
  - `reminder:*` → 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

```typescript
// 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)

1. Email account CRUD with provider presets (Google, Outlook, Custom)
2. Credentials encrypted with AES-256-GCM using EMAIL_CREDENTIAL_KEY env var
3. SMTP/IMAP connection test with live feedback
4. IMAP sync: fetch new messages, match to clients via email, thread by In-Reply-To
5. Email thread viewer with DOMPurify-sanitized HTML rendering
6. Email composer with TipTap (email context), merge fields, attachment upload
7. Email send via user's SMTP account, stored in thread, audited
8. 11 MJML system email templates rendering with port branding
9. All L0–L2 inline HTML emails refactored to use MJML templates
10. BR-131: auto-disable account after 3 consecutive IMAP/SMTP failures

### Reminders & Calendar (Stream B)

11. Reminder CRUD with entity linking (client, interest, berth)
12. Reminder complete, snooze (BR-062), dismiss actions
13. Follow-up auto-reminders per BR-060: hourly check, inactivity threshold, auto_generated=true
14. Auto-reminders link to interest via interest_id
15. Overdue reminder notifications (BR-050)
16. Berth tenure expiry notifications (BR-003) at 6-month warning
17. Google Calendar OAuth 2.0 connect/disconnect
18. Calendar selection from user's Google Calendar list
19. Two-way sync: push CRM reminders → Google Calendar events
20. Two-way sync: detect Google Calendar changes → update CRM reminders
21. Sync triggers: 30-min poll, on login, on navigation (if > 5 min since last sync)
22. Token refresh with automatic revocation detection
23. Unified upcoming view: CRM reminders + Google Calendar events merged chronologically

### Notifications (Stream B continued)

24. Notification center: bell dropdown with unread count, live Socket.io updates
25. Full notification list page with type/read filtering
26. Notification preferences: per-type × per-channel (in-app, email) toggles
27. System alerts to super_admin always delivered (never suppressible)
28. Notification cooldown: same type+entity throttled for 1 hour (BR-052)
29. Email notifications delivered via MJML templates through Poste.io
30. All L1–L2 trigger points wired to notification creator

### Search & Views (Stream C)

31. Global search: tsvector full-text + pg_trgm fuzzy matching
32. Search across clients, interests, berths, expenses, invoices, documents
33. tsvector columns + GIN indexes + search triggers on all searchable tables
34. Trigram indexes for fuzzy/prefix matching on key fields
35. Command palette (Cmd+K / Ctrl+K) with grouped results, keyboard navigation
36. Recent searches remembered per user
37. Saved views: create, edit, delete, share, set as default per entity type
38. Saved view bar integrated on all 5 list pages
39. Bulk operations: status change, tag assign/remove, export, archive
40. Bulk progress tracking via Socket.io with completion summary
41. Bulk action bar appears on row selection in all list tables

### Dashboard (Stream D)

42. Dashboard as landing page at `/(dashboard)/[portSlug]/` (root)
43. 4 KPI cards with trend indicators
44. Pipeline widget with correct 8-stage names from BR-010
45. Berth occupancy donut chart
46. Revenue forecast using correct stage probability weights
47. Expense summary bar chart with category breakdown
48. Activity feed with real-time Socket.io updates
49. Upcoming items widget (unified reminders + Google Calendar)
50. Overdue widget (reminders, invoices, expired docs) with severity coloring
51. All widgets: Redis cached (5 min), auto-invalidate on relevant events
52. 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}/` — no `domain/` 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_KEY` per 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_id` to 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
- [ ] `cmdk` package 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:

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

1. `In-Reply-To` or `References` hit existing `message_id_header`
2. Same normalized subject within active window
3. 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.
