7 Commits

Author SHA1 Message Date
2a4dadd5a7 docs(launch): execute-ready initial-deployment runbook
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m51s
Locked decisions (Postgres=own, deploy dir /root/docker-compose/pn-crm, DB/Redis localhost-only), prerequisites checklist, ordered gated phases (recon -> CRM -> data -> Documenso -> website cutover), rollback anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:50:42 +02:00
44b004fa8f feat(intake): recipient picker UI (users/roles/everyone/emails)
Adds RecipientPicker (multi-select users + roles, everyone-with-inquiry-access toggle, free-text emails) and a new 'recipients' settings field type. The inquiry + residential notification-recipient settings now render the picker instead of a raw JSON textarea, persisting the structured {emails,userIds,roleIds,everyone} config the server resolver expands. tsc clean; full vitest suite (1570) green. Live browser verification of the picker pending a dev server (env currently on the prod build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:36:24 +02:00
5ea0c75fff feat(intake): structured notification-recipient resolver (emails/users/roles/everyone)
parseRecipientConfig (backward-compat: legacy string[] -> emails) + resolveRecipientEmails (expands userIds/roleIds/everyone-with-interests.view into deduped addresses) + resolveNotificationRecipients (load setting, fallback to inquiry_contact_email). Wired into the website-intake email path so berth/contact/residential staff alerts honor the richer recipients. TDD: parseRecipientConfig unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:28:48 +02:00
0416dc8d39 docs(launch): website-integration env vars + cutover sequence
deployment-plan.md gains a full env-var reference (CRM + website) and the cutover env-flip sequence; launch-readiness.md gets the 2026-06-02 closeout; BACKLOG.md adds the deferred integration-health-panel idea (section L).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:12 +02:00
990b566eff feat(intake): CRM-owned website inquiry emails + in-app notifications
Flag-gated (website_intake_email_enabled, default OFF) sending of registrant confirmation + staff alert for inquiries captured at /api/public/website-inquiries, reusing the branded berth + residential templates and adding contact-form client-confirmation + sales-alert templates. In-app (bell) notifications fire on every fresh capture, independent of the flag. Recipients resolve from the existing inquiry_/residential_notification_recipients settings; fires only on a fresh (non-deduped) insert so retries never re-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:08 +02:00
f699533224 Merge feat/residential-toggle-and-reports-comparison into main
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Failing after 5m43s
Reports overhaul (residential toggle, sales comparison + filters, financial
report, importer, migration scripts, reports polish, marketing 404 gate) +
pre-launch codebase/security audit with full remediation (85 findings: 4
CRITICAL / 17 HIGH / 29 MEDIUM / 35 LOW; 84 fixed, L21 false-positive) +
custom-report build-blocker fix.

Validation: 1103 unit + 458 integration tests green; tsc clean; production
build green. E2e smoke deferred to CI (needs the standalone server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:08:31 +02:00
79b6ab2ae0 fix(build): split custom-report registry into client-safe metadata + server query module
The custom-report builder (client component) imported the registry which pulls
in @/lib/db (postgres -> tls), breaking the production build. Extract
ENTITY_META/ENTITY_KEYS/column defs into registry-meta.ts (no DB imports);
registry.ts keeps runQuery + composes ENTITY_REGISTRY. Pre-existing blocker
surfaced during pre-merge build validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:28:51 +02:00
19 changed files with 1533 additions and 127 deletions

View File

@@ -435,3 +435,17 @@ not surfaced in §C above were resolved via the `fix(audit): …` commits
- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 08 shipped)
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
- [`website-refactor.md`](./website-refactor.md) — public website cutover plan
---
## L. Website "integration health" panel (post-launch idea)
**Raised 2026-06-02 (Matt); verdict: NOT worth building pre-launch.**
A CRM admin "control panel" to watch website log events / activity. Reasons to defer:
- Inquiry visibility already exists: `website_submissions` + its triage inbox (open / assigned / converted / dismissed).
- Website traffic/events are already covered by Umami; app + error logs belong in pino/server logs (or a log tool), not a hand-rolled CRM page. Shipping website logs into the CRM is net-new bidirectional plumbing plus a security surface for marginal gain.
- Not launch-blocking; classic pre-launch scope creep.
**If revisited post-launch:** scope it to ONE small "integration health" card on the admin dashboard — last website submission received, count today, intake 4xx/5xx rate, inquiry email-send success. Pure read, no new ingestion pipeline. Build only if a real operational need surfaces; during cutover this is observable via logs + the submissions inbox.

View File

@@ -97,7 +97,7 @@ nginx vhost exists yet (fresh setup). Template: `portnimara_dev.conf`
1. Create `/etc/nginx/sites-available/crm_portnimara.conf` modelled on
`portnimara_dev.conf`: port-80 → 443 redirect + `.well-known/acme-challenge`
location; port-443 server `proxy_pass http://127.0.0.1:7100` with the same
header block (Host, X-Real-IP, CF-Connecting-IP, X-Forwarded-_, websocket
header block (Host, X-Real-IP, CF-Connecting-IP, X-Forwarded-\_, websocket
`Upgrade`/`Connection` for socket.io), `client_max_body_size 64M`,
`proxy_read_timeout 300`, buffering off. **HTTP-only first** (no `ssl\__`
lines yet) so Certbot can complete the challenge.
@@ -236,3 +236,143 @@ successfully applied`, 140→157, none unfinished), app boots (home 302,
removed; restored clone gone, off-box dump retained). Compose file kept
at `private/documenso-dryrun/docker-compose.yml` for a re-run. Prod
still untouched.
---
## Environment variables — initial deployment + cutover
> Single source of truth for the env each instance needs for the
> website<->CRM integration (added 2026-06-02). **Every website-side CRM
> var is a no-op when unset**, so the marketing site behaves exactly as
> today until these are filled at cutover. Full CRM schema: `src/lib/env.ts`.
### CRM instance (`crm.portnimara.com`)
| Var | Value | Notes |
| ------------------------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `APP_URL` | `https://crm.portnimara.com` | Absolute URLs + email links (the inquiry sales-alert "Open in CRM" button). |
| `WEBSITE_INTAKE_SECRET` | shared secret | **MUST equal** the website's `CRM_INTAKE_SECRET`. If unset, `/api/public/website-inquiries` returns **503** and refuses all intake. |
| `EMAIL_REDIRECT_TO` | **unset in prod** | Dev-only reroute; the prod build guard fails if it is set. |
| `DATABASE_URL`, `REDIS_*`, storage/MinIO, `DOCUMENSO_*`, `SMTP_*`, better-auth secret | per `.env` | Standard (see Phase 1 Pre-flight). |
Per-port **settings** (stored in `system_settings`, set via Admin UI — NOT env):
- `website_intake_email_enabled` — boolean, **default OFF**. Flip ON at
cutover so the CRM sends the registrant confirmation + staff alert for
website inquiries (berth / residence / contact), reusing the branded
templates + per-port From. Keep OFF until the website's own sending is
turned off (see `WEBSITE_INQUIRY_EMAILS_DISABLED`) to avoid double-sends.
- `inquiry_notification_recipients` (JSON string[]) — staff who receive
berth + contact-form inquiry alerts.
- `residential_notification_recipients` (JSON string[]) — staff who receive
residence inquiry alerts.
- `inquiry_contact_email` (string) — fallback alert recipient + reply-to.
### Website instance (Nuxt marketing site — repo `ron/website.git`)
New vars for the CRM integration (read via `process.env` in Nitro;
**all no-op when unset → site unchanged**):
| Var | Value | Enables | Set when |
| --------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `CRM_INTAKE_URL` | `https://crm.portnimara.com` (bare host, no trailing slash) | Inquiry dual-write delivery + base URL for the berth feed | Cutover (safe earlier; just starts populating `website_submissions`) |
| `CRM_INTAKE_SECRET` | shared secret | Auth for the dual-write (`X-Webhook-Secret`); **MUST equal** CRM `WEBSITE_INTAKE_SECRET` | With `CRM_INTAKE_URL` |
| `CRM_BERTHS_ENABLED` | `1` (or `true`/`yes`) | Switches the public berth map/list to read from CRM `/api/public/berths` instead of NocoDB (requires `CRM_INTAKE_URL`) | Cutover, after CRM berth data is migrated + verified |
| `WEBSITE_INQUIRY_EMAILS_DISABLED` | `1` | Turns OFF the website's own Gmail confirmation + alert emails, handing email ownership to the CRM | Cutover, flipped **together** with CRM `website_intake_email_enabled = ON` |
UTM: **no env var** — cookieless; the client plugin reads `utm_*` from the
landing URL and forwards them via an `x-utm` header.
Existing website env (keep, unchanged): NocoDB url/token, SMTP user/pass,
`alertRecipientsBerths/Residences/Contact`, `RECAPTCHA_SECRET`,
`NUXT_PUBLIC_RECAPTCHA_SITE_KEY`, Directus url. NocoDB stays as the berth
fallback + the dual-write's primary target until the old system is retired;
SMTP + alert recipients stay until `WEBSITE_INQUIRY_EMAILS_DISABLED` is set.
### Cutover env-flip sequence (website)
1. Confirm CRM is up, berth data migrated, and `WEBSITE_INTAKE_SECRET` set on the CRM.
2. Set website `CRM_INTAKE_URL` + `CRM_INTAKE_SECRET` → verify a test inquiry lands in `website_submissions`.
3. Flip CRM `website_intake_email_enabled = ON` **and** website `WEBSITE_INQUIRY_EMAILS_DISABLED = 1` together → CRM is the single email owner.
4. Set website `CRM_BERTHS_ENABLED = 1` → public map reads from the CRM.
5. Watch errors; rollback = unset the website vars (instant revert to NocoDB + website email).
## Progress log (cont.)
- 2026-06-02: **Website integration prep (local only; no prod changes, nothing pushed).**
Website repo (`main`, uncommitted): env-gated berth feed (`CRM_BERTHS_ENABLED`),
cookieless UTM forwarding (no env), inquiry dual-write (pre-existing). Website
email kill-switch added (`WEBSITE_INQUIRY_EMAILS_DISABLED`). CRM repo: flag-gated
email ownership (`website_intake_email_enabled`, default OFF) reusing the inquiry
- residential templates plus a new contact-form alert template, hooked into
`/api/public/website-inquiries`. New website env vars documented above. CRM
tsc-clean + unit test added; website berth/UTM vue-tsc-clean. Nothing deployed.
- 2026-06-02 (later): in-app notifications for website submissions + the
structured notification-recipient resolver (emails/users/roles/everyone,
backward-compatible) + the admin recipient-picker UI all shipped on the
`feat/website-intake-email-ownership` branch (CRM repo). Contact-form client
confirmation added on BOTH the website (gated by `WEBSITE_INQUIRY_EMAILS_DISABLED`)
and the CRM (gated by `website_intake_email_enabled`). tsc-clean; full vitest
suite (1570) green. Picker live browser-verify pending a dev server. Branch is
4 commits ahead of `main`, not merged, not pushed.
---
## Initial Deployment Runbook — execute-ready (assembled 2026-06-02)
> The single ordered checklist for go-live; detailed step content is in Phase 1
> / Phase 2 above + Initiative 5 (`launch-readiness.md`). **Guardrail stands: no
> prod-server mutation without per-action approval; reads/recon are free.** Per
> Matt (2026-06-02): assemble ALL inputs + the full plan before executing
> anything, including recon.
### Locked decisions
- **CRM Postgres:** OWN — compose-default `postgres:16`, isolated `pgdata`.
- **Deploy dir:** `/root/docker-compose/pn-crm/` (matches the other compose folders).
- **DB/Redis exposure:** bind to `127.0.0.1` ONLY — no public ports (the Documenso
`5432` public-exposure + brute-force lesson; the R1 port scan confirms).
- **Initial image:** include the email-ownership work — merge
`feat/website-intake-email-ownership` -> `main` -> push -> CI builds -> pull.
It is all flag-OFF by default, so it ships dormant + safe. (Alternative on
record: deploy `main` as-is, merge before the website cutover flip.)
### Prerequisites — gather BEFORE executing
| Need | For | Status |
| -------------------------------------------------------- | ---------------------------------------- | ------------------------ |
| SSH `stefan@45.142.177.246:22022` (key) | all recon + deploy | have |
| prod root pass (`su`) | docker / nginx / certbot | VERIFY (creds file) |
| Gitea registry pull token | `docker login` -> pull crm images | NEED (generate) |
| `WEBSITE_INTAKE_SECRET` (shared) | CRM `.env` + website `CRM_INTAKE_SECRET` | generate at P1 |
| Documenso API token + webhook secret | CRM `.env` (login `matt@portnimara.com`) | NEED |
| MinIO creds (endpoint/key/secret/bucket) for the new CRM | CRM `.env` storage | confirm (creds §3) |
| Legacy MinIO read creds | EOI backfill (D2) | NEED |
| Website-server root pass | Phase 4 env wiring | you provide at that step |
| Maintenance window | Documenso restart | schedule |
### Ordered steps (each gated)
- **Phase 0 [recon]** R1 port scan (external + internal listeners) · R2 NocoDB +
Documenso drift vs the 2026-06-01 pull · R3 fresh read-only Documenso `pg_dump`
-> re-run the `1.13.1->2.11.0` clone dry-run (final "won't break" check).
- **Phase 1 [APPROVAL]** P1 prod `.env` -> P2 `/root/docker-compose/pn-crm`
(localhost-bound DB/Redis) -> P3 nginx (HTTP-first) -> P4
`certbot --nginx -d crm.portnimara.com` -> P5 `docker login` + pull + up -> P6
schema + seed port/admin -> P7 verify (health, login, berths, socket.io).
- **Phase 2 [APPROVAL]** D1 load migrated data -> D2 MinIO EOI backfill -> D3
reconcile counts.
- **Phase 3 [APPROVAL · VITAL · together]** backups (pg_dump + cold volume
snapshot + cert + MinIO inventory, off-box) -> staged `1.13.1->2.0.0->2.11.0`
-> verify (login, existing envelope renders, test send, webhook reaches CRM,
CRM stays on v1 API) -> rollback ready.
- **Phase 4 [APPROVAL]** website env wiring on the other server -> cutover flips
(`CRM_INTAKE_URL`/`SECRET` on; then `website_intake_email_enabled` ON +
`WEBSITE_INQUIRY_EMAILS_DISABLED=1`; then `CRM_BERTHS_ENABLED=1`).
### Rollback anchors
- CRM: `docker compose -f docker-compose.prod.yml down` — the pn-crm stack is
isolated (own Postgres), zero impact on the other apps on the box.
- Documenso: revert the image tag + restore the cold volume snapshot / pg_dump.
- Website: unset the `CRM_*` env vars -> instant revert to NocoDB + website email.

View File

@@ -644,3 +644,75 @@ unproven full builds onto a same-day prod launch.
- **Marketing + Financial reports** — remain unbuilt + now hidden; gated
on Init 1b (website UTM/inquiry cutover) and Init 1c (invoices-module
decision) respectively.
---
## 2026-06-02 (session) — Pre-deployment closeout
Driving toward cutover. Decisions + state captured this session.
### Decisions locked
- **Email ownership = the new CRM, flag-gated (option A).** Registrant
confirmations + staff alerts move from the website to the CRM. The CRM
intake path sends them behind a per-port flag (default OFF); the website
keeps sending until cutover, then flips off in one coordinated step (no
gap, no double-send). Preserve asymmetry: berth/residence = confirmation
- alert; contact form = alert only. Prove end-to-end before the flip.
Cross-ref: `project_email_ownership_at_cutover` memory.
- **`feature/video-headers` is excluded from this launch** (per Matt) — a
separate workstream, do not touch. Launch base = `main`.
- **Berth-read swap is a cutover-time flip, not now.** Until cutover NocoDB
is the live source staff update; the public map must mirror it.
- **UTM capture is cookieless** (no consent banner exists). In-memory only;
no cookie / localStorage / sessionStorage (ePrivacy/PECR). Session-scoped.
### Marketing website (repo: code.portnimara.com/ron/website.git, branch `main`)
Local `main` was 1 unpushed commit ahead (the CRM dual-write) + an uncommitted
Umami refactor; nothing to pull from the remote. Reconciled:
- Committed the Umami refactor (`7e111b3`, `d03fcee`) to set a clean base.
`main` is NOT yet pushed (unpushed since 2026-05-04).
- **Inquiry dual-write**: pre-existing, dormant (no-ops without env). Contract
verified matching CRM `/api/public/website-inquiries` (header, payload,
`kind` enum, idempotent UUID).
- **Berth-read swap**: SHIPPED locally, default-OFF. `server/utils/berths.ts`
reads CRM `/api/public/berths` (+ single) when `CRM_BERTHS_ENABLED` truthy
and `CRM_INTAKE_URL` set; else NocoDB. `register.ts` skips the NocoDB
interest->berth link in CRM mode (UUID ids; CRM links via dual-write).
`PublicBerth` confirmed a verbatim superset of the website `Berth` type
incl. `Map Data` {path,x,y,transform,fontSize} -> the CRM fully backs the
website berth map. vue-tsc: type-clean.
- **UTM forwarding**: SHIPPED locally, cookieless. `plugins/utm.client.ts`
reads utm\_\* from the landing URL into memory and adds an `x-utm` header on
/api/register + /api/contact POSTs; `server/utils/utm.ts#readUtm` parses it;
`crmIntake.ts` forwards the five fields (CRM schema already accepts them).
Zero form-component changes; a misfire degrades to null UTM (no breakage).
vue-tsc: type-clean.
- **Uncommitted**: the berth-swap + UTM changes are in the working tree, not
yet committed (awaiting go).
- Pre-existing bugs noted (not fixed): `pages/berths/[mooringNumber].vue:123`
uses `.MooringNumber` (should be `["Mooring Number"]`);
`berths-item/introduction.vue:32` mis-indexes `"Water Depth"`. The website
repo has no typecheck/lint step (recommend wiring one pre-deploy).
### Env flips required at cutover (website)
- `CRM_INTAKE_URL` + `CRM_INTAKE_SECRET` (turn on inquiry dual-write delivery)
- `CRM_BERTHS_ENABLED=1` (switch the public berth map/list to the CRM)
- website email sending OFF + CRM email flag ON (single owner)
### Closeout sequence
1. Local website (no prod risk): berth swap DONE, UTM DONE.
2. CRM-side email ownership build (flag-gated) + website email-off toggle;
prove end-to-end. NEXT.
3. [GATED] Documenso v1.13.1 -> v2.11.0 prod upgrade (dry-run passed
2026-06-01; Phases A-E sober/scheduled, per-step approval).
4. [GATED] Prod CRM deploy (Phase 1: nginx/certbot/compose/.env). Inputs
needed: Postgres own/shared, deploy dir, registry token, Documenso API
token.
5. [GATED] Data migration + cutover (MinIO EOI backfill, final NocoDB
reconcile, freeze + website env flips) in a maintenance window, explicit go.
6. Post-launch: M23/M25 migrations; e2e in CI; website typecheck/lint.

View File

@@ -16,6 +16,11 @@ import {
} from '@/lib/errors';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import {
isWebsiteIntakeEmailEnabled,
notifyWebsiteSubmissionInApp,
sendWebsiteSubmissionEmails,
} from '@/lib/services/website-intake-email.service';
/**
* POST /api/public/website-inquiries
@@ -169,6 +174,40 @@ export async function POST(req: NextRequest) {
},
'website inquiry captured',
);
// In-app (bell) notifications for reps - always on a fresh capture,
// independent of email ownership, so inquiries surface in the CRM inbox.
void notifyWebsiteSubmissionInApp({
portId: port.id,
portSlug: parsed.port_slug,
kind: parsed.kind,
submissionId: parsed.submission_id,
payload: parsed.payload,
}).catch((err) =>
logger.error(
{ err, submissionId: parsed.submission_id },
'Failed to create website-intake notifications',
),
);
// Flag-gated CRM-owned emails (registrant confirmation + staff alert).
// Fire only on this fresh-insert branch so a redelivery never re-sends.
// Inline fire-and-forget: a send failure must not 500 the capture POST.
if (await isWebsiteIntakeEmailEnabled(port.id)) {
void sendWebsiteSubmissionEmails({
portId: port.id,
portSlug: parsed.port_slug,
kind: parsed.kind,
submissionId: parsed.submission_id,
payload: parsed.payload,
}).catch((err) =>
logger.error(
{ err, submissionId: parsed.submission_id },
'Failed to send website-intake emails',
),
);
}
// L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the
// `{ data }` envelope). This is the public website's intake contract —
// the external marketing site reads `id`/`deduped` off the JSON root.

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown, Save, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface Option {
id: string;
label: string;
}
export interface RecipientConfigValue {
emails: string[];
userIds: string[];
roleIds: string[];
everyone: boolean;
}
/**
* Client mirror of the server's parseRecipientConfig
* (`src/lib/services/notification-recipients.ts`) - kept inline because that
* module imports server-only deps (drizzle/db). A legacy `string[]` is treated
* as explicit emails.
*/
function parseValue(value: unknown): RecipientConfigValue {
const strArr = (v: unknown): string[] =>
Array.isArray(v)
? v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
: [];
if (Array.isArray(value)) {
return { emails: strArr(value), userIds: [], roleIds: [], everyone: false };
}
if (value && typeof value === 'object') {
const o = value as Record<string, unknown>;
return {
emails: strArr(o.emails),
userIds: strArr(o.userIds),
roleIds: strArr(o.roleIds),
everyone: o.everyone === true,
};
}
return { emails: [], userIds: [], roleIds: [], everyone: false };
}
function MultiSelect({
options,
selected,
onChange,
placeholder,
searchPlaceholder,
emptyText,
}: {
options: Option[];
selected: string[];
onChange: (next: string[]) => void;
placeholder: string;
searchPlaceholder: string;
emptyText: string;
}) {
const [open, setOpen] = useState(false);
const byId = new Map(options.map((o) => [o.id, o.label]));
const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
return (
<div className="space-y-2">
{selected.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selected.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{byId.get(id) ?? id.slice(0, 8)}
<button
type="button"
onClick={() => toggle(id)}
className="ml-0.5 rounded-sm hover:bg-muted-foreground/20"
aria-label={`Remove ${byId.get(id) ?? id}`}
>
<X className="h-3 w-3" aria-hidden />
</button>
</Badge>
))}
</div>
) : null}
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between sm:w-72"
>
<span className="truncate text-muted-foreground">{placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem key={o.id} value={o.label} onSelect={() => toggle(o.id)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selected.includes(o.id) ? 'opacity-100' : 'opacity-0',
)}
/>
{o.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
/**
* Admin control for an inquiry notification-recipient setting. Edits the
* structured `{emails,userIds,roleIds,everyone}` config (or a legacy email
* array) and persists via the parent's `onSave`. The server resolver expands
* users/roles/everyone into concrete addresses at send time.
*/
export function RecipientPicker({
value,
saving,
onSave,
}: {
value: unknown;
saving: boolean;
onSave: (config: RecipientConfigValue) => void;
}) {
const initial = parseValue(value);
const [everyone, setEveryone] = useState(initial.everyone);
const [userIds, setUserIds] = useState<string[]>(initial.userIds);
const [roleIds, setRoleIds] = useState<string[]>(initial.roleIds);
const [emailsText, setEmailsText] = useState(initial.emails.join('\n'));
const { data: usersData } = useQuery<{ data: { id: string; displayName: string | null }[] }>({
queryKey: ['recipient-user-options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
staleTime: 5 * 60_000,
});
const { data: rolesData } = useQuery<{ data: { id: string; name: string }[] }>({
queryKey: ['recipient-role-options'],
queryFn: () => apiFetch('/api/v1/admin/roles'),
staleTime: 5 * 60_000,
});
const userOptions: Option[] = (usersData?.data ?? []).map((u) => ({
id: u.id,
label: u.displayName ?? u.id.slice(0, 8),
}));
const roleOptions: Option[] = (rolesData?.data ?? []).map((r) => ({ id: r.id, label: r.name }));
function handleSave() {
const emails = emailsText
.split(/[\n,]/)
.map((e) => e.trim())
.filter((e) => e.length > 0);
onSave({ emails, userIds, roleIds, everyone });
}
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-4">
<div>
<Label>Everyone with inquiry access</Label>
<p className="text-xs text-muted-foreground">
Send to every user whose role grants inquiry visibility.
</p>
</div>
<Switch checked={everyone} onCheckedChange={setEveryone} />
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Specific users
</Label>
<MultiSelect
options={userOptions}
selected={userIds}
onChange={setUserIds}
placeholder="Add users…"
searchPlaceholder="Search users…"
emptyText="No users found."
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Roles</Label>
<MultiSelect
options={roleOptions}
selected={roleIds}
onChange={setRoleIds}
placeholder="Add roles…"
searchPlaceholder="Search roles…"
emptyText="No roles found."
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Additional email addresses
</Label>
<Textarea
rows={3}
className="text-sm"
placeholder="One per line (or comma-separated)"
value={emailsText}
onChange={(e) => setEmailsText(e.target.value)}
/>
</div>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{saving ? 'Saving…' : 'Save recipients'}
</Button>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api/client';
import { RecipientPicker } from './recipient-picker';
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
interface Setting {
@@ -35,7 +36,7 @@ const KNOWN_SETTINGS: Array<{
key: string;
label: string;
description: string;
type: 'boolean' | 'number' | 'json' | 'string' | 'select';
type: 'boolean' | 'number' | 'json' | 'string' | 'select' | 'recipients';
defaultValue: unknown;
options?: Array<{ value: string; label: string }>;
}> = [
@@ -132,18 +133,18 @@ const KNOWN_SETTINGS: Array<{
},
{
key: 'inquiry_notification_recipients',
label: 'External Notification Recipients',
label: 'Berth & contact inquiry alerts',
description:
'Additional email addresses that receive sales notifications for new interests (JSON array)',
type: 'json',
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
type: 'recipients',
defaultValue: [],
},
{
key: 'residential_notification_recipients',
label: 'Residential Notification Recipients',
label: 'Residential inquiry alerts',
description:
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
type: 'json',
'Who receives staff alerts for new residential inquiries: users, roles, everyone with inquiry access, and/or emails. Falls back to Inquiry Contact Email when empty.',
type: 'recipients',
defaultValue: [],
},
{
@@ -451,6 +452,32 @@ export function SettingsManager() {
</Card>
)}
{/* Notification Recipients (users / roles / everyone / emails) */}
{KNOWN_SETTINGS.some((s) => s.type === 'recipients') && (
<Card>
<CardHeader>
<CardTitle>Notification Recipients</CardTitle>
<CardDescription>
Who receives staff alerts for new inquiries. Pick specific users, roles, everyone
with inquiry access, and/or extra email addresses.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{KNOWN_SETTINGS.filter((s) => s.type === 'recipients').map((setting) => (
<div key={setting.key} className="space-y-2">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p>
<RecipientPicker
value={getEffectiveValue(setting.key, setting.defaultValue)}
saving={saving === setting.key}
onSave={(config) => saveSetting(setting.key, config)}
/>
</div>
))}
</CardContent>
</Card>
)}
{/* Numeric Settings */}
<Card>
<CardHeader>

View File

@@ -30,7 +30,7 @@ import {
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
import { ENTITY_KEYS, ENTITY_META, type EntityKey } from '@/lib/reports/custom/registry-meta';
import { formatMoney, formatNumber } from '@/lib/reports/format-currency';
/**
@@ -67,7 +67,7 @@ interface CustomTemplateConfig extends Record<string, unknown> {
}
function defaultColumnsFor(entity: EntityKey): string[] {
return ENTITY_REGISTRY[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key);
return ENTITY_META[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key);
}
export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string }) {
@@ -187,7 +187,7 @@ export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string
toast.success(`Downloaded ${filename}`);
}
const def = ENTITY_REGISTRY[entity];
const def = ENTITY_META[entity];
return (
<div className="space-y-6">
@@ -224,7 +224,7 @@ export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string
<SelectContent>
{ENTITY_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ENTITY_REGISTRY[k].label}
{ENTITY_META[k].label}
</SelectItem>
))}
</SelectContent>

View File

@@ -22,6 +22,8 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification',
'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert',
'contact_form_sales_alert',
'contact_form_client_confirmation',
// M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets
@@ -101,6 +103,20 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry - {{clientName}}',
},
contact_form_sales_alert: {
key: 'contact_form_sales_alert',
label: 'Contact form - sales alert',
description: 'Internal alert sent to the sales team when a website contact form is submitted.',
mergeTokens: ['portName', 'clientName', 'email'],
defaultSubject: 'New contact form submission - {{clientName}}',
},
contact_form_client_confirmation: {
key: 'contact_form_client_confirmation',
label: 'Contact form - client confirmation',
description: 'Auto-reply sent to a visitor after they submit the general website contact form.',
mergeTokens: ['portName', 'recipientName'],
defaultSubject: 'Thank you for contacting {{portName}}',
},
notification_digest: {
key: 'notification_digest',
label: 'Notification digest',

View File

@@ -0,0 +1,104 @@
import { Button, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
subject?: string | null;
}
export interface ContactFormSalesAlertData {
fullName: string;
email: string;
interestType?: string | null;
comments?: string | null;
crmDeepLink?: string;
portName?: string;
}
function SalesAlertBody({
portName,
data,
accent,
}: {
portName: string;
data: ContactFormSalesAlertData;
accent: string;
}) {
const labelCell = { color: '#666', width: '140px' } as const;
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New contact form submission
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
{data.interestType ? (
<tr>
<td style={labelCell}>Interest</td>
<td>{data.interestType}</td>
</tr>
) : null}
{data.comments ? (
<tr>
<td style={labelCell}>Comments</td>
<td>{data.comments}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
</>
);
}
export async function contactFormSalesAlert(
data: ContactFormSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New contact form submission - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -0,0 +1,81 @@
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
subject?: string | null;
}
export interface ContactFormClientConfirmationData {
firstName: string;
contactEmail: string;
portName?: string;
}
function ClientConfirmationBody({
portName,
firstName,
contactEmail,
accent,
}: {
portName: string;
firstName: string;
contactEmail: string;
accent: string;
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Thank you for getting in touch
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for reaching out to {portName}. We have received your message and a member of our
team will be in touch with you shortly.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If anything else comes to mind in the meantime, please write to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
>
{contactEmail}
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}
export async function contactFormClientConfirmation(
data: ContactFormClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank you for contacting ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<ClientConfirmationBody
portName={portName}
firstName={data.firstName}
contactEmail={data.contactEmail}
accent={accent}
/>,
{ pretty: false },
);
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -0,0 +1,144 @@
/**
* Custom-report entity registry — client-safe metadata.
*
* This module holds ONLY the pure-data parts of the custom-report
* registry: entity keys, the filter/column type contracts, the
* per-entity column allowlists, and the per-entity metadata
* (label/description/dateAxis/columns). It has NO `@/lib/db`, drizzle,
* or schema imports, so it is safe to import from client components
* (e.g. the column-picker UI in the custom report builder).
*
* The server-only query logic (`runQuery` + drizzle) lives in
* `registry.ts`, which imports the column arrays + meta from here and
* composes the full `ENTITY_REGISTRY` consumed by the run endpoint.
*
* Adding a new entity:
* 1. Append it to ENTITY_KEYS.
* 2. Add its column array + an ENTITY_META entry here.
* 3. Add the matching `runQuery` + ENTITY_REGISTRY entry in
* `registry.ts`.
* 4. The UI's entity-picker reads ENTITY_META directly.
*/
export const ENTITY_KEYS = ['clients', 'interests', 'berths', 'tenancies'] as const;
export type EntityKey = (typeof ENTITY_KEYS)[number];
export interface CustomFilter {
/** ISO 8601 — inclusive lower bound on the entity's "date" column
* (createdAt or equivalent — see entity definition). */
from?: Date;
/** ISO 8601 — inclusive upper bound. */
to?: Date;
}
export interface ColumnDefinition {
/** Stable key. Persisted in saved-template configs. */
key: string;
/** Human-readable column header used in CSV/PDF output + the UI
* multi-select. */
label: string;
/** Default selection in the UI. Reps can uncheck. */
defaultSelected?: boolean;
}
/**
* Client-safe metadata for an entity — everything except the
* server-only `runQuery`. The full `CustomEntityDefinition` (meta +
* runQuery) lives in `registry.ts`.
*/
export interface CustomEntityMeta {
key: EntityKey;
label: string;
description: string;
/** Friendly name for the date filter — different entities anchor
* the date range to different timestamps. */
dateAxis: string;
columns: ColumnDefinition[];
}
// ─── Clients ─────────────────────────────────────────────────────────────────
export const CLIENTS_COLUMNS: ColumnDefinition[] = [
{ key: 'fullName', label: 'Full name', defaultSelected: true },
{ key: 'nationalityIso', label: 'Nationality', defaultSelected: false },
{ key: 'preferredLanguage', label: 'Preferred language' },
{ key: 'preferredContactMethod', label: 'Preferred contact', defaultSelected: false },
{ key: 'source', label: 'Source', defaultSelected: true },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
{ key: 'archivedAt', label: 'Archived at' },
];
// ─── Interests ───────────────────────────────────────────────────────────────
export const INTERESTS_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'primaryBerth', label: 'Primary berth', defaultSelected: true },
{ key: 'pipelineStage', label: 'Stage', defaultSelected: true },
{ key: 'leadCategory', label: 'Lead category' },
{ key: 'outcome', label: 'Outcome', defaultSelected: true },
{ key: 'source', label: 'Source', defaultSelected: false },
{ key: 'depositExpectedAmount', label: 'Deposit expected (amt)', defaultSelected: false },
{ key: 'depositExpectedCurrency', label: 'Deposit expected (ccy)' },
{ key: 'dateFirstContact', label: 'First contact', defaultSelected: false },
{ key: 'dateLastContact', label: 'Last contact', defaultSelected: false },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
];
// ─── Berths ──────────────────────────────────────────────────────────────────
export const BERTHS_COLUMNS: ColumnDefinition[] = [
{ key: 'mooringNumber', label: 'Mooring', defaultSelected: true },
{ key: 'area', label: 'Area' },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'length', label: 'Length (m)' },
{ key: 'width', label: 'Width (m)' },
{ key: 'draft', label: 'Draft (m)' },
{ key: 'price', label: 'Price', defaultSelected: true },
{ key: 'priceCurrency', label: 'Currency' },
{ key: 'createdAt', label: 'Created' },
];
// ─── Tenancies ───────────────────────────────────────────────────────────────
export const TENANCIES_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'mooringNumber', label: 'Berth', defaultSelected: true },
{ key: 'tenureType', label: 'Tenure type', defaultSelected: true },
{ key: 'startDate', label: 'Start', defaultSelected: true },
{ key: 'endDate', label: 'End', defaultSelected: true },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'createdAt', label: 'Created' },
];
// ─── Metadata registry ───────────────────────────────────────────────────────
export const ENTITY_META: Record<EntityKey, CustomEntityMeta> = {
clients: {
key: 'clients',
label: 'Clients',
description: 'People in your CRM: name, source, contact preferences.',
dateAxis: 'Created',
columns: CLIENTS_COLUMNS,
},
interests: {
key: 'interests',
label: 'Interests / deals',
description: 'Sales pipeline: stage, outcome, value, deposit details.',
dateAxis: 'Created',
columns: INTERESTS_COLUMNS,
},
berths: {
key: 'berths',
label: 'Berths',
description: 'Mooring inventory: dimensions, status, price.',
dateAxis: 'Created',
columns: BERTHS_COLUMNS,
},
tenancies: {
key: 'tenancies',
label: 'Tenancies',
description: 'Berth leases / annual contracts: dates, tenure type, status.',
dateAxis: 'Created',
columns: TENANCIES_COLUMNS,
},
};

