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)
|
||||
- [`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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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';
|
||||
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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
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.
|
||||
*/
|
||||
async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
|
||||
export async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
|
||||
const assignments = await db
|
||||
.select({
|
||||
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,
|
||||
},
|
||||
|
||||
// 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
|
||||
|
||||
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