1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
82 lines
4.0 KiB
TypeScript
82 lines
4.0 KiB
TypeScript
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 });
|
|
}
|