Compare commits
24
Commits
229ef51537
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ebef6bc5 | ||
|
|
e4085aa0f3 | ||
|
|
b4f733ee65 | ||
|
|
5124010b8a | ||
|
|
0019996b15 | ||
|
|
e74462da0b | ||
|
|
49d0d24950 | ||
|
|
6850ff9dee | ||
|
|
5203dabeac | ||
|
|
13d7f6e93b | ||
|
|
d3b88d92c2 | ||
|
|
ade938eaaa | ||
|
|
8dfbbea0ba | ||
|
|
2dc8ec4e6d | ||
|
|
325615576a | ||
|
|
e42e3b70ef | ||
|
|
f74fd07ba5 | ||
|
|
e22c9226d8 | ||
|
|
6c3e8dd6a0 | ||
|
|
8ace5f41fe | ||
|
|
41467bd4cf | ||
|
|
b65bc6d0e0 | ||
|
|
50e719e1a9 | ||
|
|
63e73e7fe6 |
+75
-12
@@ -164,16 +164,42 @@ fill out the form to that Organization.
|
|||||||
|
|
||||||
## 5. Install the `webform-mw` Civi extension
|
## 5. Install the `webform-mw` Civi extension
|
||||||
|
|
||||||
The extension adds an **Engagement Report** tab to Organization
|
The extension does two jobs:
|
||||||
contact pages that embeds the staff report in an iframe.
|
|
||||||
|
- adds an **Engagement Report** tab to Organization contact pages that
|
||||||
|
embeds the staff report in an iframe; and
|
||||||
|
- exposes the **file-upload proxy route** `civicrm/webform-mw/upload`
|
||||||
|
(handled by `CRM_WebformMw_Page_Upload`) that the app's `/api/upload`
|
||||||
|
POSTs binary files to. This route was **added in v0.3.0** — a CRM
|
||||||
|
running an older version has the report tab but every form file
|
||||||
|
upload returns **502 Bad Gateway** (the app can't reach the route, so
|
||||||
|
`/api/upload` fails closed). **Production must be on v0.3.0 or later.**
|
||||||
|
|
||||||
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
|
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
|
||||||
`[civicrm.extensionsDir]` (usually
|
`[civicrm.extensionsDir]` (usually
|
||||||
`<civi-root>/sites/default/ext/`). The directory must be named
|
`<civi-root>/sites/default/ext/`). The directory must be named
|
||||||
exactly `webform-mw` (matches `<key>` in `info.xml`).
|
exactly `webform-mw` (matches `<key>` in `info.xml`). When
|
||||||
|
**upgrading** an already-installed extension, overwrite the existing
|
||||||
|
directory in place.
|
||||||
|
|
||||||
2. `Administer → System Settings → Extensions → Add new → Refresh`,
|
2. `Administer → System Settings → Extensions → Add new → Refresh`,
|
||||||
then **Install** next to "WebForm-mw".
|
then **Install** next to "WebForm-mw" (first install) or run the
|
||||||
|
**Upgrade** action if one is offered. If neither, **Disable** then
|
||||||
|
**Enable** the extension.
|
||||||
|
|
||||||
|
**Then flush caches** — `cv flush`, or
|
||||||
|
**Administer → System Settings → Cleanup Caches**. New menu routes
|
||||||
|
(like `civicrm/webform-mw/upload`) are only registered after the
|
||||||
|
router is rebuilt; copying files without a flush leaves the upload
|
||||||
|
route 404ing and uploads 502ing.
|
||||||
|
|
||||||
|
Confirm the upload route resolves (a `400` means the route is live;
|
||||||
|
a `404`/login redirect means the flush didn't take):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://crm.fci.coop/civicrm/webform-mw/upload
|
||||||
|
# → {"error":"Missing file part"} ✓ route exists
|
||||||
|
```
|
||||||
|
|
||||||
3. Configure — add to `civicrm.settings.php`:
|
3. Configure — add to `civicrm.settings.php`:
|
||||||
|
|
||||||
@@ -197,13 +223,20 @@ Full extension docs:
|
|||||||
|
|
||||||
## 6. CSP / `frame-ancestors` — app side
|
## 6. CSP / `frame-ancestors` — app side
|
||||||
|
|
||||||
The Next.js app's `/staff/report` route must allow the CiviCRM origin
|
The Next.js app's `/staff/report` and `/api/staff/file` routes must
|
||||||
in its `frame-ancestors` CSP, or the iframe will refuse to render.
|
allow every CiviCRM origin that will iframe them, or the browser will
|
||||||
|
refuse to render.
|
||||||
|
|
||||||
The build reads `CIVI_BASE_URL` and adds its origin to the CSP
|
Set **`CIVI_FRAME_ALLOWED_ORIGINS`** (comma-separated) in the app
|
||||||
automatically — so make sure `CIVI_BASE_URL` on the app deploy points
|
deploy env. Each origin needs the scheme:
|
||||||
at the **production** CRM origin (`https://crm.fci.coop`), not
|
|
||||||
`client.crm.fci.coop`.
|
```
|
||||||
|
CIVI_FRAME_ALLOWED_ORIGINS=https://crm.fci.coop,https://client.crm.fci.coop
|
||||||
|
```
|
||||||
|
|
||||||
|
Include both prod and any staging Civi origins you want to keep
|
||||||
|
embedding. If unset, the build falls back to the origin of
|
||||||
|
`CIVI_BASE_URL` (single-Civi compatibility).
|
||||||
|
|
||||||
Confirm after deploy:
|
Confirm after deploy:
|
||||||
|
|
||||||
@@ -211,8 +244,11 @@ Confirm after deploy:
|
|||||||
curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy
|
curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy
|
||||||
```
|
```
|
||||||
|
|
||||||
Should include `frame-ancestors 'self' https://crm.fci.coop` (or
|
Should include
|
||||||
whatever your production CRM origin is).
|
`frame-ancestors 'self' https://crm.fci.coop https://client.crm.fci.coop`
|
||||||
|
(or whatever list you configured). If you see only one origin and the
|
||||||
|
other Civi is failing to embed, the env var is missing or stale —
|
||||||
|
trigger a new build, not just a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,6 +302,11 @@ Run these against the production deploy:
|
|||||||
5. Open the target Organization in CiviCRM → **Engagement Report**
|
5. Open the target Organization in CiviCRM → **Engagement Report**
|
||||||
tab. The staff report should render the submission you just made.
|
tab. The staff report should render the submission you just made.
|
||||||
|
|
||||||
|
6. On the form, attach a file to any file field. It should upload
|
||||||
|
without error. A **502** here means the `webform-mw` extension on
|
||||||
|
this CRM is older than v0.3.0 (or the post-upgrade cache flush was
|
||||||
|
skipped) — see step 5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Change log
|
## Change log
|
||||||
@@ -276,3 +317,25 @@ so the rationale survives.
|
|||||||
- **2026-06-08** — Documented the field-242 "Unknown" default issue
|
- **2026-06-08** — Documented the field-242 "Unknown" default issue
|
||||||
after a production submission was stamped `Stage = "Unknown"`.
|
after a production submission was stamped `Stage = "Unknown"`.
|
||||||
Cleared via `CustomField.update`; see step 2.
|
Cleared via `CustomField.update`; see step 2.
|
||||||
|
- **2026-06-18** — Extension bumped to **v0.3.1** (lightbox fix). The
|
||||||
|
staff-report iframe is expanded to full content height, so the
|
||||||
|
attachment lightbox (a modal inside the iframe) centered in the whole
|
||||||
|
document and sat off-screen unless the parent page was scrolled to the
|
||||||
|
middle. `Tab.tpl` now broadcasts the iframe's visible slice
|
||||||
|
(`webform-mw-viewport`) and the app pins/sizes the lightbox to it.
|
||||||
|
**Re-copy the extension to prod and `cv flush`** (template change) —
|
||||||
|
same procedure as step 5; no settings change.
|
||||||
|
- **2026-06-16** — Step 5 now states the file-upload feature requires
|
||||||
|
extension **v0.3.0+** (the `civicrm/webform-mw/upload` proxy route)
|
||||||
|
and documents the upgrade-vs-first-install path plus the mandatory
|
||||||
|
cache flush. Surfaced when production uploads returned **502**: prod
|
||||||
|
Civi still had v0.1.0, which has the Engagement Report tab but not
|
||||||
|
the upload route, so `/api/upload` couldn't reach it and failed
|
||||||
|
closed. Added a post-deploy upload check as step 6 of section 8.
|
||||||
|
- **2026-06-16** — Step 6 split off `CIVI_FRAME_ALLOWED_ORIGINS` as a
|
||||||
|
separate env from `CIVI_BASE_URL`. Surfaced after the production
|
||||||
|
cutover hit a `frame-ancestors` block: the app's CSP only listed the
|
||||||
|
staging Civi origin (derived from `CIVI_BASE_URL`), so prod
|
||||||
|
(`crm.fci.coop`) couldn't iframe `survey.fci.coop`. The new var
|
||||||
|
takes a comma-separated list so one app deploy can be embedded by
|
||||||
|
both dev and prod Civi.
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
|
||||||
|
*
|
||||||
|
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
||||||
|
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
||||||
|
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
|
||||||
|
* always sends `attachment`, which forces a download — that's correct for
|
||||||
|
* its UI but wrong for an embedded preview.
|
||||||
|
*
|
||||||
|
* Authorization layers:
|
||||||
|
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
|
||||||
|
* 2. Per-file ownership probe: the requested fileId must appear as a
|
||||||
|
* value in one of the org's file-typed custom-field columns, OR in
|
||||||
|
* one of the org's activities' file-typed columns. This is the same
|
||||||
|
* ownership chain the staff report uses to surface the file in the
|
||||||
|
* first place. NOT enforced via civicrm_entity_file — that linkage
|
||||||
|
* is metadata-only here (upload anchors to submitter, not org).
|
||||||
|
*
|
||||||
|
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
|
||||||
|
* which includes a freshly-minted `fcs` JWT. We don't carry any user
|
||||||
|
* session cookies — that JWT is the auth for `/civicrm/file`.
|
||||||
|
*
|
||||||
|
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
|
||||||
|
* meaningful (there are no real bytes to serve).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||||
|
import { civi, civi3 } from "@/lib/civicrm";
|
||||||
|
import { resolveMime } from "@/lib/mime.mjs";
|
||||||
|
|
||||||
|
const MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Only mimes the lightbox actually renders inline are allowed through the
|
||||||
|
// `Content-Disposition: inline` path. Everything else gets coerced to
|
||||||
|
// application/octet-stream + attachment so it always downloads.
|
||||||
|
//
|
||||||
|
// Why this matters: our /api/upload route validates uploaded mimes
|
||||||
|
// against an allowlist, but the underlying civicrm_file row can be
|
||||||
|
// populated by other paths too — a Civi admin uploading directly through
|
||||||
|
// the CiviCRM UI, a future Civi import, etc. If any of those routes
|
||||||
|
// stored mime_type="text/html" or "image/svg+xml", an inline serve from
|
||||||
|
// this same-origin proxy would let arbitrary script run against
|
||||||
|
// /api/* and the staff key. Defence in depth: don't trust mime_type
|
||||||
|
// when the response carries `inline`.
|
||||||
|
//
|
||||||
|
// Explicitly NOT in this set: svg (script-bearing), html, xml, any text/*.
|
||||||
|
const SAFE_INLINE_MIMES = new Set<string>([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"application/pdf",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isCiviStubMode(): boolean {
|
||||||
|
return !(
|
||||||
|
process.env.CIVI_BASE_URL &&
|
||||||
|
process.env.CIVI_API_KEY &&
|
||||||
|
process.env.CIVI_SITE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentRow {
|
||||||
|
id: string | number;
|
||||||
|
url?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
|
||||||
|
const res = await civi3<AttachmentRow>("Attachment", "get", {
|
||||||
|
id: fileId,
|
||||||
|
return: "id,url,mime_type,name",
|
||||||
|
sequential: 1,
|
||||||
|
});
|
||||||
|
return res.values?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same groups /api/staff/report scans. Kept in sync deliberately: this
|
||||||
|
// proxy must only authorise files reachable through the same set of
|
||||||
|
// fields the report itself surfaces.
|
||||||
|
const ACTIVITY_GROUP_NAMES = [
|
||||||
|
"Check_in_data__organizing_",
|
||||||
|
"Stage_1",
|
||||||
|
"Stage_2",
|
||||||
|
"Stage_3",
|
||||||
|
"Stage_4",
|
||||||
|
"Stage_5",
|
||||||
|
];
|
||||||
|
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
|
||||||
|
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
|
||||||
|
|
||||||
|
interface FileFieldRefs {
|
||||||
|
org: string[];
|
||||||
|
activity: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover the APIv4 field references for File-typed custom fields the
|
||||||
|
* staff report cares about. Returns refs like "Stage_1.Vision_Upload"
|
||||||
|
* split by whether they live on the Organization Contact or on Activities.
|
||||||
|
*/
|
||||||
|
async function discoverFileFieldRefs(): Promise<FileFieldRefs> {
|
||||||
|
const res = await civi<{
|
||||||
|
name: string;
|
||||||
|
data_type: string;
|
||||||
|
"custom_group_id.name": string;
|
||||||
|
}>("CustomField", "get", {
|
||||||
|
select: ["name", "data_type", "custom_group_id.name"],
|
||||||
|
where: [
|
||||||
|
["custom_group_id.name", "IN", [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]],
|
||||||
|
["data_type", "=", "File"],
|
||||||
|
["is_active", "=", true],
|
||||||
|
],
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const org: string[] = [];
|
||||||
|
const activity: string[] = [];
|
||||||
|
for (const row of res.values ?? []) {
|
||||||
|
const group = row["custom_group_id.name"];
|
||||||
|
const ref = `${group}.${row.name}`;
|
||||||
|
if (ORG_GROUP_NAMES.includes(group)) org.push(ref);
|
||||||
|
else if (ACTIVITY_GROUP_NAMES.includes(group)) activity.push(ref);
|
||||||
|
}
|
||||||
|
return { org, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm fileId is reachable from this org through the same column
|
||||||
|
* ownership the staff report uses.
|
||||||
|
*
|
||||||
|
* Implementation: fetch the org row and the org's activities, selecting
|
||||||
|
* the file-typed custom-field columns, then check in JS whether any
|
||||||
|
* column value equals fileId. We avoid APIv4 OR clauses against custom
|
||||||
|
* fields because that combination has been fragile in practice; the
|
||||||
|
* SELECT-and-compare path is the same shape /api/staff/report uses
|
||||||
|
* successfully.
|
||||||
|
*
|
||||||
|
* We do NOT use civicrm_entity_file for this check: our upload route
|
||||||
|
* anchors files to the submitter's contact id (not the org's), so that
|
||||||
|
* linkage doesn't reflect ownership. The custom-field column is the
|
||||||
|
* authoritative chain.
|
||||||
|
*/
|
||||||
|
async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean> {
|
||||||
|
let refs: FileFieldRefs;
|
||||||
|
try {
|
||||||
|
refs = await discoverFileFieldRefs();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[staff/file] CustomField.get failed:", msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = (rows: Array<Record<string, unknown>>, fieldRefs: string[]): boolean => {
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const ref of fieldRefs) {
|
||||||
|
const v = row[ref];
|
||||||
|
if (v === undefined || v === null || v === "") continue;
|
||||||
|
if (Number(v) === fileId) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgProbe =
|
||||||
|
refs.org.length > 0
|
||||||
|
? civi<Record<string, unknown>>("Contact", "get", {
|
||||||
|
where: [["id", "=", orgId]],
|
||||||
|
select: ["id", ...refs.org],
|
||||||
|
limit: 1,
|
||||||
|
}).catch((e: unknown) => {
|
||||||
|
console.error(
|
||||||
|
"[staff/file] Contact.get probe failed:",
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
return { values: [] as Array<Record<string, unknown>> };
|
||||||
|
})
|
||||||
|
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
|
||||||
|
|
||||||
|
const activityProbe =
|
||||||
|
refs.activity.length > 0
|
||||||
|
? civi<Record<string, unknown>>("Activity", "get", {
|
||||||
|
where: [
|
||||||
|
["target_contact_id", "=", orgId],
|
||||||
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
||||||
|
],
|
||||||
|
select: ["id", ...refs.activity],
|
||||||
|
limit: 500,
|
||||||
|
}).catch((e: unknown) => {
|
||||||
|
console.error(
|
||||||
|
"[staff/file] Activity.get probe failed:",
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
return { values: [] as Array<Record<string, unknown>> };
|
||||||
|
})
|
||||||
|
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
|
||||||
|
|
||||||
|
const [orgRes, actRes] = await Promise.all([orgProbe, activityProbe]);
|
||||||
|
return (
|
||||||
|
matches(orgRes.values ?? [], refs.org) || matches(actRes.values ?? [], refs.activity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const key = url.searchParams.get("key");
|
||||||
|
const idStr = url.searchParams.get("id");
|
||||||
|
const orgStr = url.searchParams.get("org");
|
||||||
|
const wantsDownload = url.searchParams.get("dl") === "1";
|
||||||
|
|
||||||
|
if (!isStaffKeyValid(key)) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = Number(idStr);
|
||||||
|
const orgId = Number(orgStr);
|
||||||
|
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
|
||||||
|
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
|
||||||
|
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCiviStubMode()) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let row: AttachmentRow | null;
|
||||||
|
try {
|
||||||
|
row = await fetchAttachment(fileId);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[staff/file] Attachment.get failed:", msg);
|
||||||
|
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
|
||||||
|
}
|
||||||
|
if (!row || !row.url) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorisation: prove this file actually belongs to `orgId` via the
|
||||||
|
// custom-field columns the staff report itself surfaces. Without this a
|
||||||
|
// staff key (which is org-agnostic) could be used to enumerate file ids
|
||||||
|
// outside any report context.
|
||||||
|
const owned = await fileBelongsToOrg(fileId, orgId);
|
||||||
|
if (!owned) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signed URL Civi returns is sometimes a relative path (depends on
|
||||||
|
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
|
||||||
|
// absolute URL.
|
||||||
|
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
|
||||||
|
const upstream = row.url.startsWith("http")
|
||||||
|
? row.url
|
||||||
|
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
|
||||||
|
|
||||||
|
let upstreamRes: Response;
|
||||||
|
try {
|
||||||
|
upstreamRes = await fetch(upstream, { cache: "no-store" });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[staff/file] upstream fetch failed:", msg);
|
||||||
|
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
|
||||||
|
}
|
||||||
|
if (!upstreamRes.ok || !upstreamRes.body) {
|
||||||
|
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLengthRaw = upstreamRes.headers.get("content-length");
|
||||||
|
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveMime(
|
||||||
|
row.mime_type ?? upstreamRes.headers.get("content-type"),
|
||||||
|
row.name,
|
||||||
|
);
|
||||||
|
// Inline is only allowed for mimes the lightbox actually renders. Anything
|
||||||
|
// else (incl. an attacker-controlled mime_type from a non-allowlisted
|
||||||
|
// upload path) is downgraded to octet-stream + attachment so the browser
|
||||||
|
// downloads instead of executing.
|
||||||
|
const inlineSafe = !wantsDownload && SAFE_INLINE_MIMES.has(resolved);
|
||||||
|
const mime = inlineSafe ? resolved : "application/octet-stream";
|
||||||
|
const disposition = inlineSafe ? "inline" : "attachment";
|
||||||
|
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": mime,
|
||||||
|
"Content-Disposition": `${disposition}; filename="${safeName}"`,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
};
|
||||||
|
// For attachment responses, layer on a strict sandbox CSP as defence in
|
||||||
|
// depth — the file is being downloaded so the CSP has no UX effect, but
|
||||||
|
// if a future change ever flips it to inline by mistake, the sandbox
|
||||||
|
// blocks script + plugins. For inline responses we rely on the SAFE
|
||||||
|
// MIME allowlist + nosniff + the app's global CSP, because a strict
|
||||||
|
// `sandbox` here breaks Chrome's PDF viewer (it can't load fonts or
|
||||||
|
// plugin-mode rendering under sandbox).
|
||||||
|
if (!inlineSafe) {
|
||||||
|
headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
|
||||||
|
}
|
||||||
|
if (Number.isFinite(contentLength)) {
|
||||||
|
headers["Content-Length"] = String(contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(upstreamRes.body, { status: 200, headers });
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||||
import { civi } from "@/lib/civicrm";
|
import { civi, civi3 } from "@/lib/civicrm";
|
||||||
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
||||||
import type {
|
import type {
|
||||||
StaffReportPayload,
|
StaffReportPayload,
|
||||||
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
|
|||||||
{
|
{
|
||||||
activityId: 9012,
|
activityId: 9012,
|
||||||
date: daysAgo(3),
|
date: daysAgo(3),
|
||||||
value: { id: 4242, file_name: "co-op-vision.pdf" },
|
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -290,10 +290,21 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
|
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
|
||||||
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
|
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
|
||||||
|
|
||||||
// 2. Org Contact (display_name + every org-side custom field).
|
// 2. Org Contact (display_name + every org-side custom field, plus file-name
|
||||||
const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)];
|
// joins for any file-typed org fields so the staff report can render a
|
||||||
// 3. Activities (every activity-side custom field + file-name/url joins).
|
// label next to the link).
|
||||||
const fileFieldRefs = activityDescriptors
|
const orgFileNameRefs = orgDescriptors
|
||||||
|
.filter((d) => d.render === "file")
|
||||||
|
.map((d) => `${d.civiField}.file_name`);
|
||||||
|
const orgSelect = [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"contact_type",
|
||||||
|
...orgDescriptors.map((d) => d.civiField),
|
||||||
|
...orgFileNameRefs,
|
||||||
|
];
|
||||||
|
// 3. Activities (every activity-side custom field + file-name joins).
|
||||||
|
const activityFileNameRefs = activityDescriptors
|
||||||
.filter((d) => d.render === "file")
|
.filter((d) => d.render === "file")
|
||||||
.map((d) => `${d.civiField}.file_name`);
|
.map((d) => `${d.civiField}.file_name`);
|
||||||
const activitySelect = [
|
const activitySelect = [
|
||||||
@@ -303,7 +314,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
"source_contact_id.display_name",
|
"source_contact_id.display_name",
|
||||||
ACTIVITY_STAGE_FIELD,
|
ACTIVITY_STAGE_FIELD,
|
||||||
...activityDescriptors.map((d) => d.civiField),
|
...activityDescriptors.map((d) => d.civiField),
|
||||||
...fileFieldRefs,
|
...activityFileNameRefs,
|
||||||
];
|
];
|
||||||
// 4. Option groups for every select/multiselect + the stage option group.
|
// 4. Option groups for every select/multiselect + the stage option group.
|
||||||
const optionGroupIds = Array.from(
|
const optionGroupIds = Array.from(
|
||||||
@@ -341,6 +352,66 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
|
|
||||||
const rows = activityRes.values ?? [];
|
const rows = activityRes.values ?? [];
|
||||||
|
|
||||||
|
// Civi serves uploaded files at /civicrm/file?id=X&fcs=<JWT>; the fcs is
|
||||||
|
// an HS256 JWT signed with the site key. Without it, /civicrm/file
|
||||||
|
// crashes on a null JWT decode. APIv4 Attachment isn't exposed on this
|
||||||
|
// install, but APIv3 Attachment.get is — and it returns `url` with the
|
||||||
|
// fcs already baked in. We pass the URL straight through to the client.
|
||||||
|
const fileIds = new Set<number>();
|
||||||
|
const collectId = (v: unknown) => {
|
||||||
|
if (v === null || v === undefined || v === "") return;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
if (Number.isFinite(n) && n > 0) fileIds.add(n);
|
||||||
|
};
|
||||||
|
for (const d of activityDescriptors) {
|
||||||
|
if (d.render !== "file") continue;
|
||||||
|
for (const row of rows) collectId(row[d.civiField]);
|
||||||
|
}
|
||||||
|
for (const d of orgDescriptors) {
|
||||||
|
if (d.render !== "file") continue;
|
||||||
|
collectId(org[d.civiField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlByFileId = new Map<number, string>();
|
||||||
|
const mimeByFileId = new Map<number, string>();
|
||||||
|
if (fileIds.size > 0) {
|
||||||
|
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
|
||||||
|
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
|
||||||
|
// on htmlentities(array). The same call with `id: <single>` works
|
||||||
|
// (verified in API Explorer), so we loop one call per file id. Reports
|
||||||
|
// typically reference a handful of files, so the round-trip cost is
|
||||||
|
// small. Each request is independent; we issue them in parallel.
|
||||||
|
const lookups = await Promise.allSettled(
|
||||||
|
Array.from(fileIds).map((fid) =>
|
||||||
|
civi3<{ id: string | number; url?: string; mime_type?: string }>(
|
||||||
|
"Attachment",
|
||||||
|
"get",
|
||||||
|
{
|
||||||
|
id: fid,
|
||||||
|
return: "id,url,mime_type",
|
||||||
|
sequential: 1,
|
||||||
|
},
|
||||||
|
).then((r) => ({ fid, row: r.values?.[0] })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const result of lookups) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
console.warn(
|
||||||
|
"[staff/report] Attachment.get (v3) failed for one file:",
|
||||||
|
result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { fid, row } = result.value;
|
||||||
|
if (row && typeof row.url === "string" && row.url.length > 0) {
|
||||||
|
urlByFileId.set(fid, row.url);
|
||||||
|
}
|
||||||
|
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
|
||||||
|
mimeByFileId.set(fid, row.mime_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Activity summaries.
|
// Activity summaries.
|
||||||
const activities: ActivitySummary[] = rows.map((r) => ({
|
const activities: ActivitySummary[] = rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -373,11 +444,24 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
groupKind: "org",
|
groupKind: "org",
|
||||||
fields: orgDescriptors.map((d) => {
|
fields: orgDescriptors.map((d) => {
|
||||||
const raw = org[d.civiField];
|
const raw = org[d.civiField];
|
||||||
const history: FieldHistoryEntry[] =
|
if (raw === null || raw === undefined || raw === "") {
|
||||||
raw === null || raw === undefined || raw === ""
|
return { descriptor: d, history: [] };
|
||||||
? []
|
}
|
||||||
: [{ activityId: 0, date: "", value: raw }];
|
let value: unknown = raw;
|
||||||
return { descriptor: d, history };
|
if (d.render === "file") {
|
||||||
|
const fid = Number(raw);
|
||||||
|
const fname = org[`${d.civiField}.file_name`];
|
||||||
|
value = {
|
||||||
|
id: raw,
|
||||||
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
descriptor: d,
|
||||||
|
history: [{ activityId: 0, date: "", value }],
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -400,9 +484,12 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
let value: unknown = v;
|
let value: unknown = v;
|
||||||
if (d.render === "file") {
|
if (d.render === "file") {
|
||||||
const fname = row[`${d.civiField}.file_name`];
|
const fname = row[`${d.civiField}.file_name`];
|
||||||
|
const fid = Number(v);
|
||||||
value = {
|
value = {
|
||||||
id: v,
|
id: v,
|
||||||
file_name: typeof fname === "string" ? fname : undefined,
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
||||||
|
|||||||
+30
-30
@@ -25,7 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
import { civiMultipart, verifyChecksum } from "@/lib/civicrm";
|
||||||
import { allFields } from "@/config/form";
|
import { allFields } from "@/config/form";
|
||||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
@@ -205,44 +205,44 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIv4 File.create with inline base64 content. Spike (June 2026) on
|
// Path history exhausted before we got here:
|
||||||
// this Civi instance confirmed:
|
// 1. APIv4 File.create + content:base64 → stores base64 text on disk.
|
||||||
// - APIv4 Attachment is NOT exposed
|
// 2. APIv3 Attachment.create + content:base64 → same; v3 doesn't decode either.
|
||||||
// - APIv4 File + EntityFile ARE exposed
|
// 3. APIv3 Attachment.create + multipart file → /civicrm/ajax/rest drops $_FILES.
|
||||||
// - File.create with file_name + mime_type + content (base64) returns
|
|
||||||
// a usable file id
|
|
||||||
// - Custom file fields store the file id directly in the custom
|
|
||||||
// column, so EntityFile linkage is not needed for our use case
|
|
||||||
// - Round-trip via Contact.update + Contact.get .file_name join works
|
|
||||||
//
|
//
|
||||||
// We do not create EntityFile rows here. Civi's custom-field renderer
|
// The one path Civi reliably honors is options.move-file: APIv3
|
||||||
// joins through the custom column to civicrm_file directly, and the
|
// Attachment.create with a server-side filesystem path. PHP's $_FILES
|
||||||
// form-side prefill/read code in /api/data uses the same join.
|
// preserves binary natively, so the WebForm-mw Civi extension exposes
|
||||||
|
// a tiny multipart endpoint that copies the upload's tmp_name into
|
||||||
|
// Attachment.create as options.move-file. We POST the file there.
|
||||||
//
|
//
|
||||||
// The returned id is what the frontend stores in RHF state and
|
// Attachment.create requires (entity_table, entity_id); our custom-field
|
||||||
// ultimately sends as the field value on /api/submit. /api/submit then
|
// flow uses the returned file id directly in the custom column (no
|
||||||
// writes that id to the activity custom field (for stage-N file fields)
|
// entity_file linkage needed), so we anchor to the form-filler's contact
|
||||||
// or to the org contact custom field (for Food_Co_op_Organizing.*).
|
// id and accept the metadata-only civicrm_entity_file row.
|
||||||
//
|
//
|
||||||
// Orphan files: if the user uploads and then abandons the form, the
|
// Orphan files: civicrm_file rows linger if the user uploads then
|
||||||
// File row persists with no entity referencing it. Cleanup is handled
|
// abandons. Cleanup is handled by a CiviCRM scheduled job (separately
|
||||||
// by a CiviCRM scheduled job (configured separately by the Civi admin)
|
// configured by the Civi admin).
|
||||||
// that deletes File rows with no inbound references older than ~24h.
|
|
||||||
let fileId: number;
|
let fileId: number;
|
||||||
try {
|
try {
|
||||||
const res = await civi<{ id: number }>("File", "create", {
|
const res = await civiMultipart<{ id?: number; name?: string; error?: string }>(
|
||||||
values: {
|
"civicrm/webform-mw/upload",
|
||||||
file_name: safeName,
|
{
|
||||||
|
entity_table: "civicrm_contact",
|
||||||
|
entity_id: String(Number(cid)),
|
||||||
|
name: safeName,
|
||||||
mime_type: clientMime,
|
mime_type: clientMime,
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
|
||||||
},
|
},
|
||||||
});
|
{ bytes, filename: safeName, mime: clientMime },
|
||||||
const id = res.values?.[0]?.id;
|
);
|
||||||
if (!id) throw new Error("File.create returned no id");
|
if (!res || !res.id || !Number.isFinite(res.id)) {
|
||||||
fileId = Number(id);
|
throw new Error(res?.error ?? "Upload endpoint returned no id");
|
||||||
|
}
|
||||||
|
fileId = Number(res.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error("[upload] File.create failed:", msg);
|
console.error("[upload] Civi extension upload failed:", msg);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Could not save the upload. Please try again." },
|
{ error: "Could not save the upload. Please try again." },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File redirect page.
|
||||||
|
*
|
||||||
|
* External callers (the WebForm-mw staff report iframe) link to this route
|
||||||
|
* with `id=<civicrm_file.id>` and rely on us to mint the `fcs` JWT that
|
||||||
|
* Civi's `/civicrm/file` handler requires. The JWT is signed with the
|
||||||
|
* site's crypto key, which we have here because we're running inside
|
||||||
|
* CiviCRM; the Next.js side doesn't.
|
||||||
|
*
|
||||||
|
* URL shape: /civicrm/webform-mw/file?id=<fileId>
|
||||||
|
*
|
||||||
|
* Authorization: minting an fcs JWT is effectively "issuing a bearer
|
||||||
|
* credential for this file" — once issued, /civicrm/file will serve the
|
||||||
|
* bytes against any session. To avoid becoming a credential-laundering
|
||||||
|
* IDOR, we look up the file's linked entity and run the entity-type's
|
||||||
|
* native permission check before signing. The base `access CiviCRM`
|
||||||
|
* permission only gates reaching this endpoint at all; per-record ACLs
|
||||||
|
* happen here. Unknown entity types are denied by default.
|
||||||
|
*/
|
||||||
|
class CRM_WebformMw_Page_File extends CRM_Core_Page {
|
||||||
|
|
||||||
|
public function run() {
|
||||||
|
$fileId = (int) CRM_Utils_Request::retrieve('id', 'Positive', $this, TRUE);
|
||||||
|
|
||||||
|
// Look up the file's linked entity. We need both entity_table and
|
||||||
|
// entity_id — entity_table drives which permission API to call.
|
||||||
|
$dao = CRM_Core_DAO::executeQuery(
|
||||||
|
"SELECT entity_table, entity_id FROM civicrm_entity_file WHERE file_id = %1 LIMIT 1",
|
||||||
|
[1 => [$fileId, 'Positive']]
|
||||||
|
);
|
||||||
|
if (!$dao->fetch()) {
|
||||||
|
CRM_Utils_System::permissionDenied();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$entityTable = (string) $dao->entity_table;
|
||||||
|
$entityId = (int) $dao->entity_id;
|
||||||
|
|
||||||
|
if (!$this->canViewEntity($entityTable, $entityId)) {
|
||||||
|
CRM_Utils_System::permissionDenied();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a short-lived fcs JWT. The token is minted at click time (not
|
||||||
|
// at report-render time), so 10 minutes covers normal redirect-and-fetch
|
||||||
|
// latency without making the URL a long-lived bearer credential.
|
||||||
|
$payload = [
|
||||||
|
'exp' => time() + 60 * 10,
|
||||||
|
'civi.file' => (string) $fileId,
|
||||||
|
];
|
||||||
|
$fcs = \Civi::service('crypto.jwt')->encode($payload);
|
||||||
|
|
||||||
|
$url = CRM_Utils_System::url(
|
||||||
|
'civicrm/file',
|
||||||
|
"reset=1&id={$fileId}&eid={$entityId}&fcs=" . urlencode($fcs),
|
||||||
|
FALSE,
|
||||||
|
NULL,
|
||||||
|
FALSE,
|
||||||
|
TRUE
|
||||||
|
);
|
||||||
|
CRM_Utils_System::redirect($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the current Civi user can view the entity a file is
|
||||||
|
* linked to. Restricted to the entity types the WebForm-mw staff report
|
||||||
|
* actually surfaces (activity custom-field files and contact custom-field
|
||||||
|
* files); everything else denies. New entity types should be added here
|
||||||
|
* deliberately so we don't accidentally widen the surface.
|
||||||
|
*/
|
||||||
|
private function canViewEntity(string $entityTable, int $entityId): bool {
|
||||||
|
if ($entityId <= 0) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
switch ($entityTable) {
|
||||||
|
case 'civicrm_activity':
|
||||||
|
return CRM_Activity_BAO_Activity::checkPermission(
|
||||||
|
$entityId,
|
||||||
|
CRM_Core_Permission::VIEW
|
||||||
|
);
|
||||||
|
case 'civicrm_contact':
|
||||||
|
return CRM_Contact_BAO_Contact_Permission::allow(
|
||||||
|
$entityId,
|
||||||
|
CRM_Core_Permission::VIEW
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File upload proxy.
|
||||||
|
*
|
||||||
|
* External callers (the WebForm-mw /api/upload route on Amplify) POST a
|
||||||
|
* multipart request here with a `file` part. We hand the uploaded temp
|
||||||
|
* path to APIv3 Attachment.create via `options.move-file`, which is the
|
||||||
|
* one upload pathway Civi reliably honors on this install:
|
||||||
|
*
|
||||||
|
* - APIv4 File.create + content:base64 -> stores base64 text on disk.
|
||||||
|
* - APIv3 Attachment.create + content:b64 -> same.
|
||||||
|
* - APIv3 Attachment.create + multipart -> /civicrm/ajax/rest doesn't
|
||||||
|
* expose $_FILES to the
|
||||||
|
* action, so "file" is
|
||||||
|
* silently ignored.
|
||||||
|
* - APIv3 Attachment.create + options.move-file -> WORKS. Civi reads
|
||||||
|
* the path, moves the file
|
||||||
|
* into civicrm.files/upload,
|
||||||
|
* writes correct bytes.
|
||||||
|
*
|
||||||
|
* Required POST fields:
|
||||||
|
* file the binary (multipart `file` part)
|
||||||
|
* entity_table e.g. "civicrm_contact" (per Attachment.create contract)
|
||||||
|
* entity_id the entity id to link to
|
||||||
|
* name (optional) file_name; defaults to the upload's name
|
||||||
|
* mime_type (optional) defaults to the upload's reported type
|
||||||
|
*
|
||||||
|
* Returns JSON: { id, name } on success, { error } with 4xx/5xx otherwise.
|
||||||
|
*
|
||||||
|
* Authorization: `access CiviCRM`. AuthX is expected to authenticate the
|
||||||
|
* Bearer + Site-Key headers WebForm-mw sends.
|
||||||
|
*/
|
||||||
|
class CRM_WebformMw_Page_Upload extends CRM_Core_Page {
|
||||||
|
|
||||||
|
public function run() {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
$this->jsonError('POST required', 405);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CRM_Core_Permission::check('access CiviCRM')) {
|
||||||
|
$this->jsonError('Permission denied', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
|
||||||
|
$this->jsonError('Missing file part', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$upload = $_FILES['file'];
|
||||||
|
if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
$this->jsonError('Upload failed (php error ' . (int) $upload['error'] . ')', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])) {
|
||||||
|
$this->jsonError('Invalid upload tmp path', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityTable = (string) ($_POST['entity_table'] ?? '');
|
||||||
|
$entityId = (int) ($_POST['entity_id'] ?? 0);
|
||||||
|
// Whitelist entity tables to mirror the redirect route's defensive scope.
|
||||||
|
if (!in_array($entityTable, ['civicrm_contact', 'civicrm_activity'], TRUE)) {
|
||||||
|
$this->jsonError('Invalid entity_table', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($entityId <= 0) {
|
||||||
|
$this->jsonError('Invalid entity_id', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = (string) ($_POST['name'] ?? $upload['name'] ?? 'upload');
|
||||||
|
$mime = (string) ($_POST['mime_type'] ?? $upload['type'] ?? 'application/octet-stream');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = civicrm_api3('Attachment', 'create', [
|
||||||
|
'entity_table' => $entityTable,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'name' => $name,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'options' => [
|
||||||
|
'move-file' => $upload['tmp_name'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$fileId = NULL;
|
||||||
|
if (!empty($result['id'])) {
|
||||||
|
$fileId = (int) $result['id'];
|
||||||
|
}
|
||||||
|
elseif (!empty($result['values']) && is_array($result['values'])) {
|
||||||
|
$first = reset($result['values']);
|
||||||
|
if (!empty($first['id'])) {
|
||||||
|
$fileId = (int) $first['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$fileId) {
|
||||||
|
throw new Exception('Attachment.create returned no id');
|
||||||
|
}
|
||||||
|
$this->jsonOk(['id' => $fileId, 'name' => $name]);
|
||||||
|
}
|
||||||
|
catch (Throwable $e) {
|
||||||
|
$this->jsonError('Attachment.create failed: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jsonOk(array $payload): void {
|
||||||
|
header('Content-Type: application/json', TRUE, 200);
|
||||||
|
echo json_encode($payload);
|
||||||
|
CRM_Utils_System::civiExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jsonError(string $message, int $status): void {
|
||||||
|
header('Content-Type: application/json', TRUE, $status);
|
||||||
|
echo json_encode(['error' => $message]);
|
||||||
|
CRM_Utils_System::civiExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
||||||
</urls>
|
</urls>
|
||||||
<releaseDate>2026-06-05</releaseDate>
|
<releaseDate>2026-06-05</releaseDate>
|
||||||
<version>0.1.0</version>
|
<version>0.3.1</version>
|
||||||
<develStage>beta</develStage>
|
<develStage>beta</develStage>
|
||||||
<compatibility>
|
<compatibility>
|
||||||
<ver>5.50</ver>
|
<ver>5.50</ver>
|
||||||
|
|||||||
@@ -22,7 +22,41 @@
|
|||||||
// Add a little headroom so the report's own bottom padding isn't clipped.
|
// Add a little headroom so the report's own bottom padding isn't clipped.
|
||||||
var h = Math.max(600, Math.floor(d.height) + 24);
|
var h = Math.max(600, Math.floor(d.height) + 24);
|
||||||
frame.style.height = h + 'px';
|
frame.style.height = h + 'px';
|
||||||
|
// Geometry changed — tell the child where its visible slice now is.
|
||||||
|
scheduleViewport();
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
|
// The iframe is expanded to full content height, so the report has no
|
||||||
|
// internal scroll context. A modal opened inside it (the attachment
|
||||||
|
// lightbox) would center in the full iframe and sit off-screen. Broadcast
|
||||||
|
// the iframe's currently-visible region (in the child's own content
|
||||||
|
// coordinates) so the lightbox can pin and size itself to it. The child
|
||||||
|
// listens for 'webform-mw-viewport'.
|
||||||
|
var ticking = false;
|
||||||
|
var raf = window.requestAnimationFrame || function (cb) { return setTimeout(cb, 16); };
|
||||||
|
function postViewport() {
|
||||||
|
ticking = false;
|
||||||
|
if (!frame.contentWindow) return;
|
||||||
|
var r = frame.getBoundingClientRect();
|
||||||
|
var winH = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
var visibleTop = Math.max(0, r.top);
|
||||||
|
var visibleBottom = Math.min(winH, r.bottom);
|
||||||
|
frame.contentWindow.postMessage({
|
||||||
|
type: 'webform-mw-viewport',
|
||||||
|
// px from the iframe's content top down to the first visible row.
|
||||||
|
top: Math.max(0, -r.top),
|
||||||
|
height: Math.max(0, visibleBottom - visibleTop)
|
||||||
|
}, APP_ORIGIN || '*');
|
||||||
|
}
|
||||||
|
function scheduleViewport() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
raf(postViewport);
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', scheduleViewport, { passive: true });
|
||||||
|
window.addEventListener('resize', scheduleViewport);
|
||||||
|
window.addEventListener('load', scheduleViewport);
|
||||||
|
scheduleViewport();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{else}
|
{else}
|
||||||
|
|||||||
@@ -6,4 +6,16 @@
|
|||||||
<page_callback>CRM_WebformMw_Page_Tab</page_callback>
|
<page_callback>CRM_WebformMw_Page_Tab</page_callback>
|
||||||
<access_arguments>access CiviCRM</access_arguments>
|
<access_arguments>access CiviCRM</access_arguments>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<path>civicrm/webform-mw/file</path>
|
||||||
|
<title>WebForm-mw file redirect</title>
|
||||||
|
<page_callback>CRM_WebformMw_Page_File</page_callback>
|
||||||
|
<access_arguments>access CiviCRM</access_arguments>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<path>civicrm/webform-mw/upload</path>
|
||||||
|
<title>WebForm-mw file upload proxy</title>
|
||||||
|
<page_callback>CRM_WebformMw_Page_Upload</page_callback>
|
||||||
|
<access_arguments>access CiviCRM</access_arguments>
|
||||||
|
</item>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -322,9 +322,8 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"Submit your check-in?\n\n" +
|
"Submit your survey?\n\n" +
|
||||||
"Please confirm your responses are complete and ready. " +
|
"Please confirm your responses are complete and ready. ",
|
||||||
"After submitting, you can come back later and submit another check-in with updates.",
|
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
setSubmitState({ kind: "submitting" });
|
setSubmitState({ kind: "submitting" });
|
||||||
@@ -759,7 +758,7 @@ function SuccessDestination({
|
|||||||
onClick={onAnother}
|
onClick={onAnother}
|
||||||
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
|
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
|
||||||
>
|
>
|
||||||
Submit another survey
|
Submit an update
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-ink-mute">It's safe to close this window.</p>
|
<p className="text-xs text-ink-mute">It's safe to close this window.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
StageSectionConfig,
|
StageSectionConfig,
|
||||||
} from "@/types/form";
|
} from "@/types/form";
|
||||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
|
import { STAGE_RANK } from "@/lib/stageRank";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
import {
|
import {
|
||||||
FieldHistoryGroup,
|
FieldHistoryGroup,
|
||||||
@@ -39,15 +40,6 @@ type LoadState =
|
|||||||
|
|
||||||
type PathwayState = "past" | "current" | "future";
|
type PathwayState = "past" | "current" | "future";
|
||||||
|
|
||||||
const STAGE_RANK: Record<string, number> = {
|
|
||||||
Inquiry: 0,
|
|
||||||
Organizing: 1,
|
|
||||||
Feasibility: 2,
|
|
||||||
"Business feasibility": 3,
|
|
||||||
"Store Implementation": 4,
|
|
||||||
"Stabilize newly opened co-op": 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportView({ config, cid, cs }: ReportViewProps) {
|
export function ReportView({ config, cid, cs }: ReportViewProps) {
|
||||||
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||||||
|
|
||||||
@@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
|
|||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
<DateTimeline
|
||||||
|
sections={config.sections}
|
||||||
|
fieldHistory={data.fieldHistory}
|
||||||
|
activities={data.activities}
|
||||||
|
/>
|
||||||
|
|
||||||
{sectionsToRender.length === 0 ? (
|
{sectionsToRender.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
computeDateRange,
|
computeDateRange,
|
||||||
} from "./report/FieldHistory";
|
} from "./report/FieldHistory";
|
||||||
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
||||||
|
import { FileLink } from "./report/FileLink";
|
||||||
|
|
||||||
interface StaffReportViewProps {
|
interface StaffReportViewProps {
|
||||||
org: number;
|
org: number;
|
||||||
@@ -207,6 +208,8 @@ export function StaffReportView({
|
|||||||
section={section}
|
section={section}
|
||||||
options={data.options}
|
options={data.options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -271,10 +274,14 @@ function StaffSection({
|
|||||||
section,
|
section,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
section: StaffReportSection;
|
section: StaffReportSection;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
const filled = section.fields.filter((f) => f.history.length > 0);
|
const filled = section.fields.filter((f) => f.history.length > 0);
|
||||||
const empty = section.fields.filter((f) => f.history.length === 0);
|
const empty = section.fields.filter((f) => f.history.length === 0);
|
||||||
@@ -328,6 +335,8 @@ function StaffSection({
|
|||||||
field={f}
|
field={f}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -374,10 +383,14 @@ function CompactFieldRow({
|
|||||||
field,
|
field,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const latest = field.history[0];
|
const latest = field.history[0];
|
||||||
@@ -394,6 +407,8 @@ function CompactFieldRow({
|
|||||||
entry={latest}
|
entry={latest}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{latest.date ? (
|
{latest.date ? (
|
||||||
@@ -431,6 +446,8 @@ function CompactFieldRow({
|
|||||||
entry={e}
|
entry={e}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -446,32 +463,33 @@ function FieldValue({
|
|||||||
entry,
|
entry,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
entry: FieldHistoryEntry;
|
entry: FieldHistoryEntry;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
if (field.descriptor.render === "file") {
|
if (field.descriptor.render === "file") {
|
||||||
const v = entry.value as { id?: number | string; file_name?: string } | null;
|
const v = entry.value as
|
||||||
|
| { id?: number | string; file_name?: string; url?: string; mime?: string }
|
||||||
|
| null;
|
||||||
if (!v || v.id === undefined) return <span>—</span>;
|
if (!v || v.id === undefined) return <span>—</span>;
|
||||||
const id = String(v.id);
|
const id = String(v.id);
|
||||||
const name = v.file_name ?? `file-${id}`;
|
const name = v.file_name ?? `file-${id}`;
|
||||||
// Civi serves uploaded files at /civicrm/file?reset=1&id=<id>.
|
|
||||||
// The staff member is already authenticated to Civi (they came from
|
|
||||||
// there); the browser sends their session cookie automatically.
|
|
||||||
const href = civiBaseUrl
|
|
||||||
? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`
|
|
||||||
: "#";
|
|
||||||
return (
|
return (
|
||||||
<a
|
<FileLink
|
||||||
href={href}
|
fileId={id}
|
||||||
target="_blank"
|
fileName={name}
|
||||||
rel="noopener noreferrer"
|
civiSignedUrl={v.url}
|
||||||
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
|
mime={v.mime}
|
||||||
>
|
org={org}
|
||||||
{name}
|
authKey={authKey}
|
||||||
</a>
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -545,6 +563,28 @@ interface Y1MatrixData {
|
|||||||
periodLetter: "Q" | "M";
|
periodLetter: "Q" | "M";
|
||||||
usedNames: Set<string>;
|
usedNames: Set<string>;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Y1 Monthly Sales Target Civi machine names — irregular. M1 dropped the
|
||||||
|
* trailing period from "Y1_Monthly_Sales_Targets", M3 lives in a field named
|
||||||
|
* "_M2" (Civi schema error captured in the original form mapping), and the
|
||||||
|
* rest follow "Y1_Monthly_Sales_Target_M<n>". Hardcoded here so the staff
|
||||||
|
* report can fold these into the monthly Y1 matrix.
|
||||||
|
*/
|
||||||
|
const Y1_MONTHLY_SALES_TARGET_FIELDS: Record<number, string> = {
|
||||||
|
1: "Y1_Monthly_Sales_Targets",
|
||||||
|
2: "Y1_Monthly_Sales_Targets_M2",
|
||||||
|
3: "Y1_Monthly_Sales_Target_M2", // intentional: Civi name says M2, value is M3.
|
||||||
|
4: "Y1_Monthly_Sales_Target_M4",
|
||||||
|
5: "Y1_Monthly_Sales_Target_M5",
|
||||||
|
6: "Y1_Monthly_Sales_Target_M6",
|
||||||
|
7: "Y1_Monthly_Sales_Target_M7",
|
||||||
|
8: "Y1_Monthly_Sales_Target_M8",
|
||||||
|
9: "Y1_Monthly_Sales_Target_M9",
|
||||||
|
10: "Y1_Monthly_Sales_Target_M10",
|
||||||
|
11: "Y1_Monthly_Sales_Target_M11",
|
||||||
|
12: "Y1_Monthly_Sales_Target_M12",
|
||||||
|
};
|
||||||
|
|
||||||
function collectY1MatrixByPeriod(
|
function collectY1MatrixByPeriod(
|
||||||
filled: StaffReportField[],
|
filled: StaffReportField[],
|
||||||
periodLetter: "Q" | "M",
|
periodLetter: "Q" | "M",
|
||||||
@@ -555,6 +595,7 @@ function collectY1MatrixByPeriod(
|
|||||||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||||||
const periodsSet = new Set<number>();
|
const periodsSet = new Set<number>();
|
||||||
const metricLabel = new Map<string, string>();
|
const metricLabel = new Map<string, string>();
|
||||||
|
const byName = new Map(filled.map((f) => [f.descriptor.name, f]));
|
||||||
|
|
||||||
for (const f of filled) {
|
for (const f of filled) {
|
||||||
const m = nameRe.exec(f.descriptor.name);
|
const m = nameRe.exec(f.descriptor.name);
|
||||||
@@ -575,6 +616,24 @@ function collectY1MatrixByPeriod(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Y1 Monthly Sales Target — fold in the irregular fields that don't fit
|
||||||
|
// the Y1_M<n>_<metric> regex above. Only present in the monthly matrix.
|
||||||
|
if (periodLetter === "M") {
|
||||||
|
const byMonth = new Map<number, StaffReportField>();
|
||||||
|
for (const [periodStr, fieldName] of Object.entries(Y1_MONTHLY_SALES_TARGET_FIELDS)) {
|
||||||
|
const f = byName.get(fieldName);
|
||||||
|
if (!f) continue;
|
||||||
|
const period = Number(periodStr);
|
||||||
|
byMonth.set(period, f);
|
||||||
|
used.add(fieldName);
|
||||||
|
periodsSet.add(period);
|
||||||
|
}
|
||||||
|
if (byMonth.size > 0) {
|
||||||
|
byMetric.set("Sales_Target", byMonth);
|
||||||
|
metricLabel.set("Sales_Target", "Sales Target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (byMetric.size === 0) return null;
|
if (byMetric.size === 0) return null;
|
||||||
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
||||||
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
||||||
@@ -707,7 +766,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
|
|||||||
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
||||||
];
|
];
|
||||||
|
|
||||||
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
|
return (
|
||||||
|
<DateTimeline
|
||||||
|
sections={sections}
|
||||||
|
fieldHistory={fieldHistory}
|
||||||
|
activities={data.activities}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatLongDate retained for symmetry with other report views; unused here
|
// formatLongDate retained for symmetry with other report views; unused here
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal preview for image and PDF attachments.
|
||||||
|
*
|
||||||
|
* Uses the native <dialog> element for focus trap, Esc-to-close, and
|
||||||
|
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
|
||||||
|
* that we'd otherwise have to maintain.
|
||||||
|
*
|
||||||
|
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
|
||||||
|
* <iframe>. Anything else should not reach this component — FileLink is
|
||||||
|
* responsible for branching office/other types to plain download links.
|
||||||
|
*/
|
||||||
|
export function AttachmentLightbox({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
previewSrc,
|
||||||
|
downloadHref,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
|
||||||
|
previewSrc: string;
|
||||||
|
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
|
||||||
|
downloadHref: string;
|
||||||
|
filename: string;
|
||||||
|
/** Resolved mime; used to pick between <img> and <iframe>. */
|
||||||
|
mime: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDialogElement | null>(null);
|
||||||
|
|
||||||
|
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
|
||||||
|
// content height (no nested scrollbar), so a modal <dialog> — which centers
|
||||||
|
// in *its* viewport — lands in the vertical middle of the whole document and
|
||||||
|
// is off-screen unless the parent page happens to be scrolled there. The
|
||||||
|
// iframe can't read the parent's scroll position (cross-origin), so the
|
||||||
|
// extension's tab template broadcasts the iframe's currently-visible slice
|
||||||
|
// as `webform-mw-viewport` messages. We pin the dialog to that slice and
|
||||||
|
// size it to fill the visible height. Standalone (non-embedded) keeps the
|
||||||
|
// native viewport centering.
|
||||||
|
const [framed, setFramed] = useState(false);
|
||||||
|
const vpRef = useRef<{ top: number; height: number } | null>(null);
|
||||||
|
const [vp, setVp] = useState<{ top: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFramed(window.parent !== window);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMsg = (e: MessageEvent) => {
|
||||||
|
if (e.source !== window.parent) return;
|
||||||
|
const d = e.data as { type?: string; top?: number; height?: number } | null;
|
||||||
|
if (!d || d.type !== "webform-mw-viewport") return;
|
||||||
|
const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 };
|
||||||
|
vpRef.current = next;
|
||||||
|
// Only reflect into render state while open — many FileLinks mount a
|
||||||
|
// (closed) lightbox each, and we don't want every one re-rendering on
|
||||||
|
// each scroll frame.
|
||||||
|
if (open) setVp(next);
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMsg);
|
||||||
|
return () => window.removeEventListener("message", onMsg);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Seed from the latest known viewport the moment we open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setVp(vpRef.current);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Drive the native <dialog>'s open state from our prop.
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = ref.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
if (open && !dlg.open) {
|
||||||
|
dlg.showModal();
|
||||||
|
} else if (!open && dlg.open) {
|
||||||
|
dlg.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
|
||||||
|
// submit. Mirror that back into React state so the parent stays in sync.
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = ref.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
const handle = () => onClose();
|
||||||
|
dlg.addEventListener("close", handle);
|
||||||
|
return () => dlg.removeEventListener("close", handle);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Close when the user clicks the backdrop (everything outside the inner
|
||||||
|
// panel). The dialog itself receives the click event when the backdrop
|
||||||
|
// is hit because the panel uses pointer-events the same way.
|
||||||
|
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||||
|
if (e.target === ref.current) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = mime.startsWith("image/");
|
||||||
|
const isPdf = mime === "application/pdf";
|
||||||
|
|
||||||
|
// Vertical placement + height.
|
||||||
|
// - framed + known viewport: pin to the visible slice and fill it.
|
||||||
|
// - framed but no viewport yet (e.g. an older extension that doesn't
|
||||||
|
// broadcast): fall back to a safe fixed box so we never balloon to the
|
||||||
|
// full multi-thousand-pixel iframe height.
|
||||||
|
// - standalone: native viewport centering, tall enough for documents.
|
||||||
|
const margin = 16;
|
||||||
|
const dialogStyle: React.CSSProperties | undefined =
|
||||||
|
framed && vp
|
||||||
|
? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 }
|
||||||
|
: undefined;
|
||||||
|
const panelHeight = framed
|
||||||
|
? vp
|
||||||
|
? Math.max(360, vp.height - margin * 2)
|
||||||
|
: 640
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
onClick={onDialogClick}
|
||||||
|
aria-label={`Preview: ${filename}`}
|
||||||
|
style={dialogStyle}
|
||||||
|
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
|
||||||
|
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
|
||||||
|
<p className="min-w-0 truncate font-display text-sm text-ink">
|
||||||
|
{filename}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded px-2 py-1 text-xs font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-3">
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={previewSrc}
|
||||||
|
alt={filename}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={previewSrc}
|
||||||
|
title={filename}
|
||||||
|
className="h-full w-full border-0 bg-paper"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Defensive: FileLink shouldn't open the lightbox for non-previewable
|
||||||
|
// types, but if it does, surface a clear message instead of an empty box.
|
||||||
|
<p className="px-6 text-paper">
|
||||||
|
Preview not available. Use Download above to open the file.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,106 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
|
import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form";
|
||||||
import { formatShortDate } from "./FieldHistory";
|
import { formatShortDate } from "./FieldHistory";
|
||||||
|
import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date-grouped timeline strip at the top of the report. Walks every
|
* Date-grouped timeline strip at the top of the report. Renders five
|
||||||
* date-type field across all stage sections (0–5), pulls each field's
|
* horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom)
|
||||||
* most-recent entered date, and plots events into six horizontal swim
|
* and overlays two kinds of events:
|
||||||
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis
|
*
|
||||||
* as the journey's anchor at the right end of the time axis. A faint
|
* 1. Stage-period ranges, one per stage-transition activity, drawn
|
||||||
* "Today" tick anchors the reader if today falls within the range, and
|
* as a soft bar from the activity date to the date of the next
|
||||||
* an adaptive month/year axis runs beneath the lanes. Each dot exposes
|
* activity that moved the co-op to a higher stage. The latest
|
||||||
* a tooltip on hover or keyboard focus.
|
* open stage extends to today.
|
||||||
|
* 2. Milestone dots from date-type custom fields. The dot is placed
|
||||||
|
* on the lane corresponding to the stage the co-op was in on the
|
||||||
|
* field's stored date (resolved from the activity stream). When
|
||||||
|
* that's unknowable (date precedes any transition), it falls back
|
||||||
|
* to the field's own section rank. Stage 5's `Date_Opened` is
|
||||||
|
* pinned to Stage 5 as the journey anchor.
|
||||||
|
*
|
||||||
|
* A faint "Today" tick anchors the reader if today falls within the
|
||||||
|
* range, and an adaptive month/year axis runs beneath the lanes. Each
|
||||||
|
* dot exposes a tooltip on hover or keyboard focus.
|
||||||
*/
|
*/
|
||||||
export function DateTimeline({
|
export function DateTimeline({
|
||||||
sections,
|
sections,
|
||||||
fieldHistory,
|
fieldHistory,
|
||||||
|
activities,
|
||||||
}: {
|
}: {
|
||||||
sections: StageSectionConfig[];
|
sections: StageSectionConfig[];
|
||||||
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
||||||
|
activities: ActivitySummary[];
|
||||||
}) {
|
}) {
|
||||||
type Event = {
|
const stageRankAtDate = buildStageRankAtDate(activities);
|
||||||
|
const ranges = computeStageRanges(activities);
|
||||||
|
|
||||||
|
type MilestoneEvent = {
|
||||||
rank: number;
|
rank: number;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
date: string; // YYYY-MM-DD or full ISO
|
date: string;
|
||||||
isOpened: boolean;
|
isOpened: boolean;
|
||||||
};
|
};
|
||||||
const events: Event[] = [];
|
const milestones: MilestoneEvent[] = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
if (section.rank < 0 || section.rank > 5) continue;
|
|
||||||
for (const f of section.fields) {
|
for (const f of section.fields) {
|
||||||
if (f.type !== "date") continue;
|
if (f.type !== "date") continue;
|
||||||
const history = fieldHistory[f.name];
|
const history = fieldHistory[f.name];
|
||||||
if (!history || history.length === 0) continue;
|
if (!history || history.length === 0) continue;
|
||||||
const v = history[0].value;
|
const v = history[0].value;
|
||||||
if (typeof v !== "string" || v.length === 0) continue;
|
if (typeof v !== "string" || v.length === 0) continue;
|
||||||
events.push({
|
const isOpened = f.name === "Date_Opened";
|
||||||
rank: section.rank,
|
const resolved = stageRankAtDate(v);
|
||||||
fieldLabel: f.label,
|
let rank = isOpened ? 5 : (resolved ?? section.rank);
|
||||||
date: v,
|
if (rank < 1 || rank > 5) rank = section.rank;
|
||||||
isOpened: f.name === "Date_Opened",
|
if (rank < 1 || rank > 5) continue;
|
||||||
});
|
milestones.push({ rank, fieldLabel: f.label, date: v, isOpened });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (events.length === 0) return null;
|
|
||||||
|
|
||||||
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
|
// Collect every time value that should influence the time axis: range
|
||||||
if (times.length === 0) return null;
|
// starts, range ends (excluding open-ended), milestone dates, and today
|
||||||
|
// if any range is open (so the open range visibly extends to "now").
|
||||||
const openedEvent = events.find((e) => e.isOpened);
|
const timeBag: number[] = [];
|
||||||
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
|
let hasOpenRange = false;
|
||||||
const minT = Math.min(...times);
|
for (const r of ranges) {
|
||||||
// If Date_Opened is set, anchor the right edge there; if any other event
|
const ts = new Date(r.startDate).getTime();
|
||||||
// is later, extend so nothing falls off the chart.
|
if (Number.isFinite(ts)) timeBag.push(ts);
|
||||||
const maxT = Math.max(...times, openedTime ?? -Infinity);
|
if (r.endDate === null) {
|
||||||
const tRange = maxT - minT || 1;
|
hasOpenRange = true;
|
||||||
|
} else {
|
||||||
|
const te = new Date(r.endDate).getTime();
|
||||||
|
if (Number.isFinite(te)) timeBag.push(te);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of milestones) {
|
||||||
|
const t = new Date(m.date).getTime();
|
||||||
|
if (Number.isFinite(t)) timeBag.push(t);
|
||||||
|
}
|
||||||
const todayT = Date.now();
|
const todayT = Date.now();
|
||||||
|
if (hasOpenRange) timeBag.push(todayT);
|
||||||
|
if (timeBag.length === 0) return null;
|
||||||
|
|
||||||
|
let minT = Math.min(...timeBag);
|
||||||
|
let maxT = Math.max(...timeBag);
|
||||||
|
if (minT === maxT) {
|
||||||
|
const pad = 30 * 24 * 3600 * 1000;
|
||||||
|
minT -= pad;
|
||||||
|
maxT += pad;
|
||||||
|
}
|
||||||
|
const tRange = maxT - minT || 1;
|
||||||
const todayPct =
|
const todayPct =
|
||||||
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
|
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
|
||||||
|
|
||||||
const ticks = generateAxisTicks(minT, maxT);
|
const ticks = generateAxisTicks(minT, maxT);
|
||||||
|
|
||||||
|
const rangesByRank = new Map<number, StageRange[]>();
|
||||||
|
for (const r of ranges) {
|
||||||
|
const list = rangesByRank.get(r.rank) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
rangesByRank.set(r.rank, list);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby="timeline-heading"
|
aria-labelledby="timeline-heading"
|
||||||
@@ -80,9 +120,10 @@ export function DateTimeline({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ol className="mt-4 space-y-2">
|
<ol className="mt-4 space-y-2">
|
||||||
{[0, 1, 2, 3, 4, 5].map((rank) => {
|
{[5, 4, 3, 2, 1].map((rank) => {
|
||||||
const rowEvents = events.filter((e) => e.rank === rank);
|
const rowRanges = rangesByRank.get(rank) ?? [];
|
||||||
const isEmpty = rowEvents.length === 0;
|
const rowMilestones = milestones.filter((e) => e.rank === rank);
|
||||||
|
const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={rank}
|
key={rank}
|
||||||
@@ -99,6 +140,30 @@ export function DateTimeline({
|
|||||||
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Stage-period ranges: soft bar from activity start to next
|
||||||
|
higher-stage activity (or to today if still open). */}
|
||||||
|
{rowRanges.map((r, i) => {
|
||||||
|
const ts = new Date(r.startDate).getTime();
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
const endT =
|
||||||
|
r.endDate === null
|
||||||
|
? Math.max(todayT, ts)
|
||||||
|
: new Date(r.endDate).getTime();
|
||||||
|
if (!Number.isFinite(endT)) return null;
|
||||||
|
const startPct = ((ts - minT) / tRange) * 100;
|
||||||
|
const endPct = ((endT - minT) / tRange) * 100;
|
||||||
|
const widthPct = Math.max(endPct - startPct, 0.5);
|
||||||
|
return (
|
||||||
|
<StageRangeBar
|
||||||
|
key={`r-${i}`}
|
||||||
|
rank={rank}
|
||||||
|
range={r}
|
||||||
|
startPct={startPct}
|
||||||
|
widthPct={widthPct}
|
||||||
|
isOpen={r.endDate === null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{todayPct !== null && (
|
{todayPct !== null && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -106,19 +171,23 @@ export function DateTimeline({
|
|||||||
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rowEvents.map((e, i) => {
|
{/* Activity start dots — placed at each range start for
|
||||||
const t = new Date(e.date).getTime();
|
keyboard focus + tooltip with the activity subject. */}
|
||||||
|
{rowRanges.map((r, i) => {
|
||||||
|
const t = new Date(r.startDate).getTime();
|
||||||
if (!Number.isFinite(t)) return null;
|
if (!Number.isFinite(t)) return null;
|
||||||
const x = ((t - minT) / tRange) * 100;
|
const x = ((t - minT) / tRange) * 100;
|
||||||
return (
|
return (
|
||||||
<TimelineDot
|
<ActivityDot key={`a-${i}`} rank={rank} range={r} x={x} />
|
||||||
key={i}
|
|
||||||
rank={rank}
|
|
||||||
event={e}
|
|
||||||
x={x}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Milestone date-field dots layered on top. */}
|
||||||
|
{rowMilestones.map((e, i) => {
|
||||||
|
const t = new Date(e.date).getTime();
|
||||||
|
if (!Number.isFinite(t)) return null;
|
||||||
|
const x = ((t - minT) / tRange) * 100;
|
||||||
|
return <MilestoneDot key={`m-${i}`} rank={rank} event={e} x={x} />;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -138,7 +207,6 @@ export function DateTimeline({
|
|||||||
/>
|
/>
|
||||||
{ticks.map((tk, i) => {
|
{ticks.map((tk, i) => {
|
||||||
const x = ((tk.t - minT) / tRange) * 100;
|
const x = ((tk.t - minT) / tRange) * 100;
|
||||||
// Edge labels shift so they don't overflow the row.
|
|
||||||
const alignClass =
|
const alignClass =
|
||||||
x < 6
|
x < 6
|
||||||
? "left-0 origin-top-left"
|
? "left-0 origin-top-left"
|
||||||
@@ -185,8 +253,14 @@ export function DateTimeline({
|
|||||||
)}
|
)}
|
||||||
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
||||||
<ul className="sr-only">
|
<ul className="sr-only">
|
||||||
{events.map((e, i) => (
|
{ranges.map((r, i) => (
|
||||||
<li key={i}>
|
<li key={`r-${i}`}>
|
||||||
|
Stage {r.rank}: {formatShortDate(r.startDate)}
|
||||||
|
{r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{milestones.map((e, i) => (
|
||||||
|
<li key={`m-${i}`}>
|
||||||
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
||||||
{e.isOpened ? " (opened)" : ""}
|
{e.isOpened ? " (opened)" : ""}
|
||||||
</li>
|
</li>
|
||||||
@@ -198,8 +272,6 @@ export function DateTimeline({
|
|||||||
|
|
||||||
function stageDotBg(rank: number): string {
|
function stageDotBg(rank: number): string {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 0:
|
|
||||||
return "bg-leaf-200";
|
|
||||||
case 1:
|
case 1:
|
||||||
return "bg-leaf-300";
|
return "bg-leaf-300";
|
||||||
case 2:
|
case 2:
|
||||||
@@ -215,14 +287,108 @@ function stageDotBg(rank: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageRangeBg(rank: number): string {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return "bg-leaf-300/30";
|
||||||
|
case 2:
|
||||||
|
return "bg-leaf-500/25";
|
||||||
|
case 3:
|
||||||
|
return "bg-leaf-600/25";
|
||||||
|
case 4:
|
||||||
|
return "bg-leaf-700/25";
|
||||||
|
case 5:
|
||||||
|
return "bg-clay-700/25";
|
||||||
|
default:
|
||||||
|
return "bg-leaf-500/25";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft horizontal bar marking a stage period on a lane. */
|
||||||
|
function StageRangeBar({
|
||||||
|
rank,
|
||||||
|
range,
|
||||||
|
startPct,
|
||||||
|
widthPct,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
rank: number;
|
||||||
|
range: StageRange;
|
||||||
|
startPct: number;
|
||||||
|
widthPct: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
title={
|
||||||
|
`Stage ${rank}: ${formatShortDate(range.startDate)}` +
|
||||||
|
(range.endDate ? ` → ${formatShortDate(range.endDate)}` : " → current")
|
||||||
|
}
|
||||||
|
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
|
||||||
|
className={
|
||||||
|
"absolute top-1/2 -translate-y-1/2 h-2 rounded-full " +
|
||||||
|
stageRangeBg(rank) +
|
||||||
|
(isOpen ? " ring-1 ring-inset ring-clay-300/40" : "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dot at the start of a stage period; carries the activity tooltip. */
|
||||||
|
function ActivityDot({
|
||||||
|
rank,
|
||||||
|
range,
|
||||||
|
x,
|
||||||
|
}: {
|
||||||
|
rank: number;
|
||||||
|
range: StageRange;
|
||||||
|
x: number;
|
||||||
|
}) {
|
||||||
|
const tipAlign =
|
||||||
|
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Stage ${rank} check-in, ${formatShortDate(range.startDate)}`}
|
||||||
|
style={{ left: `${x}%` }}
|
||||||
|
className={
|
||||||
|
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 ring-1 ring-paper h-3 w-3 " +
|
||||||
|
stageDotBg(rank)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className={
|
||||||
|
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
|
||||||
|
tipAlign
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="block font-medium">
|
||||||
|
{range.subject ?? `Stage ${rank} check-in`}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block tabular-nums text-paper-2/80">
|
||||||
|
{formatShortDate(range.startDate)}
|
||||||
|
{range.endDate ? (
|
||||||
|
<> → {formatShortDate(range.endDate)}</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1.5 text-paper-2/60">→ current</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single dot on the timeline plus its hover/focus tooltip. Keyboard
|
* A single milestone date-field dot plus its hover/focus tooltip. Keyboard
|
||||||
* users can Tab to each dot and the tooltip will appear via
|
* users can Tab to each dot and the tooltip will appear via
|
||||||
* `group-focus-within`. The tooltip is anchored above the dot; for dots
|
* `group-focus-within`. The tooltip is anchored above the dot; for dots
|
||||||
* near the left or right edge of the row, anchor flips so the card
|
* near the left or right edge of the row, anchor flips so the card
|
||||||
* doesn't overflow.
|
* doesn't overflow.
|
||||||
*/
|
*/
|
||||||
function TimelineDot({
|
function MilestoneDot({
|
||||||
rank,
|
rank,
|
||||||
event,
|
event,
|
||||||
x,
|
x,
|
||||||
@@ -236,11 +402,7 @@ function TimelineDot({
|
|||||||
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
||||||
const ring = isOpened ? "ring-2" : "ring-1";
|
const ring = isOpened ? "ring-2" : "ring-1";
|
||||||
const tipAlign =
|
const tipAlign =
|
||||||
x < 18
|
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
? "left-0"
|
|
||||||
: x > 82
|
|
||||||
? "right-0"
|
|
||||||
: "left-1/2 -translate-x-1/2";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
@@ -303,7 +465,6 @@ export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number
|
|||||||
plan.unit === "year"
|
plan.unit === "year"
|
||||||
? 0
|
? 0
|
||||||
: Math.floor(start.getMonth() / plan.step) * plan.step;
|
: Math.floor(start.getMonth() / plan.step) * plan.step;
|
||||||
// Don't skip a tick that sits right at minT — start from minT-aligned step.
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const t = new Date(y, m, 1).getTime();
|
const t = new Date(y, m, 1).getTime();
|
||||||
if (t > maxT) break;
|
if (t > maxT) break;
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||||
|
import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an attachment row in the staff report with the right affordance
|
||||||
|
* for its type:
|
||||||
|
*
|
||||||
|
* - image / pdf -> button opens the inline lightbox
|
||||||
|
* - office -> download link + "View in Google Docs" secondary link
|
||||||
|
* - other -> plain download link
|
||||||
|
*
|
||||||
|
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
|
||||||
|
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
|
||||||
|
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
|
||||||
|
* Google's Docs Viewer for office files; we deliberately don't proxy that
|
||||||
|
* one because Google's servers must fetch it without our staff key.
|
||||||
|
*/
|
||||||
|
export function FileLink({
|
||||||
|
fileId,
|
||||||
|
fileName,
|
||||||
|
civiSignedUrl,
|
||||||
|
mime: explicitMime,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
|
civiBaseUrl,
|
||||||
|
}: {
|
||||||
|
fileId: number | string;
|
||||||
|
fileName: string;
|
||||||
|
/** From Attachment.get (includes fcs). Used for office Google Viewer + download fallback. */
|
||||||
|
civiSignedUrl?: string;
|
||||||
|
mime?: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
|
civiBaseUrl: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const mime = resolveMime(explicitMime, fileName);
|
||||||
|
const category = categoryFromMime(mime);
|
||||||
|
|
||||||
|
const proxyBase =
|
||||||
|
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
|
||||||
|
`&org=${encodeURIComponent(String(org))}` +
|
||||||
|
`&key=${encodeURIComponent(authKey)}`;
|
||||||
|
const previewSrc = proxyBase;
|
||||||
|
// Prefer Civi's signed URL for downloads when present (one fewer hop
|
||||||
|
// through our Lambda); the proxy is the fallback.
|
||||||
|
const downloadHref = civiSignedUrl
|
||||||
|
? absUrl(civiSignedUrl, civiBaseUrl)
|
||||||
|
: `${proxyBase}&dl=1`;
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"text-ink underline decoration-rule underline-offset-4 hover:decoration-ink";
|
||||||
|
|
||||||
|
if (category === "image" || category === "pdf") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={
|
||||||
|
"bg-transparent p-0 text-left " +
|
||||||
|
linkClass +
|
||||||
|
" focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</button>
|
||||||
|
<AttachmentLightbox
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
previewSrc={previewSrc}
|
||||||
|
downloadHref={downloadHref}
|
||||||
|
filename={fileName}
|
||||||
|
mime={mime}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === "office") {
|
||||||
|
// Google Docs Viewer renders DOC/DOCX/XLS/XLSX in a new tab. It fetches
|
||||||
|
// the source URL server-side, so the URL must be reachable without our
|
||||||
|
// staff key — that's why we pass the Civi-signed URL straight through.
|
||||||
|
const gview = civiSignedUrl
|
||||||
|
? `https://docs.google.com/viewer?url=${encodeURIComponent(absUrl(civiSignedUrl, civiBaseUrl))}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</a>
|
||||||
|
{gview && (
|
||||||
|
<a
|
||||||
|
href={gview}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Opens in Google Docs Viewer (file bytes are sent to Google to render)"
|
||||||
|
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
||||||
|
>
|
||||||
|
View in Google Docs
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "other" — unknown types: just a download link.
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absUrl(u: string, base: string): string {
|
||||||
|
if (u.startsWith("http")) return u;
|
||||||
|
if (!base) return u;
|
||||||
|
return `${base.replace(/\/+$/, "")}${u.startsWith("/") ? "" : "/"}${u}`;
|
||||||
|
}
|
||||||
@@ -281,11 +281,14 @@ export function MembershipChart({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Most-recent point value labels */}
|
{/* Most-recent point value labels. Both anchor to the chart's right
|
||||||
|
edge so the carry-forward endpoint label stays inside the viewBox
|
||||||
|
instead of overflowing past the right padding. */}
|
||||||
{actualCoords.length > 0 && (
|
{actualCoords.length > 0 && (
|
||||||
<text
|
<text
|
||||||
x={actualCoords[actualCoords.length - 1].x + 6}
|
x={xOf(maxT) - 4}
|
||||||
y={actualCoords[actualCoords.length - 1].y - 6}
|
y={actualCoords[actualCoords.length - 1].y - 6}
|
||||||
|
textAnchor="end"
|
||||||
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
|
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
|
||||||
>
|
>
|
||||||
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
|
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
|
||||||
|
|||||||
+10
-10
@@ -184,7 +184,7 @@ const stage0: StageSectionConfig = {
|
|||||||
label: "Member-Owner Goal for current Stage",
|
label: "Member-Owner Goal for current Stage",
|
||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Member_Goal_for_current_Stage`,
|
civiField: `${G0}.Member_Goal_for_current_Stage`,
|
||||||
help: "What is your member-owner goal for your current co-op development Stage?",
|
help: "What is your member / owner goal for your current co-op development Stage?",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Members__current_",
|
name: "Members__current_",
|
||||||
@@ -199,7 +199,7 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Total_members_at_opening`,
|
civiField: `${G0}.Total_members_at_opening`,
|
||||||
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
help: "How many member-owners will be required at opening for this store?",
|
help: "How many member-owners did the co-op have on opening day?",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: "Volunteers_Helping_In_Store",
|
// name: "Volunteers_Helping_In_Store",
|
||||||
@@ -285,7 +285,7 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "currency",
|
type: "currency",
|
||||||
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
civiField: `${G0}.Member_equity_raised`,
|
civiField: `${G0}.Member_equity_raised`,
|
||||||
help: "Member-owner equity raised, as of the time of survey",
|
help: "Member-owner equity raised, as of the time of check-in",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: "Member_loans_total",
|
// name: "Member_loans_total",
|
||||||
@@ -299,7 +299,7 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "currency",
|
type: "currency",
|
||||||
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
civiField: `${G0}.Member_loans_raised`,
|
civiField: `${G0}.Member_loans_raised`,
|
||||||
help: "Total amount (USD) raised from member-owner loans, as of the time of survey",
|
help: "Total amount (USD) raised from member-owner loans, as of the time of check-in",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: "Member_preferred_shares_total",
|
// name: "Member_preferred_shares_total",
|
||||||
@@ -320,7 +320,7 @@ const stage0: StageSectionConfig = {
|
|||||||
// { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
|
// { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
|
||||||
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" },
|
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" },
|
||||||
// { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
|
// { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
|
||||||
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member-owner contributions (loans or equity) or bank debt." },
|
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member contributions (loans or equity) or bank debt." },
|
||||||
// { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
|
// { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
|
||||||
{
|
{
|
||||||
name: "FTEs",
|
name: "FTEs",
|
||||||
@@ -346,7 +346,7 @@ const stage1: StageSectionConfig = {
|
|||||||
label: "Preliminary Market Assessment",
|
label: "Preliminary Market Assessment",
|
||||||
type: "date",
|
type: "date",
|
||||||
civiField: `${G1}.Preliminary_Market_Assessment`,
|
civiField: `${G1}.Preliminary_Market_Assessment`,
|
||||||
help: "What date was your Preliminary Market Assessment completed?",
|
help: "What date was your most recent Preliminary Market Assessment completed?",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Preliminary_Market_Assessment_Upload",
|
name: "Preliminary_Market_Assessment_Upload",
|
||||||
@@ -360,7 +360,7 @@ const stage1: StageSectionConfig = {
|
|||||||
label: "Preliminary Sources And Uses",
|
label: "Preliminary Sources And Uses",
|
||||||
type: "date",
|
type: "date",
|
||||||
civiField: `${G1}.Preliminary_Sources_Uses`,
|
civiField: `${G1}.Preliminary_Sources_Uses`,
|
||||||
help: "When was your Preliminary Sources & Uses completed?",
|
help: "When was your most recent Preliminary Sources & Uses completed?",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Preliminary_Sources_Uses_Upload",
|
name: "Preliminary_Sources_Uses_Upload",
|
||||||
@@ -551,14 +551,14 @@ const stage3: StageSectionConfig = {
|
|||||||
label: "Capital Campaign: Owner Participation %",
|
label: "Capital Campaign: Owner Participation %",
|
||||||
type: "percent",
|
type: "percent",
|
||||||
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
|
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
|
||||||
help: "What percentage of your owners have contributed to your capital campaign?",
|
help: "What percentage of your member-owners have contributed to your capital campaign?",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Capital_Campaign_Average_Owner_Investment",
|
name: "Capital_Campaign_Average_Owner_Investment",
|
||||||
label: "Capital Campaign: Average Owner Investment",
|
label: "Capital Campaign: Average Owner Investment",
|
||||||
type: "currency",
|
type: "currency",
|
||||||
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
|
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
|
||||||
help: "What is the average investment size from your owners? (i.e. total raised divided by number of owners participating. If your co-op raised $100,000 from 100 owners, the average owner investment would be $1,000. $100,000 / 100 = $1,000.)",
|
help: "What is the average investment size from your member-owners? (i.e. total raised divided by number of member-owners participating. If your co-op raised $100,000 from 100 owners, the average member-owner investment would be $1,000. $100,000 / 100 = $1,000.)",
|
||||||
},
|
},
|
||||||
] as FieldConfig[],
|
] as FieldConfig[],
|
||||||
fieldGroups: [
|
fieldGroups: [
|
||||||
@@ -781,7 +781,7 @@ export const formConfig: FormConfig = {
|
|||||||
id: "org_engagement_check_in",
|
id: "org_engagement_check_in",
|
||||||
title: "Co-op Survey",
|
title: "Co-op Survey",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
|
"Thank you for updating your co-op information.",
|
||||||
stageField: "current_stage",
|
stageField: "current_stage",
|
||||||
sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
|
sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
|
||||||
};
|
};
|
||||||
|
|||||||
+210
@@ -86,6 +86,216 @@ export async function civi<T = unknown>(
|
|||||||
return (await res.json()) as CiviApiResponse<T>;
|
return (await res.json()) as CiviApiResponse<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy APIv3 call. Some Civi entities (notably Attachment) are exposed
|
||||||
|
* only via APIv3 on this install; this helper hits the universal
|
||||||
|
* /civicrm/ajax/rest endpoint with AuthX headers. Returns the normalized
|
||||||
|
* values list — APIv3 may return values as either an array or an object
|
||||||
|
* keyed by id, depending on version; we flatten to an array.
|
||||||
|
*/
|
||||||
|
export async function civi3<T = unknown>(
|
||||||
|
entity: string,
|
||||||
|
action: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
opts: CiviApiOptions = {},
|
||||||
|
): Promise<CiviApiResponse<T>> {
|
||||||
|
if (isStubMode()) {
|
||||||
|
console.warn(`${STUB_LOG_PREFIX} v3 ${entity}.${action} — env not set, returning empty values`);
|
||||||
|
return { values: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
|
||||||
|
const url = `${base}/civicrm/ajax/rest`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
entity,
|
||||||
|
action,
|
||||||
|
json: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
|
||||||
|
// v3's rest endpoint enforces this header as CSRF protection.
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
};
|
||||||
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
||||||
|
const creds = Buffer.from(
|
||||||
|
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
|
||||||
|
).toString("base64");
|
||||||
|
headers["Authorization"] = `Basic ${creds}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`CiviCRM v3 ${entity}.${action} failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
is_error?: number;
|
||||||
|
error_message?: string;
|
||||||
|
values?: T[] | Record<string, T>;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
if (json.is_error) {
|
||||||
|
throw new Error(
|
||||||
|
`CiviCRM v3 ${entity}.${action} error: ${json.error_message ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = json.values;
|
||||||
|
const values: T[] = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: raw && typeof raw === "object"
|
||||||
|
? Object.values(raw)
|
||||||
|
: [];
|
||||||
|
return { values, count: json.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multipart APIv3 call. Used for binary uploads — APIv4 File.create stores
|
||||||
|
* the `content` field verbatim (no base64 decoding), so files come back
|
||||||
|
* corrupted. APIv3 Attachment.create accepts the file via the standard
|
||||||
|
* multipart `file` part (read from $_FILES on the server) which preserves
|
||||||
|
* the bytes exactly.
|
||||||
|
*
|
||||||
|
* Caller provides `params` (non-binary metadata) and `file` (the binary +
|
||||||
|
* filename + mime). Authentication is the same AuthX headers as civi3().
|
||||||
|
*/
|
||||||
|
export async function civi3Upload<T = unknown>(
|
||||||
|
entity: string,
|
||||||
|
action: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
file: { bytes: Uint8Array; filename: string; mime: string },
|
||||||
|
opts: CiviApiOptions = {},
|
||||||
|
): Promise<CiviApiResponse<T>> {
|
||||||
|
if (isStubMode()) {
|
||||||
|
console.warn(
|
||||||
|
`${STUB_LOG_PREFIX} v3-multipart ${entity}.${action} — env not set, returning empty values`,
|
||||||
|
);
|
||||||
|
return { values: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
|
||||||
|
const url = `${base}/civicrm/ajax/rest`;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("entity", entity);
|
||||||
|
form.append("action", action);
|
||||||
|
form.append("json", JSON.stringify(params));
|
||||||
|
// Wrap the Uint8Array in a fresh ArrayBuffer slice so Blob's typing
|
||||||
|
// (which only accepts ArrayBuffer, not the wider ArrayBufferLike) is
|
||||||
|
// happy. The slice is a no-op on real Uint8Array inputs.
|
||||||
|
const fileBuf = file.bytes.buffer.slice(
|
||||||
|
file.bytes.byteOffset,
|
||||||
|
file.bytes.byteOffset + file.bytes.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
form.append(
|
||||||
|
"file",
|
||||||
|
new Blob([fileBuf], { type: file.mime }),
|
||||||
|
file.filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do NOT set Content-Type — fetch sets multipart/form-data with the
|
||||||
|
// correct boundary automatically when body is a FormData.
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
};
|
||||||
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
||||||
|
const creds = Buffer.from(
|
||||||
|
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
|
||||||
|
).toString("base64");
|
||||||
|
headers["Authorization"] = `Basic ${creds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`CiviCRM v3 ${entity}.${action} (multipart) failed (${res.status}): ${text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
is_error?: number;
|
||||||
|
error_message?: string;
|
||||||
|
values?: T[] | Record<string, T>;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
if (json.is_error) {
|
||||||
|
throw new Error(
|
||||||
|
`CiviCRM v3 ${entity}.${action} (multipart) error: ${json.error_message ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = json.values;
|
||||||
|
const values: T[] = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: raw && typeof raw === "object"
|
||||||
|
? Object.values(raw)
|
||||||
|
: [];
|
||||||
|
return { values, count: json.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a multipart request directly to an arbitrary Civi route. Used for
|
||||||
|
* extension endpoints that handle multipart uploads natively (the v3/v4
|
||||||
|
* ajax/rest path silently drops $_FILES on this install, and JSON+base64
|
||||||
|
* stores the literal base64 text on disk).
|
||||||
|
*
|
||||||
|
* The caller's `path` is appended to CIVI_BASE_URL. AuthX headers are sent
|
||||||
|
* the same way as the other helpers. Returns the parsed JSON body.
|
||||||
|
*/
|
||||||
|
export async function civiMultipart<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
fields: Record<string, string>,
|
||||||
|
file: { bytes: Uint8Array; filename: string; mime: string },
|
||||||
|
opts: CiviApiOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
if (isStubMode()) {
|
||||||
|
console.warn(`${STUB_LOG_PREFIX} multipart ${path} — env not set`);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
|
||||||
|
const url = `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
||||||
|
const fileBuf = file.bytes.buffer.slice(
|
||||||
|
file.bytes.byteOffset,
|
||||||
|
file.bytes.byteOffset + file.bytes.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
form.append("file", new Blob([fileBuf], { type: file.mime }), file.filename);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
};
|
||||||
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
||||||
|
const creds = Buffer.from(
|
||||||
|
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
|
||||||
|
).toString("base64");
|
||||||
|
headers["Authorization"] = `Basic ${creds}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Civi POST ${path} failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a contact checksum (cid + cs) against CiviCRM.
|
* Validate a contact checksum (cid + cs) against CiviCRM.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* Mime helpers for staff-report attachment rendering.
|
||||||
|
*
|
||||||
|
* The staff report shows file attachments with three different affordances:
|
||||||
|
* - images / PDF -> inline lightbox preview
|
||||||
|
* - office docs -> plain download + "View in Google Docs" link
|
||||||
|
* - anything else -> plain download
|
||||||
|
*
|
||||||
|
* Civi can serve a mime via `Attachment.get`, but historical uploads may
|
||||||
|
* have a stale or missing `mime_type` column. Fall back to extension-based
|
||||||
|
* inference so we always reach a stable category.
|
||||||
|
*
|
||||||
|
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||||
|
* import this file directly without any tooling — matches the pattern set
|
||||||
|
* by lib/staff-field-mapping.mjs.
|
||||||
|
*
|
||||||
|
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const EXT_TO_MIME = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFICE_MIMES = new Set([
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the lowercased extension from a filename, or "" if absent.
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function extOf(filename) {
|
||||||
|
if (!filename) return "";
|
||||||
|
const dot = filename.lastIndexOf(".");
|
||||||
|
if (dot < 0 || dot === filename.length - 1) return "";
|
||||||
|
return filename.slice(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a filename's extension to a known mime, or null if unrecognised.
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function mimeFromFilename(filename) {
|
||||||
|
const ext = extOf(filename);
|
||||||
|
return EXT_TO_MIME[ext] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a mime by trusting the explicit value first, then falling back to
|
||||||
|
* filename inference. Returns "application/octet-stream" if nothing matches.
|
||||||
|
* @param {string | undefined | null} explicit
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function resolveMime(explicit, filename) {
|
||||||
|
if (explicit && explicit !== "application/octet-stream") return explicit;
|
||||||
|
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorise a mime for UI dispatch.
|
||||||
|
* @param {string} mime
|
||||||
|
* @returns {AttachmentCategory}
|
||||||
|
*/
|
||||||
|
export function categoryFromMime(mime) {
|
||||||
|
if (mime.startsWith("image/")) return "image";
|
||||||
|
if (mime === "application/pdf") return "pdf";
|
||||||
|
if (OFFICE_MIMES.has(mime)) return "office";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Run with: npm run test:mime
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
extOf,
|
||||||
|
mimeFromFilename,
|
||||||
|
resolveMime,
|
||||||
|
categoryFromMime,
|
||||||
|
} from "./mime.mjs";
|
||||||
|
|
||||||
|
test("extOf returns lowercased extension", () => {
|
||||||
|
assert.equal(extOf("Photo.JPG"), "jpg");
|
||||||
|
assert.equal(extOf("doc.tar.gz"), "gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extOf handles missing or trailing-dot names", () => {
|
||||||
|
assert.equal(extOf(""), "");
|
||||||
|
assert.equal(extOf(null), "");
|
||||||
|
assert.equal(extOf("no-extension"), "");
|
||||||
|
assert.equal(extOf("trailing."), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mimeFromFilename recognises known types", () => {
|
||||||
|
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
|
||||||
|
assert.equal(mimeFromFilename("budget.XLSX"),
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mimeFromFilename returns null for unknown extensions", () => {
|
||||||
|
assert.equal(mimeFromFilename("archive.zip"), null);
|
||||||
|
assert.equal(mimeFromFilename("readme"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime trusts an explicit non-octet mime", () => {
|
||||||
|
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime falls back to filename when explicit is missing", () => {
|
||||||
|
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
|
||||||
|
assert.equal(resolveMime(null, "image.gif"), "image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime falls back to filename when explicit is octet-stream", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime returns octet-stream when nothing is known", () => {
|
||||||
|
assert.equal(resolveMime(null, null), "application/octet-stream");
|
||||||
|
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: image", () => {
|
||||||
|
assert.equal(categoryFromMime("image/png"), "image");
|
||||||
|
assert.equal(categoryFromMime("image/webp"), "image");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: pdf", () => {
|
||||||
|
assert.equal(categoryFromMime("application/pdf"), "pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: office variants", () => {
|
||||||
|
assert.equal(categoryFromMime("application/msword"), "office");
|
||||||
|
assert.equal(
|
||||||
|
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||||
|
"office",
|
||||||
|
);
|
||||||
|
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
|
||||||
|
assert.equal(
|
||||||
|
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||||
|
"office",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: other", () => {
|
||||||
|
assert.equal(categoryFromMime("application/zip"), "other");
|
||||||
|
assert.equal(categoryFromMime("text/plain"), "other");
|
||||||
|
assert.equal(categoryFromMime("application/octet-stream"), "other");
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { ActivitySummary } from "@/types/form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CiviCRM activity stage values → Framework Stage rank (1..5). "Inquiry"
|
||||||
|
* predates Stage 1 and is conventionally rank 0 (pre-engagement); callers
|
||||||
|
* that render stage lanes 1..5 should treat 0 as "no rank in scope".
|
||||||
|
*/
|
||||||
|
export const STAGE_RANK: Record<string, number> = {
|
||||||
|
Inquiry: 0,
|
||||||
|
Organizing: 1,
|
||||||
|
Feasibility: 2,
|
||||||
|
"Business feasibility": 3,
|
||||||
|
"Store Implementation": 4,
|
||||||
|
"Stabilize newly opened co-op": 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a resolver that returns the co-op's Framework Stage rank at a given
|
||||||
|
* point in time, derived from the activity stream. Only activities that
|
||||||
|
* carry a non-empty `stage` value are stage transitions; their stage holds
|
||||||
|
* from that activity's date forward until the next transition.
|
||||||
|
*
|
||||||
|
* Returns `null` when the date precedes any known transition (i.e. we have
|
||||||
|
* no evidence of which stage the co-op was in at that point) — callers can
|
||||||
|
* fall back to a field-section default in that case.
|
||||||
|
*/
|
||||||
|
export function buildStageRankAtDate(
|
||||||
|
activities: ActivitySummary[]
|
||||||
|
): (isoDate: string) => number | null {
|
||||||
|
const transitions = activities
|
||||||
|
.filter(
|
||||||
|
(a): a is ActivitySummary & { stage: string } =>
|
||||||
|
typeof a.stage === "string" && a.stage.length > 0
|
||||||
|
)
|
||||||
|
.map((a) => ({ t: new Date(a.date).getTime(), stage: a.stage }))
|
||||||
|
.filter((tr) => Number.isFinite(tr.t))
|
||||||
|
.sort((a, b) => a.t - b.t);
|
||||||
|
|
||||||
|
return (isoDate: string) => {
|
||||||
|
const t = new Date(isoDate).getTime();
|
||||||
|
if (!Number.isFinite(t)) return null;
|
||||||
|
let last: number | null = null;
|
||||||
|
for (const tr of transitions) {
|
||||||
|
if (tr.t > t) break;
|
||||||
|
const r = STAGE_RANK[tr.stage];
|
||||||
|
if (typeof r === "number") last = r;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageRange {
|
||||||
|
rank: number;
|
||||||
|
/** ISO date this stage period begins (the stage-transition activity). */
|
||||||
|
startDate: string;
|
||||||
|
/**
|
||||||
|
* ISO date the period ends — the next chronologically-later activity with
|
||||||
|
* a higher stage rank. `null` means the period is open-ended (still in
|
||||||
|
* effect today); the chart should extend it to the right edge.
|
||||||
|
*/
|
||||||
|
endDate: string | null;
|
||||||
|
activityId: number;
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive per-activity stage ranges from the activity stream. Each
|
||||||
|
* stage-transition activity (one with a non-empty `stage`) produces one
|
||||||
|
* range: start = the activity's date, end = the date of the next
|
||||||
|
* chronologically-following activity whose stage rank is *strictly higher*.
|
||||||
|
* If no later activity has a higher rank, the range is open-ended.
|
||||||
|
*
|
||||||
|
* Multiple activities at the same stage each yield their own range; they'll
|
||||||
|
* share the same end date if they sit between the same two higher-rank
|
||||||
|
* boundaries.
|
||||||
|
*/
|
||||||
|
export function computeStageRanges(activities: ActivitySummary[]): StageRange[] {
|
||||||
|
type Item = {
|
||||||
|
t: number;
|
||||||
|
rank: number;
|
||||||
|
activityId: number;
|
||||||
|
isoDate: string;
|
||||||
|
subject: string | null;
|
||||||
|
};
|
||||||
|
const items: Item[] = [];
|
||||||
|
for (const a of activities) {
|
||||||
|
if (typeof a.stage !== "string" || a.stage.length === 0) continue;
|
||||||
|
const r = STAGE_RANK[a.stage];
|
||||||
|
if (typeof r !== "number" || r < 1 || r > 5) continue;
|
||||||
|
const t = new Date(a.date).getTime();
|
||||||
|
if (!Number.isFinite(t)) continue;
|
||||||
|
items.push({
|
||||||
|
t,
|
||||||
|
rank: r,
|
||||||
|
activityId: a.id,
|
||||||
|
isoDate: a.date,
|
||||||
|
subject: a.subject ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort((x, y) => x.t - y.t);
|
||||||
|
|
||||||
|
const ranges: StageRange[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const cur = items[i];
|
||||||
|
let endIso: string | null = null;
|
||||||
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
|
if (items[j].rank > cur.rank) {
|
||||||
|
endIso = items[j].isoDate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push({
|
||||||
|
rank: cur.rank,
|
||||||
|
startDate: cur.isoDate,
|
||||||
|
endDate: endIso,
|
||||||
|
activityId: cur.activityId,
|
||||||
|
subject: cur.subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
+46
-15
@@ -6,14 +6,15 @@ import type { NextConfig } from "next";
|
|||||||
*
|
*
|
||||||
* Two profiles:
|
* Two profiles:
|
||||||
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
|
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
|
||||||
* Applied to every route except /staff/report.
|
* Applied to every route except the embed-friendly ones.
|
||||||
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
|
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
|
||||||
* Lets the CiviCRM "Engagement Report" extension embed the staff page
|
* Lets the CiviCRM "Engagement Report" extension embed /staff/report,
|
||||||
* in an iframe on contact pages.
|
* and lets the lightbox iframe inside that page load the
|
||||||
|
* /api/staff/file proxy for PDF preview.
|
||||||
*
|
*
|
||||||
* The catch-all source uses a negative lookahead so it does NOT match
|
* The catch-all source uses a negative lookahead so it does NOT match the
|
||||||
* /staff/report — otherwise both rules apply and the browser ANDs the
|
* embed-friendly routes — otherwise both rules apply and the browser ANDs
|
||||||
* frame-ancestors directives together, blocking embedding entirely.
|
* the frame-ancestors directives together, blocking embedding entirely.
|
||||||
*/
|
*/
|
||||||
const isDev = process.env.NODE_ENV !== "production";
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
|
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
|
||||||
@@ -42,13 +43,38 @@ const sharedHeaders = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function civiOriginForCsp(): string {
|
/**
|
||||||
const raw = process.env.CIVI_BASE_URL;
|
* Civi origins permitted to embed the staff report in an iframe.
|
||||||
if (!raw) return "";
|
*
|
||||||
|
* Reads `CIVI_FRAME_ALLOWED_ORIGINS` (comma-separated origins) so a single
|
||||||
|
* survey.fci.coop deployment can be framed by both the dev Civi
|
||||||
|
* (client.crm.fci.coop) and the production Civi (crm.fci.coop). Falls
|
||||||
|
* back to the origin of `CIVI_BASE_URL` when the multi-origin var isn't
|
||||||
|
* set so existing single-Civi deployments keep working.
|
||||||
|
*
|
||||||
|
* Each entry is validated as a parsable URL; bad values are dropped and
|
||||||
|
* logged at build time rather than silently making the CSP invalid.
|
||||||
|
*/
|
||||||
|
function civiOriginsForCsp(): string[] {
|
||||||
|
const explicit = process.env.CIVI_FRAME_ALLOWED_ORIGINS;
|
||||||
|
if (explicit) {
|
||||||
|
const raws = explicit.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const origins: string[] = [];
|
||||||
|
for (const raw of raws) {
|
||||||
try {
|
try {
|
||||||
return new URL(raw).origin;
|
origins.push(new URL(raw).origin);
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
console.warn(`[next.config] ignoring invalid CIVI_FRAME_ALLOWED_ORIGINS entry: ${raw}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
}
|
||||||
|
const base = process.env.CIVI_BASE_URL;
|
||||||
|
if (!base) return [];
|
||||||
|
try {
|
||||||
|
return [new URL(base).origin];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +85,13 @@ const strictHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const staffEmbedHeaders = (() => {
|
const staffEmbedHeaders = (() => {
|
||||||
const origin = civiOriginForCsp();
|
const origins = civiOriginsForCsp();
|
||||||
const frameAncestors = origin ? `'self' ${origin}` : "'self'";
|
const frameAncestors = origins.length > 0 ? `'self' ${origins.join(" ")}` : "'self'";
|
||||||
return [
|
return [
|
||||||
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
|
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
|
||||||
...sharedHeaders,
|
...sharedHeaders,
|
||||||
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
|
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
|
||||||
|
// (X-Frame-Options can only encode one origin; CSP supersedes here.)
|
||||||
];
|
];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -84,8 +111,12 @@ const nextConfig: NextConfig = {
|
|||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{ source: "/staff/report", headers: staffEmbedHeaders },
|
{ source: "/staff/report", headers: staffEmbedHeaders },
|
||||||
// Catch-all that explicitly excludes /staff/report — see header notes.
|
// The lightbox in /staff/report iframes this proxy for inline PDF
|
||||||
{ source: "/((?!staff/report).*)", headers: strictHeaders },
|
// previews. Must share the embed-friendly profile so the browser
|
||||||
|
// doesn't block the same-origin iframe.
|
||||||
|
{ source: "/api/staff/file", headers: staffEmbedHeaders },
|
||||||
|
// Catch-all that excludes the embed-friendly routes — see header notes.
|
||||||
|
{ source: "/((?!staff/report|api/staff/file).*)", headers: strictHeaders },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
||||||
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
|
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
|
||||||
|
"test:mime": "node --test lib/mime.test.mjs",
|
||||||
|
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user