Compare commits
5 Commits
f699533224
...
2a4dadd5a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a4dadd5a7 | |||
| 44b004fa8f | |||
| 5ea0c75fff | |||
| 0416dc8d39 | |||
| 990b566eff |
@@ -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 0–8 shipped)
|
- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 0–8 shipped)
|
||||||
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
|
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
|
||||||
- [`website-refactor.md`](./website-refactor.md) — public website cutover plan
|
- [`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.
|
||||||
|
|||||||
@@ -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
|
1. Create `/etc/nginx/sites-available/crm_portnimara.conf` modelled on
|
||||||
`portnimara_dev.conf`: port-80 → 443 redirect + `.well-known/acme-challenge`
|
`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
|
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`,
|
`Upgrade`/`Connection` for socket.io), `client_max_body_size 64M`,
|
||||||
`proxy_read_timeout 300`, buffering off. **HTTP-only first** (no `ssl\__`
|
`proxy_read_timeout 300`, buffering off. **HTTP-only first** (no `ssl\__`
|
||||||
lines yet) so Certbot can complete the challenge.
|
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
|
removed; restored clone gone, off-box dump retained). Compose file kept
|
||||||
at `private/documenso-dryrun/docker-compose.yml` for a re-run. Prod
|
at `private/documenso-dryrun/docker-compose.yml` for a re-run. Prod
|
||||||
still untouched.
|
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.
|
||||||
|
|||||||
@@ -644,3 +644,75 @@ unproven full builds onto a same-day prod launch.
|
|||||||
- **Marketing + Financial reports** — remain unbuilt + now hidden; gated
|
- **Marketing + Financial reports** — remain unbuilt + now hidden; gated
|
||||||
on Init 1b (website UTM/inquiry cutover) and Init 1c (invoices-module
|
on Init 1b (website UTM/inquiry cutover) and Init 1c (invoices-module
|
||||||
decision) respectively.
|
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.
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
} from '@/lib/errors';
|
} from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||||
|
import {
|
||||||
|
isWebsiteIntakeEmailEnabled,
|
||||||
|
notifyWebsiteSubmissionInApp,
|
||||||
|
sendWebsiteSubmissionEmails,
|
||||||
|
} from '@/lib/services/website-intake-email.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/public/website-inquiries
|
* POST /api/public/website-inquiries
|
||||||
@@ -169,6 +174,40 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
'website inquiry captured',
|
'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
|
// L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the
|
||||||
// `{ data }` envelope). This is the public website's intake contract —
|
// `{ data }` envelope). This is the public website's intake contract —
|
||||||
// the external marketing site reads `id`/`deduped` off the JSON root.
|
// the external marketing site reads `id`/`deduped` off the JSON root.
|
||||||
|
|||||||
242
src/components/admin/settings/recipient-picker.tsx
Normal file
242
src/components/admin/settings/recipient-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { RecipientPicker } from './recipient-picker';
|
||||||
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
@@ -35,7 +36,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select';
|
type: 'boolean' | 'number' | 'json' | 'string' | 'select' | 'recipients';
|
||||||
defaultValue: unknown;
|
defaultValue: unknown;
|
||||||
options?: Array<{ value: string; label: string }>;
|
options?: Array<{ value: string; label: string }>;
|
||||||
}> = [
|
}> = [
|
||||||
@@ -132,18 +133,18 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'inquiry_notification_recipients',
|
key: 'inquiry_notification_recipients',
|
||||||
label: 'External Notification Recipients',
|
label: 'Berth & contact inquiry alerts',
|
||||||
description:
|
description:
|
||||||
'Additional email addresses that receive sales notifications for new interests (JSON array)',
|
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||||
type: 'json',
|
type: 'recipients',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'residential_notification_recipients',
|
key: 'residential_notification_recipients',
|
||||||
label: 'Residential Notification Recipients',
|
label: 'Residential inquiry alerts',
|
||||||
description:
|
description:
|
||||||
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
|
'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: 'json',
|
type: 'recipients',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -451,6 +452,32 @@ export function SettingsManager() {
|
|||||||
</Card>
|
</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 */}
|
{/* Numeric Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const TEMPLATE_KEYS = [
|
|||||||
'inquiry_sales_notification',
|
'inquiry_sales_notification',
|
||||||
'residential_inquiry_client_confirmation',
|
'residential_inquiry_client_confirmation',
|
||||||
'residential_inquiry_sales_alert',
|
'residential_inquiry_sales_alert',
|
||||||
|
'contact_form_sales_alert',
|
||||||
|
'contact_form_client_confirmation',
|
||||||
// M-EM04: daily notification digest. The digest service previously
|
// M-EM04: daily notification digest. The digest service previously
|
||||||
// resolved its subject via `'crm_invite' as any` because no entry
|
// resolved its subject via `'crm_invite' as any` because no entry
|
||||||
// existed; making it a first-class key removes the cast and lets
|
// 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'],
|
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
|
||||||
defaultSubject: 'New residential inquiry - {{clientName}}',
|
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: {
|
notification_digest: {
|
||||||
key: 'notification_digest',
|
key: 'notification_digest',
|
||||||
label: 'Notification digest',
|
label: 'Notification digest',
|
||||||
|
|||||||
104
src/lib/email/templates/contact-form-alert.tsx
Normal file
104
src/lib/email/templates/contact-form-alert.tsx
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal file
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
|
|||||||
/**
|
/**
|
||||||
* Finds all user IDs on a port whose role grants `interests.view` permission.
|
* 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
|
const assignments = await db
|
||||||
.select({
|
.select({
|
||||||
userId: userPortRoles.userId,
|
userId: userPortRoles.userId,
|
||||||
|
|||||||
116
src/lib/services/notification-recipients.ts
Normal file
116
src/lib/services/notification-recipients.ts
Normal 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] : [];
|
||||||
|
}
|
||||||
280
src/lib/services/website-intake-email.service.ts
Normal file
280
src/lib/services/website-intake-email.service.ts
Normal 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}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/lib/services/website-intake-fields.ts
Normal file
58
src/lib/services/website-intake-fields.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -662,6 +662,21 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
defaultValue: false,
|
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 ──────────────────────────────────────
|
// ─── Operations - Residential module ──────────────────────────────────────
|
||||||
// Port-scoped gate for the entire Residential surface (sidebar
|
// Port-scoped gate for the entire Residential surface (sidebar
|
||||||
// "Residential" section, /residential/clients + /residential/interests
|
// "Residential" section, /residential/clients + /residential/interests
|
||||||
|
|||||||
47
tests/unit/notification-recipients.test.ts
Normal file
47
tests/unit/notification-recipients.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
tests/unit/website-intake-fields.test.ts
Normal file
75
tests/unit/website-intake-fields.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user