diff --git a/EMAIL_DELIVERY.md b/EMAIL_DELIVERY.md new file mode 100644 index 0000000..df0ab07 --- /dev/null +++ b/EMAIL_DELIVERY.md @@ -0,0 +1,207 @@ +# Email link delivery + +The check-in form is reached via a personalized URL that carries the +contact's CiviCRM ID + a server-issued checksum: + +``` +https://check-in.fci.coop/?cid=&cs= +``` + +This document covers the three pieces needed to actually get those links +into form-fillers' hands: + +1. A **CiviCRM message template** that bakes the URL into an email body. +2. A way to **trigger sending** (one of three options below). +3. A **link-preview endpoint** in this app for ad-hoc / testing use. + +You don't need all three — most teams use #1 + the simplest version of #2. +The preview endpoint (#3) is a debugging convenience. + +--- + +## 1. Create the CiviCRM message template + +In CiviCRM: + +1. Navigate to **Mailings → Message Templates → Add Message Template**. +2. Title: `Co-op Check-in invitation` +3. Subject: + ``` + Time for your co-op check-in + ``` +4. Plain-text body (substitute your real domain): + + ``` + Hello {contact.display_name}, + + Please take a few minutes to update us on your co-op's progress this + month. The form pre-fills your prior responses — you only need to update + what's changed. + + Open your check-in: + https://check-in.fci.coop/?cid={contact.contact_id}&cs={contact.checksum} + + The link is personalized to you and expires in 14 days. If you no longer + have it, reply to this email and we'll send a fresh one. + + With gratitude, + The Food Co-op Initiative team + ``` + +5. HTML body (same content, slightly nicer): + + ```html +

Hello {contact.display_name},

+ +

Please take a few minutes to update us on your co-op's progress this + month. The form pre-fills your prior responses — you only need to update + what's changed.

+ +

+ + Open your check-in + +

+ +

The link is personalized to + you and expires in 14 days. If you no longer have it, reply to this email + and we'll send a fresh one.

+ +

With gratitude,
The Food Co-op Initiative team