View File

@@ -1,5 +1,5 @@
/**
* Custom-report entity registry.
* Custom-report entity registry — server query layer.
*
* The custom builder is the catch-all for slices the four canonical
* reports don't cover — pick an entity, pick columns, optionally
@@ -8,19 +8,27 @@
* from the launch-readiness scope (companies, yachts, invoices,
* payments, deals, sends) layer in as their schemas are wired.
*
* This module is SERVER-ONLY: it pulls in `@/lib/db` + drizzle to run
* the underlying queries. The client-safe metadata (entity keys,
* column allowlists, labels/descriptions, the filter/column type
* contracts) lives in `registry-meta.ts` and is imported here. Client
* components (e.g. the column-picker UI) MUST import from
* `registry-meta.ts`, never this file, or they drag the DB layer into
* the browser bundle.
*
* Each entity defines:
* - `columns`: an allowlist of column keys + human labels + a
* resolver that extracts the value from a fetched row. The
* allowlist matters: it gates which fields a rep can pull into a
* CSV, so PII columns can be opt-in per role later.
* - `columns`: an allowlist of column keys + human labels (sourced
* from `registry-meta.ts`). The allowlist gates which fields a rep
* can pull into a CSV, so PII columns can be opt-in per role later.
* - `runQuery`: a Drizzle select that joins whatever the columns
* need, applies the port filter + optional date range, and
* returns raw rows.
*
* Adding a new entity:
* 1. Append it to ENTITY_KEYS.
* 2. Add a CustomEntityDefinition entry to ENTITY_REGISTRY.
* 3. Update the UI's entity-picker (it reads ENTITY_REGISTRY directly).
* 1. Append it to ENTITY_KEYS (in registry-meta.ts).
* 2. Add its column array + ENTITY_META entry in registry-meta.ts.
* 3. Add the matching `runQuery` + ENTITY_REGISTRY entry here.
* 4. The UI's entity-picker reads ENTITY_META directly.
*/
import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
@@ -32,35 +40,25 @@ import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berthTenancies as tenancies } from '@/lib/db/schema/tenancies';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
export const ENTITY_KEYS = ['clients', 'interests', 'berths', 'tenancies'] as const;
export type EntityKey = (typeof ENTITY_KEYS)[number];
import {
ENTITY_KEYS,
ENTITY_META,
type ColumnDefinition,
type CustomEntityMeta,
type CustomFilter,
type EntityKey,
} from './registry-meta';
export interface CustomFilter {
/** ISO 8601 — inclusive lower bound on the entity's "date" column
* (createdAt or equivalent — see entity definition). */
from?: Date;
/** ISO 8601 — inclusive upper bound. */
to?: Date;
}
// Re-export the client-safe contracts so existing SERVER imports of
// this module keep working unchanged.
export { ENTITY_KEYS };
export type { ColumnDefinition, CustomFilter, EntityKey };
export interface ColumnDefinition {
/** Stable key. Persisted in saved-template configs. */
key: string;
/** Human-readable column header used in CSV/PDF output + the UI
* multi-select. */
label: string;
/** Default selection in the UI. Reps can uncheck. */
defaultSelected?: boolean;
}
export interface CustomEntityDefinition {
key: EntityKey;
label: string;
description: string;
/** Friendly name for the date filter — different entities anchor
* the date range to different timestamps. */
dateAxis: string;
columns: ColumnDefinition[];
/**
* Full server-side entity definition: the client-safe metadata plus
* the server-only `runQuery`.
*/
export interface CustomEntityDefinition extends CustomEntityMeta {
/** Execute the underlying query and return raw rows keyed by column
* key. The runner is responsible for the joins + port scoping;
* callers only pass which columns they want + the filter. */
@@ -82,16 +80,6 @@ function applyDateRange(column: ReturnType<typeof sql<Date>>, filter: CustomFilt
// ─── Clients ─────────────────────────────────────────────────────────────────
const CLIENTS_COLUMNS: ColumnDefinition[] = [
{ key: 'fullName', label: 'Full name', defaultSelected: true },
{ key: 'nationalityIso', label: 'Nationality', defaultSelected: false },
{ key: 'preferredLanguage', label: 'Preferred language' },
{ key: 'preferredContactMethod', label: 'Preferred contact', defaultSelected: false },
{ key: 'source', label: 'Source', defaultSelected: true },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
{ key: 'archivedAt', label: 'Archived at' },
];
async function runClientsQuery({
portId,
filter,
@@ -120,20 +108,6 @@ async function runClientsQuery({
// ─── Interests ───────────────────────────────────────────────────────────────
const INTERESTS_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'primaryBerth', label: 'Primary berth', defaultSelected: true },
{ key: 'pipelineStage', label: 'Stage', defaultSelected: true },
{ key: 'leadCategory', label: 'Lead category' },
{ key: 'outcome', label: 'Outcome', defaultSelected: true },
{ key: 'source', label: 'Source', defaultSelected: false },
{ key: 'depositExpectedAmount', label: 'Deposit expected (amt)', defaultSelected: false },
{ key: 'depositExpectedCurrency', label: 'Deposit expected (ccy)' },
{ key: 'dateFirstContact', label: 'First contact', defaultSelected: false },
{ key: 'dateLastContact', label: 'Last contact', defaultSelected: false },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
];
async function runInterestsQuery({
portId,
filter,
@@ -182,18 +156,6 @@ async function runInterestsQuery({
// ─── Berths ──────────────────────────────────────────────────────────────────
const BERTHS_COLUMNS: ColumnDefinition[] = [
{ key: 'mooringNumber', label: 'Mooring', defaultSelected: true },
{ key: 'area', label: 'Area' },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'length', label: 'Length (m)' },
{ key: 'width', label: 'Width (m)' },
{ key: 'draft', label: 'Draft (m)' },
{ key: 'price', label: 'Price', defaultSelected: true },
{ key: 'priceCurrency', label: 'Currency' },
{ key: 'createdAt', label: 'Created' },
];
async function runBerthsQuery({
portId,
filter,
@@ -224,16 +186,6 @@ async function runBerthsQuery({
// ─── Tenancies ───────────────────────────────────────────────────────────────
const TENANCIES_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'mooringNumber', label: 'Berth', defaultSelected: true },
{ key: 'tenureType', label: 'Tenure type', defaultSelected: true },
{ key: 'startDate', label: 'Start', defaultSelected: true },
{ key: 'endDate', label: 'End', defaultSelected: true },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'createdAt', label: 'Created' },
];
async function runTenanciesQuery({
portId,
filter,
@@ -267,37 +219,21 @@ async function runTenanciesQuery({
// ─── Registry ────────────────────────────────────────────────────────────────
export const ENTITY_REGISTRY: Record<EntityKey, CustomEntityDefinition> = {
clients: {
key: 'clients',
label: 'Clients',
description: 'People in your CRM: name, source, contact preferences.',
dateAxis: 'Created',
columns: CLIENTS_COLUMNS,
runQuery: runClientsQuery,
},
interests: {
key: 'interests',
label: 'Interests / deals',
description: 'Sales pipeline: stage, outcome, value, deposit details.',
dateAxis: 'Created',
columns: INTERESTS_COLUMNS,
runQuery: runInterestsQuery,
},
berths: {
key: 'berths',
label: 'Berths',
description: 'Mooring inventory: dimensions, status, price.',
dateAxis: 'Created',
columns: BERTHS_COLUMNS,
runQuery: runBerthsQuery,
},
tenancies: {
key: 'tenancies',
label: 'Tenancies',
description: 'Berth leases / annual contracts: dates, tenure type, status.',
dateAxis: 'Created',
columns: TENANCIES_COLUMNS,
runQuery: runTenanciesQuery,
},
const RUN_QUERIES: Record<EntityKey, CustomEntityDefinition['runQuery']> = {
clients: runClientsQuery,
interests: runInterestsQuery,
berths: runBerthsQuery,
tenancies: runTenanciesQuery,
};
/**
* Full server registry: client-safe metadata (from `registry-meta.ts`)
* composed with the matching `runQuery` for each entity.
*/
export const ENTITY_REGISTRY: Record<EntityKey, CustomEntityDefinition> = ENTITY_KEYS.reduce(
(acc, key) => {
acc[key] = { ...ENTITY_META[key], runQuery: RUN_QUERIES[key] };
return acc;
},
{} as Record<EntityKey, CustomEntityDefinition>,
);

View File

@@ -146,7 +146,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
/**
* Finds all user IDs on a port whose role grants `interests.view` permission.
*/
async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
export async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
const assignments = await db
.select({
userId: userPortRoles.userId,

View File

@@ -0,0 +1,116 @@
/**
* Notification-recipient resolution for inquiry alerts.
*
* A recipient setting (e.g. `inquiry_notification_recipients`) can be either:
* - the LEGACY shape: a bare `string[]` of email addresses, or
* - the structured shape: `{ emails, userIds, roleIds, everyone }`.
*
* `parseRecipientConfig` normalizes both (legacy array -> explicit emails), so
* no data migration is needed and existing configs keep working.
* `resolveRecipientEmails` expands users / roles / "everyone-with-access" into
* concrete email addresses, merged with any explicit emails and deduped.
*/
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userPortRoles } from '@/lib/db/schema/users';
import { getSetting } from '@/lib/services/settings.service';
import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-notifications.service';
export interface RecipientConfig {
/** Explicit email addresses. */
emails: string[];
/** CRM user ids whose account email should receive the alert. */
userIds: string[];
/** Role ids; every user holding the role on the port is included. */
roleIds: string[];
/** When true, everyone on the port with the inquiry-view permission. */
everyone: boolean;
}
function strArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
: [];
}
/** Normalize a stored recipient setting value into a RecipientConfig. */
export function parseRecipientConfig(value: unknown): RecipientConfig {
// Legacy shape: a bare string[] of email addresses.
if (Array.isArray(value)) {
return { emails: strArray(value), userIds: [], roleIds: [], everyone: false };
}
if (value && typeof value === 'object') {
const o = value as Record<string, unknown>;
return {
emails: strArray(o.emails),
userIds: strArray(o.userIds),
roleIds: strArray(o.roleIds),
everyone: o.everyone === true,
};
}
return { emails: [], userIds: [], roleIds: [], everyone: false };
}
/** Expand a RecipientConfig into a deduped list of email addresses. */
export async function resolveRecipientEmails(
portId: string,
config: RecipientConfig,
): Promise<string[]> {
const userIdSet = new Set<string>(config.userIds);
if (config.everyone) {
for (const id of await findUsersWithInterestsPermission(portId)) userIdSet.add(id);
}
if (config.roleIds.length > 0) {
const rows = await db
.select({ userId: userPortRoles.userId })
.from(userPortRoles)
.where(and(eq(userPortRoles.portId, portId), inArray(userPortRoles.roleId, config.roleIds)));
for (const r of rows) userIdSet.add(r.userId);
}
const collected: string[] = [...config.emails];
if (userIdSet.size > 0) {
const rows = await db
.select({ email: user.email })
.from(user)
.where(inArray(user.id, Array.from(userIdSet)));
for (const r of rows) if (r.email) collected.push(r.email);
}
// Case-insensitive dedupe, preserving the first-seen form.
const seen = new Set<string>();
const result: string[] = [];
for (const raw of collected) {
const email = raw.trim();
const key = email.toLowerCase();
if (email && !seen.has(key)) {
seen.add(key);
result.push(email);
}
}
return result;
}
/**
* Load + resolve a recipient setting to concrete email addresses. Falls back to
* `fallbackKey` (a single-string setting, default `inquiry_contact_email`) when
* the primary resolves to nothing. Pass an empty `fallbackKey` to disable the
* fallback.
*/
export async function resolveNotificationRecipients(
portId: string,
primaryKey: string,
fallbackKey = 'inquiry_contact_email',
): Promise<string[]> {
const primary = await getSetting(primaryKey, portId);
const resolved = await resolveRecipientEmails(portId, parseRecipientConfig(primary?.value));
if (resolved.length > 0) return resolved;
if (!fallbackKey) return [];
const fallback = await getSetting(fallbackKey, portId);
return typeof fallback?.value === 'string' && fallback.value.length > 0 ? [fallback.value] : [];
}

View File

@@ -0,0 +1,280 @@
/**
* CRM-owned emails for captured website inquiries.
*
* The marketing website dual-writes every inquiry into `website_submissions`
* (capture-only). At cutover, email ownership moves from the website to the
* CRM: when the per-port flag `website_intake_email_enabled` is ON, the CRM
* sends the registrant confirmation + staff alert for each fresh submission,
* reusing the existing branded inquiry templates. Default OFF, so the website
* keeps sending until the flip and we never double-send.
*
* Sends are inline + fire-and-forget (the caller wraps in `void ...catch`):
* a send failure must never 500 the public capture endpoint. Dedup is handled
* upstream by invoking this only on a fresh (non-redelivered) insert.
*/
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { sendEmail } from '@/lib/email';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
import { inquirySalesNotification } from '@/lib/email/templates/inquiry-sales-notification';
import {
residentialClientConfirmation,
residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry';
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
import { createNotification } from '@/lib/services/notifications.service';
import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-notifications.service';
import { logger } from '@/lib/logger';
/**
* Per-port gate. Default OFF (no row -> disabled), matching the
* `invoices_module_enabled` pattern.
*/
export async function isWebsiteIntakeEmailEnabled(portId: string): Promise<boolean> {
const row = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'website_intake_email_enabled'),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
return row[0]?.value === true;
}
/**
* Resolve staff-alert recipients for a port. Delegates to the shared resolver,
* which expands the structured {emails,userIds,roleIds,everyone} config (or a
* legacy email array) into concrete addresses, falling back to
* `inquiry_contact_email`. Returns [] when nothing is configured.
*/
async function resolveRecipients(portId: string, primaryKey: string): Promise<string[]> {
return resolveNotificationRecipients(portId, primaryKey);
}
export interface WebsiteSubmissionEmailInput {
portId: string;
portSlug: string;
kind: string;
submissionId: string;
payload: Record<string, unknown>;
}
export async function sendWebsiteSubmissionEmails(
input: WebsiteSubmissionEmailInput,
): Promise<void> {
const { portId, portSlug, kind, payload } = input;
const fields = extractInquiryFields(payload);
const [branding, portBrand, emailCfg] = await Promise.all([
getBrandingShell(portId),
getPortBrandingConfig(portId).catch(() => null),
getPortEmailConfig(portId).catch(() => null),
]);
const portName = portBrand?.appName ?? 'Port Nimara';
const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com';
// No interest/client row exists for a raw submission, so link to the
// dashboard rather than a (nonexistent) entity detail page.
const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`;
if (kind === 'berth_inquiry') {
if (fields.email) {
const confirmation = await inquiryClientConfirmation(
{
firstName: fields.firstName,
mooringNumber: fields.mooringNumber,
contactEmail,
portName,
},
{ branding },
);
const subject = await resolveSubject({
key: 'inquiry_client_confirmation',
portId,
fallback: confirmation.subject,
tokens: {
portName,
recipientName: fields.firstName,
mooringNumber: fields.mooringNumber ?? '',
},
});
await sendEmail(
fields.email,
subject,
confirmation.html,
undefined,
confirmation.text,
portId,
);
}
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients');
if (recipients.length > 0) {
const alert = await inquirySalesNotification(
{
fullName: fields.fullName,
email: fields.email,
phone: fields.phone,
mooringNumber: fields.mooringNumber,
crmUrl,
portName,
},
{ branding },
);
const subject = await resolveSubject({
key: 'inquiry_sales_notification',
portId,
fallback: alert.subject,
tokens: {
portName,
clientName: fields.fullName,
mooringNumber: fields.mooringNumber ?? '',
email: fields.email,
},
});
await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId);
}
return;
}
if (kind === 'residence_inquiry') {
if (fields.email) {
const confirmation = await residentialClientConfirmation(
{ firstName: fields.firstName, contactEmail, portName },
{ branding },
);
const subject = await resolveSubject({
key: 'residential_inquiry_client_confirmation',
portId,
fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName },
});
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
}
const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
if (recipients.length > 0) {
const alert = await residentialSalesAlert(
{
fullName: fields.fullName,
email: fields.email,
phone: fields.phone,
placeOfResidence: fields.placeOfResidence ?? undefined,
notes: fields.comments ?? undefined,
crmDeepLink: crmUrl,
portName,
},
{ branding },
);
const subject = await resolveSubject({
key: 'residential_inquiry_sales_alert',
portId,
fallback: alert.subject,
tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone },
});
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId);
}
return;
}
if (kind === 'contact_form') {
// Client confirmation: a "thanks, we received your message" auto-reply.
// This is CRM-only (the website never sent one), so there is no
// double-send risk; it simply starts once the port flips the flag on.
if (fields.email) {
const confirmation = await contactFormClientConfirmation(
{ firstName: fields.firstName, contactEmail, portName },
{ branding },
);
const subject = await resolveSubject({
key: 'contact_form_client_confirmation',
portId,
fallback: confirmation.subject,
tokens: { portName, recipientName: fields.firstName },
});
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
}
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients');
if (recipients.length > 0) {
const alert = await contactFormSalesAlert(
{
fullName: fields.fullName,
email: fields.email,
interestType: fields.interestType,
comments: fields.comments,
crmDeepLink: crmUrl,
portName,
},
{ branding },
);
const subject = await resolveSubject({
key: 'contact_form_sales_alert',
portId,
fallback: alert.subject,
tokens: { portName, clientName: fields.fullName, email: fields.email },
});
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId);
}
return;
}
logger.warn({ kind }, 'website-intake email: unknown submission kind, no email sent');
}
const KIND_LABEL: Record<string, string> = {
berth_inquiry: 'berth inquiry',
residence_inquiry: 'residential inquiry',
contact_form: 'contact form',
};
/**
* In-app (bell) notifications for a captured website submission. Fires on
* every fresh capture, independent of the email-ownership flag, so reps see
* incoming website inquiries in the CRM inbox even before email cutover.
* Fire-and-forget; deduped per submission.
*/
export async function notifyWebsiteSubmissionInApp(input: {
portId: string;
portSlug: string;
kind: string;
submissionId: string;
payload: Record<string, unknown>;
}): Promise<void> {
const { portId, portSlug, kind, submissionId, payload } = input;
const userIds = await findUsersWithInterestsPermission(portId);
if (userIds.length === 0) return;
const fields = extractInquiryFields(payload);
const who = fields.fullName || 'A visitor';
const label = KIND_LABEL[kind] ?? 'inquiry';
const description = `${who} submitted a ${label} via the website`;
const link = `/${portSlug}/inbox`;
await Promise.allSettled(
userIds.map((userId) =>
createNotification({
portId,
userId,
type: 'new_registration',
title: 'New website inquiry',
description,
link,
entityType: 'website_submission',
entityId: submissionId,
dedupeKey: `website-submission-${submissionId}`,
}),
),
);
}

View File

@@ -0,0 +1,58 @@
/**
* Pure mapping from the marketing website's raw inquiry payload into the
* fields the CRM email templates need.
*
* The website dual-writes each form submission's body verbatim into
* `website_submissions.payload` (snake_case keys). There is no `clients` /
* `interests` row for a raw submission, so the email path reads straight from
* the payload. Kept pure + dependency-free so it is trivially unit-testable
* and defensive: any missing or non-string field degrades to '' or null
* rather than throwing.
*/
export interface InquiryFields {
firstName: string;
lastName: string;
fullName: string;
email: string;
phone: string;
/** From the berth form's `berth` field (the mooring number). */
mooringNumber: string | null;
/** From the residence form's `address` field. */
placeOfResidence: string | null;
/** From the contact form's free-text `comments` field. */
comments: string | null;
/** The contact form's `interest` (string or string[]) joined for display. */
interestType: string | null;
}
function str(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
export function extractInquiryFields(payload: Record<string, unknown>): InquiryFields {
const firstName = str(payload.first_name);
const lastName = str(payload.last_name);
const email = str(payload.email);
const phone = str(payload.phone);
const mooringNumber = str(payload.berth) || null;
const placeOfResidence = str(payload.address) || null;
const comments = str(payload.comments) || null;
const rawInterest = payload.interest;
const interestType = Array.isArray(rawInterest)
? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null
: str(rawInterest) || null;
return {
firstName,
lastName,
fullName: `${firstName} ${lastName}`.trim(),
email,
phone,
mooringNumber,
placeOfResidence,
comments,
interestType,
};
}

View File

@@ -662,6 +662,21 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: false,
},
// Operations - Website intake emails. Port-scoped gate for CRM-owned
// website-inquiry emails. OFF by default so the marketing website keeps
// sending its own confirmation + staff-alert emails; flip ON at cutover
// (and turn the website's own sending off) so the CRM is the single owner.
{
key: 'website_intake_email_enabled',
section: 'operations.intake',
label: 'CRM-owned website inquiry emails',
description:
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).',
type: 'boolean',
scope: 'port',
defaultValue: false,
},
// ─── Operations - Residential module ──────────────────────────────────────
// Port-scoped gate for the entire Residential surface (sidebar
// "Residential" section, /residential/clients + /residential/interests

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { parseRecipientConfig } from '@/lib/services/notification-recipients';
describe('parseRecipientConfig', () => {
it('treats a legacy string[] as explicit emails (backward-compat)', () => {
expect(parseRecipientConfig(['a@x.com', 'b@x.com'])).toEqual({
emails: ['a@x.com', 'b@x.com'],
userIds: [],
roleIds: [],
everyone: false,
});
});
it('reads the structured object shape', () => {
expect(
parseRecipientConfig({
emails: ['a@x.com'],
userIds: ['u1', 'u2'],
roleIds: ['r1'],
everyone: true,
}),
).toEqual({
emails: ['a@x.com'],
userIds: ['u1', 'u2'],
roleIds: ['r1'],
everyone: true,
});
});
it('filters non-string / empty entries and coerces everyone defensively', () => {
expect(
parseRecipientConfig({
emails: ['a@x.com', 2, '', null],
userIds: 'nope',
everyone: 'yes',
}),
).toEqual({ emails: ['a@x.com'], userIds: [], roleIds: [], everyone: false });
});
it('returns empty for null / garbage', () => {
const empty = { emails: [], userIds: [], roleIds: [], everyone: false };
expect(parseRecipientConfig(null)).toEqual(empty);
expect(parseRecipientConfig('nope')).toEqual(empty);
expect(parseRecipientConfig(42)).toEqual(empty);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
describe('extractInquiryFields', () => {
it('maps a berth inquiry payload (berth -> mooringNumber)', () => {
const f = extractInquiryFields({
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@example.com',
phone: '+15551234',
berth: 'A1',
interest: 'berths',
});
expect(f).toMatchObject({
firstName: 'Jane',
lastName: 'Doe',
fullName: 'Jane Doe',
email: 'jane@example.com',
phone: '+15551234',
mooringNumber: 'A1',
placeOfResidence: null,
});
});
it('maps a residence inquiry payload (address -> placeOfResidence, no mooring)', () => {
const f = extractInquiryFields({
first_name: 'Sam',
last_name: 'Lee',
email: 's@example.com',
phone: '2',
address: 'London',
interest: 'residences',
});
expect(f.mooringNumber).toBeNull();
expect(f.placeOfResidence).toBe('London');
expect(f.fullName).toBe('Sam Lee');
});
it('maps a contact form payload (interest[] -> joined interestType + comments)', () => {
const f = extractInquiryFields({
first_name: 'Ann',
last_name: 'Poe',
email: 'a@example.com',
interest: ['owner', 'broker'],
comments: 'Please call me',
});
expect(f.interestType).toBe('owner, broker');
expect(f.comments).toBe('Please call me');
expect(f.phone).toBe('');
});
it('trims whitespace and degrades missing/garbage fields safely', () => {
const f = extractInquiryFields({ first_name: ' Jo ', last_name: 42 as unknown });
expect(f.firstName).toBe('Jo');
expect(f.fullName).toBe('Jo');
expect(f.email).toBe('');
expect(f.mooringNumber).toBeNull();
expect(f.interestType).toBeNull();
});
it('returns all-empty for an empty payload', () => {
expect(extractInquiryFields({})).toMatchObject({
firstName: '',
lastName: '',
fullName: '',
email: '',
phone: '',
mooringNumber: null,
placeOfResidence: null,
comments: null,
interestType: null,
});
});
});