Build standalone CiviCRM check-in middleware
This commit is contained in:
124
app/api/data/route.ts
Normal file
124
app/api/data/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* GET /api/data?cid=<cid>&cs=<cs>
|
||||
*
|
||||
* Verifies the checksum, resolves the org from the contact via the
|
||||
* Primary Form Contact relationship, reads the org's Framework Stage,
|
||||
* and walks past Org Engagement Submission activities for per-field
|
||||
* most-recent prefill.
|
||||
*
|
||||
* Returns FormDataPayload (see types/form.ts).
|
||||
*
|
||||
* STUB MODE: if CiviCRM env vars are unset, returns mock data so the UI
|
||||
* is exercisable without a live CRM. The mock data uses the actual stage
|
||||
* values and a couple of populated example fields so the form looks alive
|
||||
* during development.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||
import { loadPrefill } from "@/lib/prefill";
|
||||
import { allFields } from "@/config/form";
|
||||
import type { FormDataPayload } from "@/types/form";
|
||||
|
||||
const STUB_PAYLOAD: FormDataPayload = {
|
||||
orgName: "Sample Co-op (stub)",
|
||||
currentStage: "Organizing",
|
||||
prefill: {
|
||||
stage_0_peer_group_participation: "Peer cohort 4",
|
||||
stage_0_members_current: 87,
|
||||
stage_0_total_members_at_opening: null,
|
||||
stage_0_projected_y1_sales: 2400000,
|
||||
stage_0_projected_y2_sales: 2950000,
|
||||
stage_0_total_cost_of_project: 4200000,
|
||||
stage_1_vision: "A neighborhood-rooted co-op grocery prioritizing local farmers and equity in food access.",
|
||||
stage_1_business_concept: "5,500 sq ft full-service co-op in a transit-adjacent storefront.",
|
||||
},
|
||||
};
|
||||
|
||||
function isStubMode(): boolean {
|
||||
return !(
|
||||
process.env.CIVI_BASE_URL &&
|
||||
process.env.CIVI_API_KEY &&
|
||||
process.env.CIVI_SITE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const cid = url.searchParams.get("cid");
|
||||
const cs = url.searchParams.get("cs");
|
||||
|
||||
if (!cid || !cs) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing cid or cs parameter." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isStubMode()) {
|
||||
return NextResponse.json(STUB_PAYLOAD);
|
||||
}
|
||||
|
||||
// Verify checksum first.
|
||||
const ok = await verifyChecksum(cid, cs);
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link is invalid or has expired. Please request a fresh one." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the org from the contact's Primary Form Contact relationship.
|
||||
// Requires that the relationship exists and is active. We fetch contact_id_b
|
||||
// because the relationship is Individual (A) → Organization (B).
|
||||
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
||||
select: ["contact_id_b"],
|
||||
where: [
|
||||
["contact_id_a", "=", Number(cid)],
|
||||
["relationship_type_id:name", "=", "Primary Form Contact of"],
|
||||
["is_active", "=", true],
|
||||
],
|
||||
limit: 2,
|
||||
});
|
||||
const orgs = relRes.values ?? [];
|
||||
if (orgs.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active Primary Form Contact relationship found for your contact." },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (orgs.length > 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Your contact has multiple active Primary Form Contact relationships; staff must resolve before this link will work." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const orgId = orgs[0].contact_id_b;
|
||||
|
||||
// Fetch org name + Framework Stage value.
|
||||
const orgRes = await civi<{
|
||||
id: number;
|
||||
display_name: string;
|
||||
"Food_Co_op_Organizing.Stage": string | null;
|
||||
custom_1?: string | null;
|
||||
}>("Contact", "get", {
|
||||
select: ["id", "display_name", "custom_1", "Food_Co_op_Organizing.Stage"],
|
||||
where: [["id", "=", orgId]],
|
||||
});
|
||||
const org = orgRes.values?.[0];
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
|
||||
}
|
||||
const currentStage =
|
||||
org["Food_Co_op_Organizing.Stage"] ?? org.custom_1 ?? "";
|
||||
|
||||
// Per-field-most-recent prefill across past submission activities.
|
||||
const { values: prefill } = await loadPrefill(orgId, allFields);
|
||||
|
||||
const payload: FormDataPayload = {
|
||||
orgName: org.display_name,
|
||||
currentStage: typeof currentStage === "string" ? currentStage : "",
|
||||
prefill,
|
||||
};
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
109
app/api/submit/route.ts
Normal file
109
app/api/submit/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* POST /api/submit
|
||||
*
|
||||
* Body: SubmitPayload { cid, cs, values }.
|
||||
*
|
||||
* Verifies checksum, resolves org from cid (same as /api/data), then creates a
|
||||
* new Org Engagement Submission activity with all visible-field values written
|
||||
* to their custom-field bindings + stage_at_submission set to the org's
|
||||
* current Framework Stage.
|
||||
*
|
||||
* STUB MODE: if CiviCRM env vars are unset, returns success without writing
|
||||
* anywhere. Useful for UI dev.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||
import { allFields } from "@/config/form";
|
||||
import type { SubmitPayload } from "@/types/form";
|
||||
|
||||
function isStubMode(): boolean {
|
||||
return !(
|
||||
process.env.CIVI_BASE_URL &&
|
||||
process.env.CIVI_API_KEY &&
|
||||
process.env.CIVI_SITE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: SubmitPayload;
|
||||
try {
|
||||
body = (await req.json()) as SubmitPayload;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Malformed JSON body." }, { status: 400 });
|
||||
}
|
||||
const { cid, cs, values } = body;
|
||||
if (!cid || !cs) {
|
||||
return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isStubMode()) {
|
||||
console.warn("[submit:STUB] would create Org Engagement Submission activity with values:", values);
|
||||
return NextResponse.json({ ok: true, stub: true });
|
||||
}
|
||||
|
||||
const ok = await verifyChecksum(cid, cs);
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link is invalid or has expired." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve org via the relationship (mirror of /api/data).
|
||||
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
||||
select: ["contact_id_b"],
|
||||
where: [
|
||||
["contact_id_a", "=", Number(cid)],
|
||||
["relationship_type_id:name", "=", "Primary Form Contact of"],
|
||||
["is_active", "=", true],
|
||||
],
|
||||
limit: 2,
|
||||
});
|
||||
const orgs = relRes.values ?? [];
|
||||
if (orgs.length !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not resolve a unique organization for your contact." },
|
||||
{ status: orgs.length === 0 ? 404 : 409 },
|
||||
);
|
||||
}
|
||||
const orgId = orgs[0].contact_id_b;
|
||||
|
||||
// Read the org's current Framework Stage so we can stamp `stage_at_submission`.
|
||||
const orgRes = await civi<{
|
||||
"Food_Co_op_Organizing.Stage": string | null;
|
||||
custom_1?: string | null;
|
||||
}>("Contact", "get", {
|
||||
select: ["custom_1", "Food_Co_op_Organizing.Stage"],
|
||||
where: [["id", "=", orgId]],
|
||||
});
|
||||
const stageAtSubmission =
|
||||
orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ??
|
||||
orgRes.values?.[0]?.custom_1 ??
|
||||
null;
|
||||
|
||||
// Build the activity record. Each form-side field name maps to its
|
||||
// configured `civiField` for the activity-side write.
|
||||
const activityRecord: Record<string, unknown> = {
|
||||
"activity_type_id:name": "Org Engagement Submission",
|
||||
"status_id:name": "Completed",
|
||||
target_contact_id: orgId,
|
||||
source_contact_id: Number(cid),
|
||||
};
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const field = FIELD_BY_NAME.get(name);
|
||||
if (!field || !field.civiField) continue;
|
||||
if (field.type === "readonly") continue; // never write read-only fields
|
||||
activityRecord[field.civiField] = value;
|
||||
}
|
||||
// Stamp the stage-at-submission audit field. Convention: a field named
|
||||
// `Submission_Audit.stage_at_submission` on the activity. Adjust to your
|
||||
// actual machine name if different.
|
||||
activityRecord["Submission_Audit.stage_at_submission"] = stageAtSubmission;
|
||||
|
||||
await civi("Activity", "create", { values: activityRecord });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,26 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Theme — Food Co-op Initiative palette
|
||||
*
|
||||
* "leaf" is the primary green. Inspired by the warm, grassroots-organic
|
||||
* feel of fci.coop: not corporate teal, not jewel-toned. A balanced
|
||||
* field-green that sits comfortably alongside warm cream/stone neutrals.
|
||||
*
|
||||
* "stone" is the neutral ramp — slightly warm, never icy.
|
||||
*
|
||||
* Both are built as Tailwind 4 color tokens, so utility classes like
|
||||
* `bg-leaf-700`, `text-stone-600` work everywhere.
|
||||
* ─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-serif: var(--font-serif), ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
|
||||
--color-leaf-50: #f3f8f0;
|
||||
--color-leaf-100: #e3eed9;
|
||||
--color-leaf-200: #c8dcb3;
|
||||
--color-leaf-300: #a3c280;
|
||||
--color-leaf-400: #7ea455;
|
||||
--color-leaf-500: #5e8638;
|
||||
--color-leaf-600: #4a6c2a;
|
||||
--color-leaf-700: #3a5520;
|
||||
--color-leaf-800: #2c401a;
|
||||
--color-leaf-900: #1f2d12;
|
||||
|
||||
--color-stone-50: #fafaf7;
|
||||
--color-stone-100: #f3f2ed;
|
||||
--color-stone-200: #e7e4dc;
|
||||
--color-stone-300: #d4cfc1;
|
||||
--color-stone-400: #aaa494;
|
||||
--color-stone-500: #807a6a;
|
||||
--color-stone-600: #5b574b;
|
||||
--color-stone-700: #403d35;
|
||||
--color-stone-800: #2a2823;
|
||||
--color-stone-900: #1a1815;
|
||||
}
|
||||
|
||||
/* Base / reset */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
color-scheme: light;
|
||||
--background: var(--color-stone-50);
|
||||
--foreground: var(--color-stone-900);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Larger, more readable form controls on small screens */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Ensure focus is always perceptible (in addition to Tailwind's ring) */
|
||||
*:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Make Chrome's date input chevron less garish */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Prevent layout shift when the sticky submit bar appears */
|
||||
@media (min-width: 640px) {
|
||||
main {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Source_Serif_4 } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const serif = Source_Serif_4({
|
||||
variable: "--font-serif",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Co-op Check-in · Food Co-op Initiative",
|
||||
description: "Update your co-op's progress through the FCI organizing framework.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,9 +27,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${inter.variable} ${serif.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col font-sans">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
71
app/page.tsx
71
app/page.tsx
@@ -1,23 +1,60 @@
|
||||
'use client';
|
||||
import { Suspense } from "react";
|
||||
import { EngagementForm } from "@/components/EngagementForm";
|
||||
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
||||
import { formConfig } from "@/config/form";
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import StageForm from '@/components/StageForm';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function FormContainer() {
|
||||
const searchParams = useSearchParams();
|
||||
const contactId = searchParams.get('contactId') || '2';
|
||||
const orgId = searchParams.get('orgId') || '1';
|
||||
|
||||
return <StageForm contactId={contactId} orgId={orgId} />;
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ cid?: string; cs?: string }>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const { cid, cs } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-100 py-12">
|
||||
<Suspense fallback={<div className="text-center">Loading...</div>}>
|
||||
<FormContainer />
|
||||
</Suspense>
|
||||
</main>
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="main" className="flex-1 bg-stone-50">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:rounded focus:bg-white focus:px-3 focus:py-2 focus:shadow"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-stone-900 sm:text-3xl">
|
||||
{formConfig.title}
|
||||
</h1>
|
||||
{formConfig.subtitle && (
|
||||
<p className="mt-2 text-stone-700 leading-relaxed">{formConfig.subtitle}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{!cid || !cs ? (
|
||||
<MissingLinkParams />
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<EngagementForm config={formConfig} cid={cid} cs={cs} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingLinkParams() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-amber-200 bg-amber-50 px-6 py-6 text-amber-900"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">This link is missing required information.</h2>
|
||||
<p className="mt-2 leading-relaxed">
|
||||
Open the form using the personalized link from your email. If you no longer have it,
|
||||
please contact your engagement coordinator and ask for a fresh link.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user