ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { mailConfigured, sendMail } from "@/lib/mail";
export const dynamic = "force-dynamic";
const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max);
/* Retention.
*
* A message through the contact form carries a name, a work address, a facility, a role, whatever
* the person chose to write and the address they wrote it from. It was kept forever: the model has
* no facility to cascade from, so nothing would ever have deleted one. Twelve months is long
* enough for the enquiry and any follow-up it turns into, and the privacy note says the same
* number — this is the mechanism that makes that sentence true rather than aspirational.
*
* Swept from here rather than from a cron, because a cron is a second thing to deploy and this
* table only grows when this handler runs. The limiter is doing duty as an interval: one sweep an
* hour, and the message the person is sending never waits on it. */
const RETENTION_DAYS = 365;
function pruneOldMessages() {
if (!allow("contact-prune", 1, 60 * 60 * 1000)) return;
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
void prisma.contactMessage
.deleteMany({ where: { createdAt: { lt: cutoff } } })
.then((r) => { if (r.count) console.log(`[contact] retention: removed ${r.count} message(s) older than ${RETENTION_DAYS} days`); })
.catch((e) => console.error("[contact] retention sweep failed:", (e as Error).message));
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
// Two buckets: a burst guard and a slower daily ceiling, so one address can't grind through it.
if (!allow("contact:" + ip, 5, 60 * 60 * 1000) || !allow("contact-day:" + ip, 20, 24 * 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a few messages in a short time. Try again later, or email hello@threadcount.tech." }, { status: 429 });
}
let b: Record<string, unknown>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
// Honeypot: a real person never fills this in.
if (str(b.company, 100)) return NextResponse.json({ ok: true });
const name = str(b.name, 120);
const email = str(b.email, 160).toLowerCase();
const message = str(b.message, 4000);
if (!name) return NextResponse.json({ error: "Add your name so I know who I'm replying to." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Add an email address I can reply to." }, { status: 400 });
if (message.length < 10) return NextResponse.json({ error: "Say a little more about what you need." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
pruneOldMessages();
const row = await prisma.contactMessage.create({
data: {
name, email, message, ip,
role: str(b.role, 120), facility: str(b.facility, 160),
topic: str(b.topic, 60), slot: str(b.slot, 60),
},
});
const emailed = await sendMail(
`ThreadCount contact — ${row.topic || "A question"}${name}`,
[`From: ${name}${row.role ? ` (${row.role})` : ""}`, row.facility && `Facility: ${row.facility}`, `Email: ${email}`,
row.topic && `Topic: ${row.topic}`, row.slot && `Walkthrough: ${row.slot}`, "", message, "", `Received ${row.createdAt.toISOString()} from ${ip}`]
.filter(Boolean).join("\n"),
email,
);
if (emailed) await prisma.contactMessage.update({ where: { id: row.id }, data: { emailed: true } });
else if (!mailConfigured()) console.warn("[contact] stored", row.id, "— SMTP not configured, no notification sent");
return NextResponse.json({ ok: true });
}