diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx index 8722c707..9d0d5fc8 100644 --- a/app/routes/settings-automations.tsx +++ b/app/routes/settings-automations.tsx @@ -1,4 +1,5 @@ -import { Link, useLoaderData, Form } from "react-router"; +import { useState, useEffect } from "react"; +import { Link, useLoaderData, Form, useNavigation, useFetcher } from "react-router"; import type { Route } from "./+types/settings-automations"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -7,69 +8,116 @@ export function meta() { return [{ title: "Automations - Settings - OpenInspection" }]; } -interface AutomationRule { - id: string; - name: string; - trigger: string; - action: string; - active: boolean; - isDefault: boolean; +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; } +interface Svc { id: string; name: string; } +interface LogRow { id: string; recipientEmail: string; sendAt: string; status: string; error: string | null; } -const TRIGGER_LABELS: Record = { - inspection_confirmed: "Inspection confirmed", - inspection_completed: "Inspection completed", - report_delivered: "Report delivered", - payment_received: "Payment received", - booking_created: "New booking created", - reminder_24h: "24 hours before inspection", +export const TRIGGER_LABELS: Record = { + "inspection.created": "Inspection created", + "inspection.confirmed": "Inspection confirmed", + "inspection.cancelled": "Inspection cancelled", + "inspection.reminder": "Before the inspection (reminder)", + "report.published": "Report published", + "invoice.created": "Invoice created", + "payment.received": "Payment received", + "agreement.signed": "Agreement signed", + "agreement.signer_signed": "A signer signed", + "agreement.viewed": "Agreement viewed", + "agreement.declined": "Agreement declined", + "agreement.expired": "Agreement expired", + "event.created": "Event created", + "event.completed": "Event completed", }; +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"]; -const ACTION_LABELS: Record = { - send_confirmation: "Send confirmation email", - send_reminder: "Send reminder email", - send_report: "Deliver report", - send_receipt: "Send payment receipt", - send_review_request: "Request review", - notify_agent: "Notify agent", -}; +interface Conditions { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[]; } + +/** Assemble the Only-if gate object from the editor inputs, or null when empty. */ +export function buildConditions(input: { requirePaid: boolean; requireSigned: boolean; serviceIds: string[] }): Conditions | null { + const conditions: Conditions = { + ...(input.requirePaid ? { requirePaid: true } : {}), + ...(input.requireSigned ? { requireSigned: true } : {}), + ...(input.serviceIds.length ? { serviceIds: input.serviceIds } : {}), + }; + return Object.keys(conditions).length ? conditions : null; +} export async function loader({ request, context }: Route.LoaderArgs) { const token = await requireToken(context, request); - try { - const api = createApi(context, { token }); - const res = await api.automations.index.$get(); - const body = res.ok ? ((await res.json()) as Record) : { data: [] }; - return { rules: (body.data ?? []) as AutomationRule[] }; - } catch { - return { rules: [] as AutomationRule[] }; - } + const api = createApi(context, { token }); + const [rulesRes, svcRes, logsRes, cfgRes] = await Promise.all([ + api.automations.index.$get().catch(() => null), + api.services.index.$get({}).catch(() => null), + api.automations.logs.recent.$get({ query: { limit: 50 } }).catch(() => null), + api.admin["tenant-config"].$get().catch(() => null), + ]); + const rules = (rulesRes && rulesRes.ok ? ((await rulesRes.json()) as { data?: Rule[] }).data : []) ?? []; + const services = (svcRes && svcRes.ok ? ((await svcRes.json()) as { data?: Svc[] }).data : []) ?? []; + const recentLogs = (logsRes && logsRes.ok ? ((await logsRes.json()) as { data?: LogRow[] }).data : []) ?? []; + const reviewUrl = (cfgRes && cfgRes.ok ? (((await cfgRes.json()) as { data?: { reviewUrl?: string | null } }).data?.reviewUrl) : "") ?? ""; + return { rules, services, recentLogs, reviewUrl }; } export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); + const api = createApi(context, { token }); const form = await request.formData(); const intent = form.get("intent"); if (intent === "toggle") { const id = String(form.get("id") ?? ""); const active = form.get("active") === "true"; - const api = createApi(context, { token }); - await api.automations[":id"].$patch({ - param: { id }, - json: { active: !active }, + const res = await api.automations[":id"].$patch({ param: { id }, json: { active: !active } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "delete") { + const id = String(form.get("id") ?? ""); + const res = await api.automations[":id"].$delete({ param: { id } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "save-review-url") { + const reviewUrl = String(form.get("reviewUrl") ?? "").trim(); + const res = await api.admin["tenant-config"].$patch({ json: { reviewUrl: reviewUrl || null } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "save") { + const serviceIds = form.getAll("serviceIds").map(String).filter(Boolean); + const conditions = buildConditions({ + requirePaid: form.get("requirePaid") === "on", + requireSigned: form.get("requireSigned") === "on", + serviceIds, }); + const json = { + name: String(form.get("name") ?? ""), + trigger: String(form.get("trigger") ?? ""), + recipient: String(form.get("recipient") ?? "client"), + delayMinutes: Number(form.get("delayMinutes") ?? 0), + subjectTemplate: String(form.get("subjectTemplate") ?? ""), + bodyTemplate: String(form.get("bodyTemplate") ?? ""), + channel: "email", + conditions, + }; + const id = String(form.get("id") ?? ""); + const res = id + ? await (api.automations[":id"].$patch as unknown as (a: { param: { id: string }; json: typeof json }) => Promise)({ param: { id }, json }) + : await (api.automations.index.$post as unknown as (a: { json: typeof json }) => Promise)({ json }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; } - return { ok: true }; } export default function SettingsAutomations() { - const { rules } = useLoaderData(); + const { rules, services, recentLogs, reviewUrl } = useLoaderData(); + const nav = useNavigation(); + const [editing, setEditing] = useState(null); return (
- {/* Breadcrumb */}
Settings @@ -79,25 +127,29 @@ export default function SettingsAutomations() {

Automations

-

- Emails sent automatically when inspection events occur. -

+

Emails sent automatically when inspection events occur.

-
- {/* Rules table */} +
+ + +

Paste your Google/Yelp review link. The “Review request” automation stays off until this is set.

+
+ + +
+
+
{rules.length === 0 ? ( -
-
- -
-

No automations yet

-

Add an automation rule to send emails on inspection events.

-
+
No automations yet.
) : (
{rules.map((rule) => ( @@ -105,32 +157,19 @@ export default function SettingsAutomations() {

{rule.name}

- {rule.isDefault && ( - - Default - - )} + {rule.isDefault && Default} + {rule.channel === "sms" && SMS}
-

- {TRIGGER_LABELS[rule.trigger] || rule.trigger} - - {ACTION_LABELS[rule.action] || rule.action} -

+

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

-
+ + -
@@ -138,14 +177,125 @@ export default function SettingsAutomations() {
)}
+ +
+
Recent activity
+ {recentLogs.length === 0 ? ( +
No automation activity yet.
+ ) : ( +
+ {recentLogs.map((l) => ( +
+ {l.recipientEmail} + {new Date(l.sendAt).toLocaleString()} + {l.status} + {l.error && {l.error}} +
+ ))} +
+ )} +
+ + {editing && ( + setEditing(null)} /> + )}
); } -function BoltIcon() { +function AutomationEditor({ rule, services, onClose }: { rule: Rule | null; services: Svc[]; onClose: () => void }) { + const parsed: Conditions = rule?.conditions ? (JSON.parse(rule.conditions) as Conditions) : {}; + const fetcher = useFetcher<{ ok: boolean; error?: string }>(); + const submitting = fetcher.state !== "idle"; + const [confirmDelete, setConfirmDelete] = useState(false); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok) onClose(); + }, [fetcher.state, fetcher.data, onClose]); + return ( - - - +
+ e.stopPropagation()} className="bg-ih-bg-card border border-ih-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-auto p-5 space-y-5"> + + {rule && } +

{rule ? "Edit automation" : "New automation"}

+ + + +
+ When + +
+ +
+ Only if + + +
+

Limit to services (none = any):

+
+ {services.map((s) => ( + + ))} +
+
+
+ +
+ Do this +
+ + + +
+ +