Email delivery: /api/preview-link admin endpoint + EMAIL_DELIVERY.md guide
This commit is contained in:
143
app/api/preview-link/route.ts
Normal file
143
app/api/preview-link/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* GET /api/preview-link?cid=<contactId>&token=<HEALTH_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);
|
||||
}
|
||||
Reference in New Issue
Block a user