Staff report: inline lightbox for image/PDF attachments

Adds a /api/staff/file proxy that re-streams Civi attachments with
Content-Disposition: inline so a native <dialog> lightbox can preview
images and PDFs in place. Office docs keep their plain download link
and gain a "View in Google Docs" secondary link (uses the Civi-signed
URL so Google can fetch without our staff key).

Also threads mime through /api/staff/report (Attachment.get mime_type)
so the dispatcher picks the right affordance without relying solely on
filename inference.
This commit is contained in:
Joel Brock
2026-06-15 11:56:45 -07:00
parent 5203dabeac
commit 6850ff9dee
8 changed files with 648 additions and 29 deletions
+174
View File
@@ -0,0 +1,174 @@
/**
* 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. Server-side check that the requested file is actually linked to the
* `org`. This prevents the staff key from being used to pull arbitrary
* file ids out of CiviCRM — a file is reachable only if its
* entity_table/entity_id ties back to the org (directly, for org
* custom-field files; or via an activity's target_contact_id, for
* activity custom-field files).
*
* 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 { civi3 } from "@/lib/civicrm";
import { resolveMime } from "@/lib/mime.mjs";
const MAX_BYTES = 10 * 1024 * 1024;
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;
entity_table?: string;
entity_id?: string | number;
}
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
const res = await civi3<AttachmentRow>("Attachment", "get", {
id: fileId,
return: "id,url,mime_type,name,entity_table,entity_id",
sequential: 1,
});
return res.values?.[0] ?? null;
}
/** Confirm a file is reachable from `orgId`. Returns false on any uncertainty. */
async function fileBelongsToOrg(row: AttachmentRow, orgId: number): Promise<boolean> {
const entityId = Number(row.entity_id);
if (!Number.isFinite(entityId) || entityId <= 0) return false;
const entityTable = String(row.entity_table ?? "");
if (entityTable === "civicrm_contact") {
return entityId === orgId;
}
if (entityTable === "civicrm_activity") {
// The activity must have orgId in its target_contact_id list. APIv4
// exposes this as `target_contact_id` array; we just need a hit-check.
try {
const probe = await civi3<{ id: string | number }>("Activity", "get", {
id: entityId,
target_contact_id: orgId,
return: "id",
sequential: 1,
});
return Array.isArray(probe.values) && probe.values.length > 0;
} catch {
return false;
}
}
return false;
}
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 });
}
const belongs = await fileBelongsToOrg(row, orgId);
if (!belongs) {
// Don't differentiate from "not found" — leaking link existence to a
// probe-with-wrong-org gives no useful info to a legit caller and a
// little to an attacker.
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 mime = resolveMime(
row.mime_type ?? upstreamRes.headers.get("content-type"),
row.name,
);
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
const headers: Record<string, string> = {
"Content-Type": mime,
"Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
};
if (Number.isFinite(contentLength)) {
headers["Content-Length"] = String(contentLength);
}
return new NextResponse(upstreamRes.body, { status: 200, headers });
}
+14 -4
View File
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
{
activityId: 9012,
date: daysAgo(3),
value: { id: 4242, file_name: "co-op-vision.pdf" },
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
},
],
},
@@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
}
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
@@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
// 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 }>("Attachment", "get", {
civi3<{ id: string | number; url?: string; mime_type?: string }>(
"Attachment",
"get",
{
id: fid,
return: "id,url",
return: "id,url,mime_type",
sequential: 1,
}).then((r) => ({ fid, row: r.values?.[0] })),
},
).then((r) => ({ fid, row: r.values?.[0] })),
),
);
for (const result of lookups) {
@@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
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);
}
}
}
@@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
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 {
@@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: v,
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 });
+31 -22
View File
@@ -19,6 +19,7 @@ import {
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
import { FileLink } from "./report/FileLink";
interface StaffReportViewProps {
org: number;
@@ -207,6 +208,8 @@ export function StaffReportView({
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
@@ -271,10 +274,14 @@ function StaffSection({
section,
options,
civiBaseUrl,
org,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const filled = 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}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
</ul>
@@ -374,10 +383,14 @@ function CompactFieldRow({
field,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
@@ -394,6 +407,8 @@ function CompactFieldRow({
entry={latest}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
{latest.date ? (
@@ -431,6 +446,8 @@ function CompactFieldRow({
entry={e}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
</li>
@@ -446,41 +463,33 @@ function FieldValue({
entry,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as
| { id?: number | string; file_name?: string; url?: string }
| { id?: number | string; file_name?: string; url?: string; mime?: string }
| null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
// Attachment.get. If absent, fall back to the WebForm-mw Civi extension's
// file-redirect route — it mints the fcs server-side and 302s to the
// real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi
// on a null fcs JWT decode.)
let href = "#";
if (v.url) {
href = v.url.startsWith("http")
? v.url
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
} else if (civiBaseUrl) {
href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
<FileLink
fileId={id}
fileName={name}
civiSignedUrl={v.url}
mime={v.mime}
org={org}
authKey={authKey}
civiBaseUrl={civiBaseUrl}
/>
);
}
return (
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useRef } 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);
// 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";
return (
<dialog
ref={ref}
onClick={onDialogClick}
aria-label={`Preview: ${filename}`}
className="m-0 h-full max-h-screen w-full max-w-screen-2xl rounded-none bg-transparent p-0 backdrop:bg-ink/70"
>
<div className="flex h-full flex-col">
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-3 shadow-sm sm:px-6">
<p className="min-w-0 truncate font-display text-base text-ink">
{filename}
</p>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={downloadHref}
className="text-sm 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-sm 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-4">
{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 max-w-screen-lg 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>
);
}
+133
View File
@@ -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}`;
}
+86
View File
@@ -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";
}
+82
View File
@@ -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");
});
+3 -1
View File
@@ -8,7 +8,9 @@
"start": "next start",
"lint": "eslint",
"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": {
"next": "16.2.6",