46a1911c1c
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
94 lines
6.0 KiB
TypeScript
94 lines
6.0 KiB
TypeScript
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() });
|
|
}
|