Compare commits
4 Commits
b703684285
...
9ad1df85d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 |
123
docs/operations/outbound-comms-safety.md
Normal file
123
docs/operations/outbound-comms-safety.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Outbound communications safety net
|
||||||
|
|
||||||
|
**Last reviewed:** 2026-05-03
|
||||||
|
**Owner:** matt@portnimara.com
|
||||||
|
|
||||||
|
This doc enumerates every channel through which the CRM can produce
|
||||||
|
outbound communication (email, document signing, webhooks) and describes
|
||||||
|
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
|
||||||
|
single environment flip pauses **all** outbound traffic, so a production
|
||||||
|
data import, dedup migration dry-run, or staging environment can run
|
||||||
|
against real data without anyone getting paged or spammed.
|
||||||
|
|
||||||
|
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
|
||||||
|
> all outbound communication is rerouted there or short-circuited. Unset
|
||||||
|
> it in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
### 1. Direct email (`sendEmail`)
|
||||||
|
|
||||||
|
**Path:** `src/lib/email/index.ts` → `sendEmail()` → nodemailer SMTP transport.
|
||||||
|
|
||||||
|
**Safety:** YES — covered.
|
||||||
|
|
||||||
|
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
|
||||||
|
to the redirect address and prefixes the subject with
|
||||||
|
`[redirected from <orig>]`. The original recipient is logged.
|
||||||
|
|
||||||
|
**Call sites** (all flow through `sendEmail`, so all are covered):
|
||||||
|
|
||||||
|
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
|
||||||
|
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
|
||||||
|
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
|
||||||
|
as attachments (the PDF body is generated locally; the email itself
|
||||||
|
goes through SMTP)
|
||||||
|
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
|
||||||
|
in the in-app UI
|
||||||
|
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
|
||||||
|
|
||||||
|
### 2. Documenso e-signature recipients
|
||||||
|
|
||||||
|
**Path:** `src/lib/services/documenso-client.ts` → `createDocument()` /
|
||||||
|
`generateDocumentFromTemplate()` → Documenso REST API.
|
||||||
|
|
||||||
|
**Safety:** YES — covered as of 2026-05-03.
|
||||||
|
|
||||||
|
Documenso's own server sends the signing-request email on our behalf.
|
||||||
|
We can't intercept that at the SMTP layer because it's external. The
|
||||||
|
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
|
||||||
|
`createDocument` rewrites every recipient's email to the redirect
|
||||||
|
address and prefixes the recipient name with `(was: <orig email>)` so
|
||||||
|
the doc is still traceable to its intended recipient.
|
||||||
|
`generateDocumentFromTemplate` does the same for both shapes the
|
||||||
|
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
|
||||||
|
v2.x `recipients` array).
|
||||||
|
|
||||||
|
The redirect happens **before** the API call, so even if Documenso has
|
||||||
|
its own retry logic the original email never leaves our process.
|
||||||
|
|
||||||
|
### 3. Webhooks (outbound to user-configured URLs)
|
||||||
|
|
||||||
|
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
|
||||||
|
|
||||||
|
**Safety:** YES — covered as of 2026-05-03.
|
||||||
|
|
||||||
|
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
|
||||||
|
before the HTTP call. The delivery row is marked `dead_letter` with a
|
||||||
|
human-readable reason so it's still visible in the deliveries listing.
|
||||||
|
The SSRF guard remains in place independently.
|
||||||
|
|
||||||
|
### 4. WhatsApp / phone deep-links
|
||||||
|
|
||||||
|
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
|
||||||
|
client / interest detail headers.
|
||||||
|
|
||||||
|
**Safety:** N/A — user-initiated only.
|
||||||
|
|
||||||
|
These are deep links the user explicitly clicks. No automated dispatch.
|
||||||
|
A deep link click opens the user's WhatsApp / phone app, which is the
|
||||||
|
intended interaction. No safety net needed.
|
||||||
|
|
||||||
|
### 5. SMS
|
||||||
|
|
||||||
|
Not implemented. The `interests.preferredContactMethod` enum includes
|
||||||
|
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
|
||||||
|
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
|
||||||
|
the same way `sendEmail` does — log the original number, drop the
|
||||||
|
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification checklist before importing real data
|
||||||
|
|
||||||
|
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
|
||||||
|
- [ ] Restart dev server (or worker) so the new env is picked up — env
|
||||||
|
vars are read at import time in some paths.
|
||||||
|
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
|
||||||
|
or similar. Confirm subject is prefixed with `[redirected from ...]`.
|
||||||
|
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
|
||||||
|
shows the redirect address as recipient (not the real client email).
|
||||||
|
- [ ] If any webhooks are configured, trigger an event that fires one and
|
||||||
|
confirm the delivery is recorded as `dead_letter` with the
|
||||||
|
"EMAIL_REDIRECT_TO is set" reason.
|
||||||
|
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
|
||||||
|
`--apply` step is what creates real records but emails/webhooks are
|
||||||
|
still gated by the redirect env.
|
||||||
|
|
||||||
|
## Production cutover
|
||||||
|
|
||||||
|
When ready to go live:
|
||||||
|
|
||||||
|
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
|
||||||
|
to a sandbox address.
|
||||||
|
2. Verify the snapshot looks right (counts, client coverage).
|
||||||
|
3. Unset `EMAIL_REDIRECT_TO` in the production env.
|
||||||
|
4. Restart the app + worker.
|
||||||
|
5. Run the migration with `--apply`. From this point forward, real
|
||||||
|
recipients will receive real comms.
|
||||||
|
|
||||||
|
If you ever need to re-pause outbound (e.g. handling a security incident,
|
||||||
|
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.
|
||||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||||
|
* contacts where it's null or empty.
|
||||||
|
*
|
||||||
|
* The legacy seed (and pre-normalization production data) stored phone
|
||||||
|
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||||
|
* is what every UI surface and dedup matcher reads. This script runs the
|
||||||
|
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||||
|
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||||
|
* form back.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||||
|
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||||
|
*
|
||||||
|
* The dry-run report prints, for each unparseable row, the contact id +
|
||||||
|
* raw value so you can hand-clean before re-running.
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clientContacts } from '@/lib/db/schema/clients';
|
||||||
|
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||||
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
|
||||||
|
interface PhoneRow {
|
||||||
|
id: string;
|
||||||
|
channel: string;
|
||||||
|
value: string | null;
|
||||||
|
valueCountry: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||||
|
// `value_e164` null/empty.
|
||||||
|
const rows: PhoneRow[] = await db
|
||||||
|
.select({
|
||||||
|
id: clientContacts.id,
|
||||||
|
channel: clientContacts.channel,
|
||||||
|
value: clientContacts.value,
|
||||||
|
valueCountry: clientContacts.valueCountry,
|
||||||
|
})
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||||
|
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||||
|
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` found ${rows.length} candidate rows`);
|
||||||
|
|
||||||
|
let parsedFull = 0;
|
||||||
|
let parsedE164Only = 0;
|
||||||
|
let unparseable = 0;
|
||||||
|
const updates: Array<{
|
||||||
|
id: string;
|
||||||
|
valueE164: string;
|
||||||
|
valueCountry: CountryCode | null;
|
||||||
|
}> = [];
|
||||||
|
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.value) continue;
|
||||||
|
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||||
|
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||||
|
|
||||||
|
if (parsed1.e164 && parsed1.country) {
|
||||||
|
// Both e164 + country resolved — best case.
|
||||||
|
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||||
|
parsedFull++;
|
||||||
|
} else if (parsed1.e164) {
|
||||||
|
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||||
|
// fictional/reserved range — libphonenumber returns the e164 form
|
||||||
|
// but refuses to assign a country). Still safe to write — the e164
|
||||||
|
// is canonical. Country stays null.
|
||||||
|
updates.push({
|
||||||
|
id: row.id,
|
||||||
|
valueE164: parsed1.e164,
|
||||||
|
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||||
|
});
|
||||||
|
parsedE164Only++;
|
||||||
|
} else {
|
||||||
|
fails.push({
|
||||||
|
id: row.id,
|
||||||
|
value: row.value,
|
||||||
|
reason: row.value.trim().startsWith('+')
|
||||||
|
? 'has + prefix but parse failed'
|
||||||
|
: 'no leading + and no country hint',
|
||||||
|
});
|
||||||
|
unparseable++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||||
|
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||||
|
console.log(' ✗ unparseable ', unparseable);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (fails.length > 0) {
|
||||||
|
console.log('Failures (first 10):');
|
||||||
|
for (const f of fails.slice(0, 10)) {
|
||||||
|
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
console.log('No updates to write.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Writing ${updates.length} updates...`);
|
||||||
|
|
||||||
|
for (const u of updates) {
|
||||||
|
await db
|
||||||
|
.update(clientContacts)
|
||||||
|
.set({
|
||||||
|
valueE164: u.valueE164,
|
||||||
|
valueCountry: u.valueCountry,
|
||||||
|
})
|
||||||
|
.where(eq(clientContacts.id, u.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ wrote ${updates.length} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -241,7 +241,14 @@ export function SettingsManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
||||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
<div
|
||||||
|
key={setting.key}
|
||||||
|
// Stack label/description above the input on phone widths.
|
||||||
|
// The previous flex row crushed the label column into a
|
||||||
|
// narrow vertical stripe ("Inquiry / Contact / Email" wrapping
|
||||||
|
// one word per line) while the input took the rest.
|
||||||
|
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label>{setting.label}</Label>
|
<Label>{setting.label}</Label>
|
||||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
@@ -249,7 +256,7 @@ export function SettingsManager() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-64"
|
className="w-full sm:w-64"
|
||||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setValues((prev) => ({
|
setValues((prev) => ({
|
||||||
@@ -283,7 +290,10 @@ export function SettingsManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
||||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
<div
|
||||||
|
key={setting.key}
|
||||||
|
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label>{setting.label}</Label>
|
<Label>{setting.label}</Label>
|
||||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
@@ -291,7 +301,7 @@ export function SettingsManager() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="w-24"
|
className="w-full sm:w-24"
|
||||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setValues((prev) => ({
|
setValues((prev) => ({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
|||||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
interface ResidentialClientRow {
|
interface ResidentialClientRow {
|
||||||
@@ -85,7 +86,9 @@ export function ResidentialClientsList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card overflow-hidden">
|
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
|
||||||
|
off the viewport at phone widths. */}
|
||||||
|
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -137,6 +140,51 @@ export function ResidentialClientsList() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: card list. Each card mirrors the table row data with
|
||||||
|
name + status pill on top, then meta line(s) below. */}
|
||||||
|
<div className="lg:hidden space-y-2">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && data?.data.length === 0 && (
|
||||||
|
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No residential clients yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.data.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/residential/clients/${c.id}` as any}
|
||||||
|
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium text-sm truncate">{c.fullName}</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||||
|
c.status === 'active'
|
||||||
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
|
: c.status === 'inactive'
|
||||||
|
? 'bg-muted text-muted-foreground'
|
||||||
|
: 'bg-blue-100 text-blue-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[c.status] ?? c.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{c.email ? <span className="truncate">{c.email}</span> : null}
|
||||||
|
{c.phone ? <span>{c.phone}</span> : null}
|
||||||
|
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
|
||||||
|
{c.source ? <span className="capitalize">· {c.source}</span> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export function ResidentialInterestsList() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card overflow-hidden">
|
{/* Desktop: table layout. Hidden below lg; mobile renders cards. */}
|
||||||
|
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -149,6 +150,47 @@ export function ResidentialInterestsList() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: card list. Stage as the headline (it's the most actionable
|
||||||
|
field for triage), preferences/notes truncated below. */}
|
||||||
|
<div className="lg:hidden space-y-2">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && data?.data.length === 0 && (
|
||||||
|
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No interests match.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.data.map((i) => (
|
||||||
|
<Link
|
||||||
|
key={i.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||||
|
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{new Date(i.updatedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i.preferences ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{i.preferences}</p>
|
||||||
|
) : null}
|
||||||
|
{i.notes ? (
|
||||||
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground/80">{i.notes}</p>
|
||||||
|
) : null}
|
||||||
|
{i.source ? (
|
||||||
|
<p className="mt-1 text-[11px] capitalize text-muted-foreground">{i.source}</p>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,28 @@ export const webhooksWorker = new Worker(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety net: when EMAIL_REDIRECT_TO is set (dev / staging / migration
|
||||||
|
// dry-run), short-circuit webhook delivery so we don't accidentally
|
||||||
|
// ping a user-configured production endpoint with synthetic events.
|
||||||
|
// Records the delivery as `dead_letter` with a clear reason so the
|
||||||
|
// attempt is still visible in the deliveries listing.
|
||||||
|
if (process.env.EMAIL_REDIRECT_TO) {
|
||||||
|
logger.info(
|
||||||
|
{ webhookId, deliveryId, url: webhook.url },
|
||||||
|
'Webhook delivery skipped (EMAIL_REDIRECT_TO is set — outbound comms are paused)',
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(webhookDeliveries)
|
||||||
|
.set({
|
||||||
|
status: 'dead_letter',
|
||||||
|
responseStatus: null,
|
||||||
|
responseBody: 'Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused.',
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(webhookDeliveries.id, deliveryId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Decrypt secret
|
// 2. Decrypt secret
|
||||||
let secret: string;
|
let secret: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -87,17 +87,72 @@ export interface DocumensoDocument {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
|
||||||
|
* email so Documenso doesn't accidentally email real clients during a
|
||||||
|
* data import / migration dry-run. Names are prefixed with the original
|
||||||
|
* email so the recipient (you) can tell who would have received the doc.
|
||||||
|
*
|
||||||
|
* In production this env var is unset and recipients flow through unchanged.
|
||||||
|
*/
|
||||||
|
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
|
||||||
|
if (!env.EMAIL_REDIRECT_TO) return recipients;
|
||||||
|
return recipients.map((r) => ({
|
||||||
|
...r,
|
||||||
|
name: `${r.name} (was: ${r.email})`,
|
||||||
|
email: env.EMAIL_REDIRECT_TO!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same idea for the template-generate endpoint, which takes a payload
|
||||||
|
* shape with recipient email/name nested inside `formValues` (Documenso
|
||||||
|
* v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
|
||||||
|
*/
|
||||||
|
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!env.EMAIL_REDIRECT_TO) return payload;
|
||||||
|
const out: Record<string, unknown> = { ...payload };
|
||||||
|
// 2.x recipient shape
|
||||||
|
if (Array.isArray(out.recipients)) {
|
||||||
|
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
...r,
|
||||||
|
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
|
||||||
|
email: env.EMAIL_REDIRECT_TO,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// v1.13 formValues shape — keys vary per template; key by anything that
|
||||||
|
// looks like an email field. The conservative approach: only touch keys
|
||||||
|
// that already hold a string and end with `Email` / `email`.
|
||||||
|
if (out.formValues && typeof out.formValues === 'object') {
|
||||||
|
const fv = { ...(out.formValues as Record<string, unknown>) };
|
||||||
|
for (const key of Object.keys(fv)) {
|
||||||
|
if (/email$/i.test(key) && typeof fv[key] === 'string') {
|
||||||
|
fv[key] = env.EMAIL_REDIRECT_TO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.formValues = fv;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDocument(
|
export async function createDocument(
|
||||||
title: string,
|
title: string,
|
||||||
pdfBase64: string,
|
pdfBase64: string,
|
||||||
recipients: DocumensoRecipient[],
|
recipients: DocumensoRecipient[],
|
||||||
portId?: string,
|
portId?: string,
|
||||||
): Promise<DocumensoDocument> {
|
): Promise<DocumensoDocument> {
|
||||||
|
const safeRecipients = applyRecipientRedirect(recipients);
|
||||||
|
if (env.EMAIL_REDIRECT_TO) {
|
||||||
|
logger.info(
|
||||||
|
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
|
||||||
|
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
|
||||||
|
);
|
||||||
|
}
|
||||||
return documensoFetch(
|
return documensoFetch(
|
||||||
'/api/v1/documents',
|
'/api/v1/documents',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ title, document: pdfBase64, recipients }),
|
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
|
||||||
},
|
},
|
||||||
portId,
|
portId,
|
||||||
).then(normalizeDocument);
|
).then(normalizeDocument);
|
||||||
@@ -108,11 +163,18 @@ export async function generateDocumentFromTemplate(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
portId?: string,
|
portId?: string,
|
||||||
): Promise<DocumensoDocument> {
|
): Promise<DocumensoDocument> {
|
||||||
|
const safePayload = applyPayloadRedirect(payload);
|
||||||
|
if (env.EMAIL_REDIRECT_TO) {
|
||||||
|
logger.info(
|
||||||
|
{ templateId },
|
||||||
|
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
|
||||||
|
);
|
||||||
|
}
|
||||||
return documensoFetch(
|
return documensoFetch(
|
||||||
`/api/v1/templates/${templateId}/generate-document`,
|
`/api/v1/templates/${templateId}/generate-document`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(safePayload),
|
||||||
},
|
},
|
||||||
portId,
|
portId,
|
||||||
).then(normalizeDocument);
|
).then(normalizeDocument);
|
||||||
|
|||||||
Reference in New Issue
Block a user