import { NextRequest, NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { User } from "@/lib/server/models/User"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { objectId } from "@/lib/server/validation"; import { normalizeKenyanPhone } from "@/lib/server/phone"; import { userLimitError, loadTier } from "@/lib/server/subscription"; import { issueInvite, unusablePasswordHash } from "@/lib/server/invites"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); if (!gate.ok) return gate.response; const auth = gate.auth; const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); if (!scope.ok) return scope.response; const filter: Record = { businessId: auth.businessId }; if (scope.branchId) filter.branchId = scope.branchId; const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean(); // invitePending: the person has not set their password yet. return NextResponse.json({ users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })), }); } const createSchema = z.object({ name: z.string().trim().min(1).max(120), role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]), branchId: objectId.optional(), phoneNumber: z.string().min(9).max(15), email: z.string().email(), // Omit the password to invite instead: the response carries a one-time // link the person uses to set their own password. password: z.string().min(8).optional(), }); export async function POST(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); if (!gate.ok) return gate.response; const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } const data = parsed.data; if (auth.role === "BRANCH_MANAGER") { if (data.role !== "DELIVERY") { return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 }); } if (data.branchId && data.branchId !== auth.branchId) { return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 }); } } const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined; if (data.role !== "SUPER_ADMIN" && !branchId) { return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 }); } if (branchId) { const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean(); if (!branch) { return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); } } let phone: string; try { phone = normalizeKenyanPhone(data.phoneNumber); } catch (e: any) { return NextResponse.json({ error: e.message }, { status: 400 }); } // Enforce the plan's user cap (counts owner + managers + staff). const tier = await loadTier(auth.businessId); const userErr = await userLimitError(auth.businessId, tier); if (userErr) return NextResponse.json({ error: userErr }, { status: 403 }); const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash(); try { const user = new User({ businessId: auth.businessId, name: data.name, role: data.role, branchId: branchId ?? null, phoneNumber: phone, email: data.email.toLowerCase(), passwordHash, }); const inviteUrl = data.password ? null : issueInvite(user, req); await user.save(); if (data.role === "BRANCH_MANAGER" && branchId) { await Branch.findOneAndUpdate( { _id: branchId, businessId: auth.businessId }, { managerId: user._id } ); } const obj: any = user.toObject(); delete obj.passwordHash; delete obj.inviteTokenHash; return NextResponse.json({ user: obj, inviteUrl }, { status: 201 }); } catch (err: any) { if (err?.code === 11000) { return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 }); } throw err; } }