/** * GET /api/preview-link?cid= * Authorization: Bearer * * Returns a ready-to-send survey 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: this mints working form links for ANY contact, so it gets its own * secret, PREVIEW_ADMIN_TOKEN, sent as a Bearer header to keep it out of * URLs and access logs. Until PREVIEW_ADMIN_TOKEN is set, HEALTH_TOKEN is * accepted instead (as a Bearer header or the legacy `?token=`) so the * existing support workflow keeps working. With neither set, production * returns 404 and development allows passthrough. * * 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"; import { safeEqual } from "@/lib/staff-token.mjs"; 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"); // Auth gate — see header comment. const secret = env.previewAdminToken || env.healthToken; if (secret) { const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]; const presented = bearer ?? (env.previewAdminToken ? null : url.searchParams.get("token")); if (!presented || !safeEqual(presented, secret)) { return new NextResponse("Not Found", { status: 404 }); } } else if (env.isProduction) { 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); }