Enforce roles and tenant status on the server, and use the Kenyan business day
The admin pages redirected cashiers away, but the API itself let them read profit, expenses and purchases and record expenses and purchases. Branch managers could also change product prices for every branch. Every gated route now goes through authorize(), which answers 401 when signed out and 403 for the wrong role. - Owners and managers only: profit report, expenses, purchases, users, morning dispatch and evening count. Owner only: create and edit products, rename branches. - PATCH /api/users/:id deactivates or reactivates staff (managers: their own cashiers only) and revokes the user's device sessions. PATCH /api/branches/:id renames a branch. - Login and token refresh refuse inactive or SUSPENDED businesses. - Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered operator email can no longer be claimed. - M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the pilot): its routes return 404 and the Daraja variables are optional. Turning it on in production requires the callback IP allowlist. - "Today" and the default month now follow Africa/Nairobi instead of the server's UTC clock, so the business day no longer starts at 03:00 EAT. - Malformed ids in query strings and bodies return 400 instead of a 500, the inventory routes check the product belongs to the tenant, and the reconciliation lookups are tenant-scoped. - Branch-scoped staff see their branch's override price from GET /api/products. - Adds a vitest suite against an in-memory MongoDB (npm test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||
import { objectId } from "@/lib/server/validation";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
isActive: z.boolean().optional(),
|
||||
branchId: objectId.optional(), // owner only: move staff to another branch
|
||||
})
|
||||
.refine((v) => v.isActive !== undefined || v.branchId !== undefined, { message: "No fields to update" });
|
||||
|
||||
/**
|
||||
* PATCH /api/users/:id — deactivate/reactivate a staff account, or (owner
|
||||
* only) move it to another branch. This is how an owner "removes" a user:
|
||||
* the account and its sales history stay, but it can no longer sign in.
|
||||
*
|
||||
* SUPER_ADMIN any user in the business except themselves and the owner of record
|
||||
* BRANCH_MANAGER only DELIVERY staff in their own branch, and only isActive
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
const gate = authorize(req, MANAGERS);
|
||||
if (!gate.ok) return gate.response;
|
||||
const auth = gate.auth;
|
||||
|
||||
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = updateSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const { isActive, branchId } = parsed.data;
|
||||
|
||||
const target = await User.findOne({ _id: params.id, businessId: auth.businessId });
|
||||
if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
|
||||
if (target._id.toString() === auth.sub) {
|
||||
return NextResponse.json({ error: "You cannot change your own account here" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (auth.role === "BRANCH_MANAGER") {
|
||||
const ownStaff = target.role === "DELIVERY" && target.branchId?.toString() === auth.branchId;
|
||||
if (!ownStaff) {
|
||||
return NextResponse.json({ error: "Branch managers may only manage delivery staff in their own branch" }, { status: 403 });
|
||||
}
|
||||
if (branchId !== undefined) {
|
||||
return NextResponse.json({ error: "Only the owner can move staff between branches" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const business = await Business.findById(auth.businessId).select("ownerId").lean();
|
||||
if (business && business.ownerId.toString() === target._id.toString()) {
|
||||
return NextResponse.json({ error: "The business owner's account cannot be changed" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (branchId !== undefined) {
|
||||
if (target.role === "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
|
||||
}
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||
target.branchId = branch._id;
|
||||
}
|
||||
if (isActive !== undefined) target.isActive = isActive;
|
||||
await target.save();
|
||||
|
||||
// Revoke every device session so a deactivated (or moved) user cannot
|
||||
// mint new access tokens; any current access token expires within 15m.
|
||||
if (isActive === false || branchId !== undefined) {
|
||||
await UserSession.deleteMany({ userId: target._id });
|
||||
}
|
||||
|
||||
const obj = target.toObject() as unknown as Record<string, unknown>;
|
||||
delete obj.passwordHash;
|
||||
return NextResponse.json({ user: obj });
|
||||
}
|
||||
Reference in New Issue
Block a user