Address the 11 October test report findings
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>
This commit is contained in:
@@ -3,11 +3,12 @@ import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
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";
|
||||
@@ -22,8 +23,11 @@ export async function GET(req: NextRequest) {
|
||||
if (!scope.ok) return scope.response;
|
||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||
if (scope.branchId) filter.branchId = scope.branchId;
|
||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
||||
return NextResponse.json({ users });
|
||||
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({
|
||||
@@ -32,7 +36,9 @@ const createSchema = z.object({
|
||||
branchId: objectId.optional(),
|
||||
phoneNumber: z.string().min(9).max(15),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
// 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) {
|
||||
@@ -63,7 +69,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (branchId) {
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||
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 });
|
||||
}
|
||||
@@ -81,10 +87,10 @@ export async function POST(req: NextRequest) {
|
||||
const userErr = await userLimitError(auth.businessId, tier);
|
||||
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
|
||||
|
||||
try {
|
||||
const user = await User.create({
|
||||
const user = new User({
|
||||
businessId: auth.businessId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
@@ -93,6 +99,8 @@ export async function POST(req: NextRequest) {
|
||||
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(
|
||||
@@ -103,7 +111,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const obj: any = user.toObject();
|
||||
delete obj.passwordHash;
|
||||
return NextResponse.json({ user: obj }, { status: 201 });
|
||||
delete obj.inviteTokenHash;
|
||||
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 11000) {
|
||||
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
||||
|
||||
Reference in New Issue
Block a user