f865837b27
The initial test report (run before the pilot-readiness changes) raised seven findings and a retest plan. This closes the ones still open. - F-01: a business on its free trial may run 2 active branches whatever its tier, so an owner can try transfers and per-branch stock. Only active branches count towards any limit. - F-06: owners can rename branches and deactivate/reactivate them from the Branches page. A branch can only be deactivated once it holds no stock and no active staff; its history stays in every report, and it leaves every picker. - F-04: the Products page lists deactivated products under a toggle with Reactivate. Deactivating warns about stock still on hand, which stays on the Stock page so it can be transferred or counted out. - F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of everything being litres. - CAT-12: product prices must be above zero and names are unique per business, ignoring case. - F-03: a reversed report period is refused by the page and the API, and the page labels which period the cards show. Report dates are Nairobi business days. - F-07: cost of goods sold is units sold x average buying price. Stock bought in the period is reported separately, with a cash surplus line, so unsold stock no longer shows as a loss. - STA-04: staff and new branch managers can be invited with a one-time, 7-day link; the person sets their own password. Managers can issue a new link for their own cashiers. - OFF-02 / POS-02: the till shows each product's stock and deducts every sale immediately, online or offline; it warns before selling more than the recorded stock, and has a Clear sale button. - npm run backfill:stock turns purchases recorded before the stock ledger into stock, so existing tenants (and the report's ZERRO fixtures) start from what they bought. tests/retest-plan.test.ts runs the report's completion and retest plan and checks every balance and total it predicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.7 KiB
TypeScript
88 lines
3.7 KiB
TypeScript
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 { ACTIVE_BRANCH, 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, ...ACTIVE_BRANCH }).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;
|
|
delete obj.inviteTokenHash;
|
|
return NextResponse.json({ user: obj });
|
|
}
|