Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cc332c3
fix(settings-sheet): strip time part from date when loading from BFF
important-new Jun 6, 2026
7161089
fix(team): read body.data.members not body.data for team list
important-new Jun 6, 2026
fa08973
feat(team): wire InviteSeatModal to Invite Member button
important-new Jun 6, 2026
36949ba
fix(bff): use valid pageSize=100 for template list in inspection-sett…
important-new Jun 6, 2026
51cbde6
feat(search): lazy-load template picker + server-side search for temp…
important-new Jun 6, 2026
8b21052
sync: merge upstream main (PR #127 squash — B-28/B-29/B-30 data-integ…
important-new Jun 7, 2026
6ed6bc4
sync: merge upstream main (PR #128 squash — stripe saas readiness + e…
important-new Jun 7, 2026
389010e
Merge branch 'InspectorHub:main' into main
important-new Jun 8, 2026
eba638a
Merge branch 'InspectorHub:main' into main
important-new Jun 8, 2026
51396cd
Merge remote-tracking branch 'upstream/main'
important-new Jun 8, 2026
e0e0a43
feat(sms): migration 0025 — channels/sms_body, recipient rename, cons…
important-new Jun 8, 2026
7444090
feat(sms): E.164 phone normalization util
important-new Jun 8, 2026
10a610e
feat(sms): Twilio credential resolution via explicit mode toggle (mir…
important-new Jun 8, 2026
7729c02
feat(sms): Twilio REST send + request-signature validation
important-new Jun 8, 2026
c32a7f8
feat(sms): consent ledger service (grant/revoke/latest-wins) + disclo…
important-new Jun 8, 2026
a929e1d
feat(sms): engine — channels[]/sms_body persistence, channel-aware ad…
important-new Jun 8, 2026
7e88fcd
fix(sms): channels default must not inject on partial update (Task 6 …
important-new Jun 8, 2026
ac6a6d4
feat(sms): flush() Twilio branch (consent gate + render + send) + cro…
important-new Jun 8, 2026
77fc983
feat(sms): add tenant_configs.company_phone for {{company_phone}} in …
important-new Jun 8, 2026
78bf665
test(sms): mirror sms_mode + company_phone into workers inline tenant…
important-new Jun 8, 2026
3a67b0a
feat(sms): ensureClientContact (D6b) — find-or-create + back-link cli…
important-new Jun 8, 2026
cc8da2c
feat(sms): consent capture — opt-in link page, inspector attestation,…
important-new Jun 8, 2026
a164c64
feat(sms): editor multi-channel + run-log badge + Settings SMS config…
important-new Jun 8, 2026
718fb8e
feat(sms): seed compliance-safe sms_body on touchpoints + disclosure v1
important-new Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/lib/api-client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import type {
SecretsApi,
ServicesApi,
SessionContextApi,
SmsPublicApi,
SmsAdminApi,
TagsApi,
TeamApi,
TemplateMigrationsApi,
Expand Down Expand Up @@ -146,6 +148,8 @@ export interface Api {
secrets: ReturnType<typeof hc<SecretsApi>>;
services: ReturnType<typeof hc<ServicesApi>>;
sessionContext: ReturnType<typeof hc<SessionContextApi>>;
smsPublic: ReturnType<typeof hc<SmsPublicApi>>;
smsAdmin: ReturnType<typeof hc<SmsAdminApi>>;
tags: ReturnType<typeof hc<TagsApi>>;
team: ReturnType<typeof hc<TeamApi>>;
templateMigrations: ReturnType<typeof hc<TemplateMigrationsApi>>;
Expand Down Expand Up @@ -206,6 +210,8 @@ const MOUNT: Record<keyof Api, string> = {
secrets: "/api/admin",
services: "/api/services",
sessionContext: "/api/session",
smsPublic: "/api/public",
smsAdmin: "/api/admin",
tags: "/api/tags",
team: "/api/team",
templateMigrations: "/api/templates",
Expand Down Expand Up @@ -284,6 +290,8 @@ export function createApi(context: AppLoadContext, opts: CreateApiOptions = {}):
secrets: mk<SecretsApi>(MOUNT.secrets),
services: mk<ServicesApi>(MOUNT.services),
sessionContext: mk<SessionContextApi>(MOUNT.sessionContext),
smsPublic: mk<SmsPublicApi>(MOUNT.smsPublic),
smsAdmin: mk<SmsAdminApi>(MOUNT.smsAdmin),
tags: mk<TagsApi>(MOUNT.tags),
team: mk<TeamApi>(MOUNT.team),
templateMigrations: mk<TemplateMigrationsApi>(MOUNT.templateMigrations),
Expand Down
2 changes: 2 additions & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 83 additions & 2 deletions app/routes/inspection-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
const body = await res.json();
const hub = ((body as Record<string, unknown>).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 };
}

/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -178,10 +201,14 @@ function humanizeStatus(status: string): string {
/* ------------------------------------------------------------------ */

export default function InspectionHubPage() {
const { hub } = useLoaderData<typeof loader>();
const { hub, smsConsent } = useLoaderData<typeof loader>();
const { inspection, people, services, tenantSlug } = hub;
const blocks = deriveBlockStates(hub);

// Track L (E) — SMS consent attestation. Dedicated fetcher (never share).
const attestSms = useFetcher<typeof action>();
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.
Expand Down Expand Up @@ -341,6 +368,12 @@ export default function InspectionHubPage() {
{people.client.phone}
</a>
)}
{/* Track L (E) — SMS consent status + inspector attestation */}
<ClientSmsConsent
consent={smsConsent}
fetcher={attestSms}
attesting={attesting}
/>
</div>
) : inspection.clientName ? (
// Bare-text fallback when only the denormalized name is present.
Expand Down Expand Up @@ -772,6 +805,54 @@ function RequestPaymentModal({
);
}

/* ------------------------------------------------------------------ */
/* Client SMS consent status + attestation (Track L) */
/* ------------------------------------------------------------------ */

function ClientSmsConsent({
consent,
fetcher,
attesting,
}: {
consent: "granted" | "revoked" | "none";
fetcher: ReturnType<typeof useFetcher<typeof action>>;
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 (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-ih-fg-3">
Client SMS: <span className={`font-bold ${tone}`}>{label}</span>
</span>
{/* 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" && (
<fetcher.Form method="post">
<input type="hidden" name="intent" value="attest-sms" />
<button
type="submit"
disabled={attesting}
className="text-[11px] font-bold text-ih-primary hover:underline disabled:opacity-60"
>
{attesting ? "Recording…" : "Client agreed to receive texts — I confirm"}
</button>
</fetcher.Form>
)}
{error && <span className="text-ih-bad-fg">{error}</span>}
</div>
);
}

/* ------------------------------------------------------------------ */
/* Publish-report modal */
/* ------------------------------------------------------------------ */
Expand Down
17 changes: 17 additions & 0 deletions app/routes/public/booking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(preselected?.id ?? null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null);
Expand Down Expand Up @@ -190,6 +192,7 @@ export default function BookingPage() {
services: [...selectedServices].map(id => ({ serviceId: id })),
clientName,
clientEmail,
...(smsOptin ? { smsOptin: true } : {}),
...(turnstileToken ? { turnstileToken } : {}),
...(agentRefSlug ? { agentRefSlug } : {}),
}),
Expand Down Expand Up @@ -450,6 +453,20 @@ export default function BookingPage() {
/>
</label>
</div>
{/* Track L (D6, path A) — unchecked SMS opt-in (TCPA consent). */}
<label className="flex items-start gap-3 mt-4 cursor-pointer">
<input
type="checkbox"
checked={smsOptin}
onChange={(e) => setSmsOptin(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-ih-border text-ih-primary focus:ring-ih-primary"
/>
<span className="text-[13px] text-ih-fg-3 leading-relaxed">
Text me appointment &amp; report updates. By checking this box you agree to
receive automated text messages about your inspection. Message &amp; data rates
may apply; reply STOP to opt out. Consent is not a condition of booking.
</span>
</label>
</div>
</section>
)}
Expand Down
120 changes: 120 additions & 0 deletions app/routes/public/sms-optin.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submitting = navigation.state === "submitting";

if (!data) {
return (
<div className="min-h-screen bg-ih-bg-app flex items-center justify-center px-4">
<div className="max-w-md w-full bg-ih-bg-card border border-ih-border rounded-2xl p-8 text-center">
<h1 className="text-xl font-bold text-ih-fg-1 mb-2">Link not found</h1>
<p className="text-sm text-ih-fg-3">
This opt-in link is invalid or has expired. If you'd still like text
updates, please contact your inspection company.
</p>
</div>
</div>
);
}

if (actionData?.ok) {
return (
<div className="min-h-screen bg-ih-bg-app flex items-center justify-center px-4">
<div className="max-w-md w-full bg-ih-bg-card border border-ih-border rounded-2xl p-8 text-center">
<h1 className="text-xl font-bold text-ih-fg-1 mb-2">You're subscribed</h1>
<p className="text-sm text-ih-fg-3">
You'll receive appointment and report updates from {data.companyName} by
text. Reply <strong>STOP</strong> anytime to opt out.
</p>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-ih-bg-app flex items-center justify-center px-4">
<div className="max-w-md w-full bg-ih-bg-card border border-ih-border rounded-2xl p-8">
<h1 className="text-xl font-bold text-ih-fg-1 mb-1">Text me updates</h1>
<p className="text-sm text-ih-fg-3 mb-4">
Get appointment reminders and report-ready alerts from{" "}
<strong>{data.companyName}</strong> by text message.
</p>
<div className="bg-ih-bg-muted border border-ih-border rounded-xl p-4 mb-5">
<p className="text-xs text-ih-fg-3 leading-relaxed">{data.disclosureText}</p>
</div>
{actionData?.error && (
<p className="text-sm text-ih-bad-fg mb-3" role="alert">
{actionData.error}
</p>
)}
<Form method="post">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-3 rounded-xl bg-ih-primary text-white text-sm font-semibold disabled:opacity-50 transition-opacity"
>
{submitting ? "Confirming..." : "Yes, text me updates"}
</button>
</Form>
<p className="text-xs text-ih-fg-3 mt-4 text-center">
Message &amp; data rates may apply. Reply STOP to opt out.
</p>
</div>
</div>
);
}
Loading
Loading