ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 12:12:58 +10:00
commit cbf533d90b
275 changed files with 43340 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { switches } from "@/lib/switches";
import { alertNewSignup } from "@/lib/ops/alerts";
import { recordAuthEvent } from "@/lib/audit";
import { TRIAL_DAYS } from "@/lib/plan";
export const dynamic = "force-dynamic";
/* Creating a facility.
*
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
* stranger and the owner identically, so a typo produces no signal at all until the day the
* password is forgotten, and by then the facility is unreachable and undeletable.
*
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
* that this is the address that recovers the account, and the answer here says whether it was
* sent — which is what lets the sign-up screen show the address back and tell someone who never
* receives it what to do about it while they are still signed in and can still act.
*/
function welcomeEmail(first: string, facility: string) {
const subject = "Your ThreadCount facility is set up";
const text = [
`Hi ${first || "there"},`,
"",
`${facility} is set up on ThreadCount, and this address is the coordinator account for it.`,
"",
"Keep this message. This is the address a password reset is sent to, and it is the only way",
"back into the facility if the password is forgotten — so if it is wrong, sign in and add a",
"second admin with an address that works, under Settings → Users.",
"",
`${process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech"}/app`,
"",
"— ThreadCount",
].join("\n");
return { subject, text };
}
export async function POST(req: NextRequest) {
const sw = await switches();
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
let b: Record<string, string>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
const facility = String(b.facility || "").trim().slice(0, 120);
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
const password = String(b.password || "");
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
const u = await prisma.$transaction(async (tx) => {
// No staff groups: the facility names its own. Any list handed over here would be one employer's
// organisation chart on another employer's register, and a group sitting on a route nobody
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
// and nobody has been promised a kit the counter would not hand over.
// Until plans are live the page still says free, so a facility created today is grandfathered:
// free with everything, for good. Once they are live a new room starts on the plan it chose —
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
// sent as `plan` is Hosted Small: the free room is the safe misreading.
const trial = sw.plansLive && b.plan === "hosted_facility";
const planData = !sw.plansLive
? { grandfathered: true, planStatus: "free" }
: trial
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
: { plan: "hosted_small", planStatus: "free" };
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData } });
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
});
await setSessionCookie(u.id, u.passwordHash);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
const em = welcomeEmail(first, facility);
const mailed = await sendTo(email, em.subject, em.text);
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
// address — the screen says so rather than pretending the address has been proven.
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
}