+ ``` + +6. Save. + +> **Token reference:** +> - `{contact.display_name}` — the form-filler's name +> - `{contact.contact_id}` — their CiviCRM ID (becomes `cid`) +> - `{contact.checksum}` — a fresh secure token (becomes `cs`) +> +> All three are stock CiviCRM tokens. The checksum is generated at send time +> and includes the recipient's contact-specific hash, so it's both +> per-contact and per-send. + +--- + +## 2. Trigger sending — three options + +### Option A — Manual: stock CiviCRM Send Email (simplest) + +Best for: low volume, one-at-a-time, or you want to review each send. + +1. Open the **organization's contact record** in CiviCRM. +2. **Relationships** tab → find the row with **"Primary Contact"** → click the related individual's name. +3. On the individual's contact view: **Actions → Send Email**. +4. **Use Template**: select `Co-op Check-in invitation`. The subject and body populate; tokens render in the preview. +5. **Send**. CiviCRM logs the email as an Activity on the contact. + +Repeat per contact. Slow but bulletproof. + +### Option B — Bulk: CiviMail (campaign-style send) + +Best for: send the check-in invitation to every active co-op at once (e.g. monthly). + +1. Build a Smart Group of contacts whose Primary Contact relationships point at orgs in active stages. Example query: + - **Contacts** → **Advanced Search** + - Add criteria: `Has relationship` → `Primary Contact` → status: Active + - Optionally constrain by the related org's `Food_Co_op_Organizing.Stage` field + - Save as Smart Group: "Active co-op primary contacts" + +2. **Mailings → New Mailing**. +3. Recipients: the smart group above. +4. Choose template: `Co-op Check-in invitation`. +5. Schedule send. + +Each recipient gets their own checksum embedded in the URL — CiviMail +expands the tokens per-recipient. + +### Option C — One-click: CiviRules action (most polished) + +Best for: a button-on-the-org-record workflow. + +Setup: + +1. **Administer → CiviRules → Manage Rules → New Rule**. +2. Title: `Send Co-op Check-in invitation`. +3. **Trigger**: pick a "manually trigger from org row" action if your CiviRules version supports it. Otherwise: trigger on org Stage change (auto-fires when staff advance an org). +4. **Action**: `Send email to contact via message template`. + - Template: `Co-op Check-in invitation`. + - Recipient: **Contact in relationship** → "Primary Contact" → side B (the Individual). +5. Save. + +When staff change `Food_Co_op_Organizing.Stage` on an org, the rule fires +and emails the org's Primary Contact automatically — no extra clicks. + +### Combination + +You can use any combination. Common pattern: Option C for stage transitions +(automatic) + Option A for ad-hoc resends. + +--- + +## 3. The `/api/preview-link` endpoint (admin debugging) + +Generates a working URL for any contact, returning the URL and the +contact/org context for verification. + +**Auth:** requires the `HEALTH_TOKEN` env var (same secret as +`/api/health`). In development without `HEALTH_TOKEN` set, no auth required. + +**Request:** + +```bash +curl "https://check-in.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN" +``` + +**Response:** + +```json +{ + "contactId": 513, + "contactName": "Bob Sample", + "orgId": 9609, + "orgName": "A Sample Food Co-op", + "orgStage": "Organizing", + "url": "https://check-in.fci.coop/?cid=513&cs=8d1f...", + "checksum": "8d1f...", + "ttlHours": 336 +} +``` + +**Use cases:** + +- **Testing**: confirm a contact's setup before sending the real CiviCRM email. +- **Manual / external sends**: copy the URL into Slack, ad-hoc email, an SMS, etc. +- **Debugging**: if a contact reports the form not working, generate a fresh URL and try it yourself. +- **Bulk export**: script-loop over a list of cids to produce a CSV of `(name, org, url)` for an external mail-merge. + +**Errors you may see:** + +- `404 — has no active Primary Contact relationship` → the contact isn't linked to any org. +- `409 — multiple active relationships` → resolve in CiviCRM first; the form needs exactly one. +- `501 — Stub mode active` → you forgot to set `CIVI_BASE_URL` etc. + +--- + +## Operational runbook + +### "We need to send invitations for this month" + +1. (Bulk path) Use Option B: build/refresh the Smart Group, schedule the CiviMail mailing, send. +2. (Per-org path) Use Option A: open each org → Send Email to Primary Contact. +3. (Auto path) Use Option C: just advance stages; emails go out automatically. + +### "Contact reports the link doesn't work" + +1. Hit `/api/preview-link?cid=&token=`. +2. Verify the response shows the right org name + stage. +3. Send the URL from the response directly to the contact (or have them try it). +4. If `/api/preview-link` 404s, the contact lacks a Primary Contact relationship — fix in CiviCRM first. +5. If their old URL truly expired (older than 14 days / your `cs_offset` setting), CiviCRM rejects it. The new one from `preview-link` will work. + +### "We want to invalidate all outstanding links for one contact" + +In CiviCRM, edit the contact's **hash** field (under their record → Edit). Bumping the hash invalidates every checksum issued for that contact. Use as a break-glass for compromised links. diff --git a/app/api/preview-link/route.ts b/app/api/preview-link/route.ts new file mode 100644 index 0000000..e3b58ee --- /dev/null +++ b/app/api/preview-link/route.ts @@ -0,0 +1,143 @@ +/** + * GET /api/preview-link?cid=&token= + * + * Returns a ready-to-send check-in URL for a given contact, plus enough + * context for staff to verify it's the right org. Used for: + * - Generating links to paste into ad-hoc emails (or external tools) + * - Testing a contact's setup before sending the real CiviCRM email + * - QA before rollout + * + * AUTH: Always requires the HEALTH_TOKEN (same secret as /api/health). In + * development, if HEALTH_TOKEN is unset, anyone can hit the endpoint — + * matches /api/health behavior so dev workflow stays frictionless. In + * production, the endpoint returns 404 if HEALTH_TOKEN is unset. + * + * Returns: + * { + * contactId, contactName, + * orgId, orgName, orgStage, + * url, // the full form URL with cid + cs + * checksum, + * ttlHours // how long the checksum is valid (Civi default 14d) + * } + */ + +import { NextResponse } from "next/server"; +import { civi } from "@/lib/civicrm"; +import { FORM_CONTACT_RELATIONSHIP } from "@/config/form"; +import { appEnv } from "@/lib/env"; + +interface PreviewLinkResponse { + contactId: number; + contactName: string; + orgId: number; + orgName: string; + orgStage: string; + url: string; + checksum: string; + ttlHours: number; +} + +export async function GET(req: Request) { + const env = appEnv(); + const url = new URL(req.url); + const cid = url.searchParams.get("cid"); + const token = url.searchParams.get("token"); + + // Auth gate: production requires HEALTH_TOKEN. Dev allows passthrough. + if (env.isProduction) { + if (!env.healthToken) return new NextResponse("Not Found", { status: 404 }); + if (token !== env.healthToken) return new NextResponse("Not Found", { status: 404 }); + } else if (env.healthToken && token !== env.healthToken) { + return new NextResponse("Not Found", { status: 404 }); + } + + if (!cid) { + return NextResponse.json({ error: "Missing cid parameter." }, { status: 400 }); + } + const contactId = Number(cid); + if (!Number.isFinite(contactId)) { + return NextResponse.json({ error: "cid must be an integer." }, { status: 400 }); + } + + if (env.isStub) { + return NextResponse.json( + { error: "Stub mode active — set CIVI_* env vars to generate real links." }, + { status: 501 }, + ); + } + + // Resolve org via the Primary Contact relationship. + const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { + select: ["contact_id_b"], + where: [ + ["contact_id_a", "=", contactId], + ["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP], + ["is_active", "=", true], + ], + limit: 2, + }); + const orgs = relRes.values ?? []; + if (orgs.length === 0) { + return NextResponse.json( + { + error: `Contact ${contactId} has no active "${FORM_CONTACT_RELATIONSHIP}" relationship to an organization.`, + }, + { status: 404 }, + ); + } + if (orgs.length > 1) { + return NextResponse.json( + { + error: `Contact ${contactId} has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships. Resolve in CiviCRM first.`, + }, + { status: 409 }, + ); + } + const orgId = orgs[0].contact_id_b; + + // Pull contact + org names + the org's current stage so the response gives + // staff everything they need to confirm "yes, this is the right link." + const [contactRes, orgRes, csRes] = await Promise.all([ + civi<{ display_name: string }>("Contact", "get", { + select: ["display_name"], + where: [["id", "=", contactId]], + }), + civi<{ display_name: string; "Food_Co_op_Organizing.Stage": string | null }>("Contact", "get", { + select: ["display_name", "Food_Co_op_Organizing.Stage"], + where: [["id", "=", orgId]], + }), + civi<{ checksum: string }>("Contact", "getChecksum", { contactId }), + ]); + + const contactName = contactRes.values?.[0]?.display_name ?? `Contact ${contactId}`; + const orgName = orgRes.values?.[0]?.display_name ?? `Organization ${orgId}`; + const orgStage = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? ""; + const checksum = csRes.values?.[0]?.checksum; + if (!checksum) { + return NextResponse.json( + { error: "Could not generate a checksum for this contact." }, + { status: 500 }, + ); + } + + // Build the URL. Prefer the explicit PUBLIC_ORIGIN env var; fall back to + // the request's own origin (handy for dev / first-deploy). + const origin = + env.publicOrigin ?? `${url.protocol}//${url.host}`; + const formUrl = new URL("/", origin); + formUrl.searchParams.set("cid", String(contactId)); + formUrl.searchParams.set("cs", checksum); + + const payload: PreviewLinkResponse = { + contactId, + contactName, + orgId, + orgName, + orgStage: orgStage ?? "", + url: formUrl.toString(), + checksum, + ttlHours: 14 * 24, // Civi default — adjust if your install changed cs_offset. + }; + return NextResponse.json(payload); +}