diff --git a/app/lib/api-client.server.ts b/app/lib/api-client.server.ts index 8e055650..36230350 100644 --- a/app/lib/api-client.server.ts +++ b/app/lib/api-client.server.ts @@ -46,6 +46,8 @@ import type { SecretsApi, ServicesApi, SessionContextApi, + SmsPublicApi, + SmsAdminApi, TagsApi, TeamApi, TemplateMigrationsApi, @@ -146,6 +148,8 @@ export interface Api { secrets: ReturnType>; services: ReturnType>; sessionContext: ReturnType>; + smsPublic: ReturnType>; + smsAdmin: ReturnType>; tags: ReturnType>; team: ReturnType>; templateMigrations: ReturnType>; @@ -206,6 +210,8 @@ const MOUNT: Record = { secrets: "/api/admin", services: "/api/services", sessionContext: "/api/session", + smsPublic: "/api/public", + smsAdmin: "/api/admin", tags: "/api/tags", team: "/api/team", templateMigrations: "/api/templates", @@ -284,6 +290,8 @@ export function createApi(context: AppLoadContext, opts: CreateApiOptions = {}): secrets: mk(MOUNT.secrets), services: mk(MOUNT.services), sessionContext: mk(MOUNT.sessionContext), + smsPublic: mk(MOUNT.smsPublic), + smsAdmin: mk(MOUNT.smsAdmin), tags: mk(MOUNT.tags), team: mk(MOUNT.team), templateMigrations: mk(MOUNT.templateMigrations), diff --git a/app/routes.ts b/app/routes.ts index 5a6d11ab..0ca866be 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -51,6 +51,8 @@ export default [ "routes/public/report-card-stack.tsx", ), route("messages/:token", "routes/public/messages.tsx"), + // Track L (D6, path B) — public SMS double-opt-in confirmation page. + route("sms-optin/:token", "routes/public/sms-optin.tsx"), route("r/:id/repair-request", "routes/public/repair-request.tsx"), route( "agreements/print/:token", diff --git a/app/routes/inspection-hub.tsx b/app/routes/inspection-hub.tsx index 8da80905..0576bd28 100644 --- a/app/routes/inspection-hub.tsx +++ b/app/routes/inspection-hub.tsx @@ -79,7 +79,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } const body = await res.json(); const hub = ((body as Record).data ?? {}) as unknown as HubData; - return { hub }; + + // Track L (E) — client SMS consent status for the People card. Best-effort: + // a failure degrades to "none" (the attest affordance still renders). + const consentRes = await api.smsAdmin.sms.consent.$get({ query: { inspectionId: id } }).catch(() => null); + const smsConsent = + consentRes && consentRes.ok + ? (((await consentRes.json()) as { data?: { consent?: "granted" | "revoked" | "none" } }).data?.consent ?? "none") + : "none"; + + return { hub, smsConsent }; } /* ------------------------------------------------------------------ */ @@ -132,6 +141,20 @@ export async function action({ request, params, context }: Route.ActionArgs) { return { ok: true, intent: "request-payment" as const, error: undefined }; } + if (intent === "attest-sms") { + // Track L (E) — inspector attestation that the client agreed to receive texts. + const res = await api.smsAdmin.sms.attest.$post({ json: { inspectionId: id } }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: { message?: string } } | null; + return { + ok: false, + intent: "attest-sms" as const, + error: err?.error?.message ?? "Could not record consent. Please try again.", + }; + } + return { ok: true, intent: "attest-sms" as const, error: undefined }; + } + if (intent === "publish") { // theme: the editor's PublishModal posts no `theme`, so it rides the // schema default ('modern'). We send the same value explicitly here — @@ -178,10 +201,14 @@ function humanizeStatus(status: string): string { /* ------------------------------------------------------------------ */ export default function InspectionHubPage() { - const { hub } = useLoaderData(); + const { hub, smsConsent } = useLoaderData(); const { inspection, people, services, tenantSlug } = hub; const blocks = deriveBlockStates(hub); + // Track L (E) — SMS consent attestation. Dedicated fetcher (never share). + const attestSms = useFetcher(); + const attesting = attestSms.state !== "idle"; + // Send-agreement modal — its own dedicated fetcher (B-17: never share // fetchers between mutations). Close on success; the loader revalidation // refreshes agreementRequests automatically. @@ -341,6 +368,12 @@ export default function InspectionHubPage() { {people.client.phone} )} + {/* Track L (E) — SMS consent status + inspector attestation */} + ) : inspection.clientName ? ( // Bare-text fallback when only the denormalized name is present. @@ -772,6 +805,54 @@ function RequestPaymentModal({ ); } +/* ------------------------------------------------------------------ */ +/* Client SMS consent status + attestation (Track L) */ +/* ------------------------------------------------------------------ */ + +function ClientSmsConsent({ + consent, + fetcher, + attesting, +}: { + consent: "granted" | "revoked" | "none"; + fetcher: ReturnType>; + attesting: boolean; +}) { + const error = + fetcher.data?.intent === "attest-sms" && !fetcher.data.ok + ? fetcher.data.error + : undefined; + + const label = + consent === "granted" ? "granted" : consent === "revoked" ? "revoked" : "not recorded"; + const tone = + consent === "granted" ? "text-ih-ok-fg" : consent === "revoked" ? "text-ih-bad-fg" : "text-ih-fg-4"; + + return ( +
+ + Client SMS: {label} + + {/* Offer the attestation only when not already granted. Framed as an + inspector confirmation that the client agreed (not a consent-less + override) — the deliberate basis for phone/in-person bookings. */} + {consent !== "granted" && ( + + + + + )} + {error && {error}} +
+ ); +} + /* ------------------------------------------------------------------ */ /* Publish-report modal */ /* ------------------------------------------------------------------ */ diff --git a/app/routes/public/booking.tsx b/app/routes/public/booking.tsx index 675341c8..9803e871 100644 --- a/app/routes/public/booking.tsx +++ b/app/routes/public/booking.tsx @@ -104,6 +104,8 @@ export default function BookingPage() { const [customTime, setCustomTime] = useState("09:00"); const [clientName, setClientName] = useState(""); const [clientEmail, setClientEmail] = useState(""); + // Track L (D6, path A) — unchecked-by-default SMS opt-in (TCPA consent). + const [smsOptin, setSmsOptin] = useState(false); const [chosenInspectorId, setChosenInspectorId] = useState(preselected?.id ?? null); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); @@ -190,6 +192,7 @@ export default function BookingPage() { services: [...selectedServices].map(id => ({ serviceId: id })), clientName, clientEmail, + ...(smsOptin ? { smsOptin: true } : {}), ...(turnstileToken ? { turnstileToken } : {}), ...(agentRefSlug ? { agentRefSlug } : {}), }), @@ -450,6 +453,20 @@ export default function BookingPage() { /> + {/* Track L (D6, path A) — unchecked SMS opt-in (TCPA consent). */} + )} diff --git a/app/routes/public/sms-optin.tsx b/app/routes/public/sms-optin.tsx new file mode 100644 index 00000000..c2b26c72 --- /dev/null +++ b/app/routes/public/sms-optin.tsx @@ -0,0 +1,120 @@ +import { useLoaderData, useActionData, useNavigation, Form } from "react-router"; +import type { Route } from "./+types/sms-optin"; +import { createApi } from "~/lib/api-client.server"; + +export function meta() { + return [{ title: "Text message updates - OpenInspection" }]; +} + +interface OptinData { + companyName: string; + disclosureText: string; +} + +/* ------------------------------------------------------------------ */ +/* Loader — resolve the token to disclosure + company name (BFF) */ +/* ------------------------------------------------------------------ */ + +export async function loader({ params, context }: Route.LoaderArgs) { + const token = params.token ?? ""; + try { + const api = createApi(context); + const res = (await api.smsPublic.sms["optin-resolve"].$get({ + query: { token }, + })) as unknown as Response; + if (!res.ok) return { data: null as OptinData | null, token }; + const body = (await res.json()) as { data?: OptinData }; + return { data: body.data ?? null, token }; + } catch { + return { data: null as OptinData | null, token }; + } +} + +/* ------------------------------------------------------------------ */ +/* Action — confirm opt-in (BFF, no client fetch) */ +/* ------------------------------------------------------------------ */ + +export async function action({ params, context }: Route.ActionArgs) { + const token = params.token ?? ""; + try { + const api = createApi(context); + const res = (await api.smsPublic.sms["optin-confirm"].$post({ + json: { token }, + })) as unknown as Response; + if (res.ok) return { ok: true as const }; + return { ok: false as const, error: "We couldn't confirm your opt-in. The link may have expired." }; + } catch { + return { ok: false as const, error: "Service unavailable. Please try again later." }; + } +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +export default function SmsOptinPage() { + const { data } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const submitting = navigation.state === "submitting"; + + if (!data) { + return ( +
+
+

Link not found

+

+ This opt-in link is invalid or has expired. If you'd still like text + updates, please contact your inspection company. +

+
+
+ ); + } + + if (actionData?.ok) { + return ( +
+
+

You're subscribed

+

+ You'll receive appointment and report updates from {data.companyName} by + text. Reply STOP anytime to opt out. +

+
+
+ ); + } + + return ( +
+
+

Text me updates

+

+ Get appointment reminders and report-ready alerts from{" "} + {data.companyName} by text message. +

+
+

{data.disclosureText}

+
+ {actionData?.error && ( +

+ {actionData.error} +

+ )} +
+ +
+

+ Message & data rates may apply. Reply STOP to opt out. +

+
+
+ ); +} diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx index 9d0d5fc8..c2479a47 100644 --- a/app/routes/settings-automations.tsx +++ b/app/routes/settings-automations.tsx @@ -11,10 +11,13 @@ export function meta() { interface Rule { id: string; name: string; trigger: string; recipient: string; delayMinutes: number; subjectTemplate: string; bodyTemplate: string; - conditions: string | null; channel: string; active: boolean; isDefault: boolean; + // Track L: channels[] supersedes the dead `channel` shadow; sms_body added. + // (Full multi-channel editor lands in Task 9; these keep the page type-safe.) + conditions: string | null; channels: string[]; smsBody: string | null; + active: boolean; isDefault: boolean; } interface Svc { id: string; name: string; } -interface LogRow { id: string; recipientEmail: string; sendAt: string; status: string; error: string | null; } +interface LogRow { id: string; recipient: string; channel: string; sendAt: string; status: string; error: string | null; } export const TRIGGER_LABELS: Record = { "inspection.created": "Inspection created", @@ -34,6 +37,15 @@ export const TRIGGER_LABELS: Record = { }; const RECIPIENTS = ["client", "buying_agent", "selling_agent", "inspector", "all"] as const; const PLACEHOLDERS = ["client_name", "property_address", "scheduled_date", "report_url", "invoice_url", "payment_url", "company_name", "review_url"]; +// Track L — SMS bodies are plain text; the renderable var set differs from email +// (no subject, no HTML; company_phone is the SMS call-back number). +const SMS_PLACEHOLDERS = ["client_name", "property_address", "scheduled_date", "report_url", "review_url", "company_name", "company_phone"]; + +/** GSM-ish segment estimate: 160 chars for one segment, 153 for each concatenated part. */ +function smsSegments(len: number): number { + if (len === 0) return 0; + return len <= 160 ? 1 : Math.ceil(len / 153); +} interface Conditions { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[]; } @@ -92,6 +104,10 @@ export async function action({ request, context }: Route.ActionArgs) { requireSigned: form.get("requireSigned") === "on", serviceIds, }); + // Track L (Task 9) — multi-channel: read the checked channels; ≥1 is enforced + // client-side (the Save button disables when none) AND server-side (zod .min(1)). + const channels = form.getAll("channels").map(String).filter((c) => c === "email" || c === "sms"); + const smsBody = String(form.get("smsBody") ?? "").trim(); const json = { name: String(form.get("name") ?? ""), trigger: String(form.get("trigger") ?? ""), @@ -99,7 +115,9 @@ export async function action({ request, context }: Route.ActionArgs) { delayMinutes: Number(form.get("delayMinutes") ?? 0), subjectTemplate: String(form.get("subjectTemplate") ?? ""), bodyTemplate: String(form.get("bodyTemplate") ?? ""), - channel: "email", + channels: channels.length ? channels : ["email"], + // Persist the SMS body only when SMS is an enabled channel; else clear it. + smsBody: channels.includes("sms") ? smsBody : null, conditions, }; const id = String(form.get("id") ?? ""); @@ -158,7 +176,7 @@ export default function SettingsAutomations() {

{rule.name}

{rule.isDefault && Default} - {rule.channel === "sms" && SMS} + {(rule.channels ?? []).includes("sms") &&SMS}

{TRIGGER_LABELS[rule.trigger] || rule.trigger} → {rule.recipient}

@@ -186,7 +204,9 @@ export default function SettingsAutomations() {
{recentLogs.map((l) => (
- {l.recipientEmail} + {l.channel ?? "email"} + {l.recipient} {new Date(l.sendAt).toLocaleString()} { if (fetcher.state === "idle" && fetcher.data?.ok) onClose(); }, [fetcher.state, fetcher.data, onClose]); @@ -254,14 +286,21 @@ function AutomationEditor({ rule, services, onClose }: { rule: Rule | null; serv
-
+
Do this -
- + + {/* Channel multi-select + recipient + delay */} +
+
+ + +
- -