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:
2026-10-11 18:34:35 +03:00
parent 2fbf7e6bce
commit d4808b3e46
38 changed files with 2700 additions and 158 deletions
+6
View File
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription";
export const runtime = "nodejs";
@@ -70,6 +71,11 @@ export async function POST(req: NextRequest) {
user.lockedUntil = null;
await user.save();
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
if (user.role === "DELIVERY") {
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
if (!binding.allowed) {
+2
View File
@@ -5,6 +5,7 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs";
@@ -49,5 +50,6 @@ export async function GET(req: NextRequest) {
maxBranches: tierDef.maxBranches,
maxUsers: tierDef.maxUsers,
},
features: { mpesa: isMpesaEnabled() },
});
}
+6
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { tenantAccessError } from "@/lib/server/subscription";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs";
@@ -29,6 +30,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
}
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
const newRefreshToken = generateRefreshToken();
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
session.lastUsedAt = new Date();
+9
View File
@@ -11,6 +11,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
import { isPlatformAdminEmail } from "@/lib/server/platform";
export const runtime = "nodejs";
@@ -52,6 +53,14 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
// and signup does not verify email ownership, so an operator address
// must never be claimable through public signup. Operator accounts are
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
}
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs";
const updateSchema = z
.object({
branchName: z.string().trim().min(1).max(120).optional(),
locationCity: z.string().trim().min(1).max(120).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
// PATCH /api/branches/:id — owner renames a branch or changes its city.
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Branch 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 branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, {
new: true,
}).lean();
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
return NextResponse.json({ branch });
}
+5 -6
View File
@@ -4,7 +4,7 @@ import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { getAuth, requireRole } from "@/lib/server/auth";
import { authorize, getAuth } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
@@ -40,10 +40,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
@@ -51,7 +50,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId;
const businessId = auth.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier);
+29 -10
View File
@@ -3,27 +3,48 @@ import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { authorize, badRequest, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const date = req.nextUrl.searchParams.get("date") || todayNairobi();
if (!isBusinessDate(date)) return badRequest("date must be YYYY-MM-DD");
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
const inventoryRows = await DailyInventory.aggregate([
{ $match: invMatch },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } },
// Lookups are tenant-scoped too, so a row can never surface another
// tenant's product or branch even if one were referenced by mistake.
{
$lookup: {
from: "products",
let: { pid: "$productId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$pid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "product",
},
},
{ $unwind: "$product" },
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } },
{
$lookup: {
from: "branches",
let: { bid: "$branchId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$bid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "branch",
},
},
{ $unwind: "$branch" },
{
$project: {
@@ -39,9 +60,7 @@ export async function GET(req: NextRequest) {
},
]);
const dayStart = new Date(date + "T00:00:00.000Z");
const dayEnd = new Date(dayStart);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
const { start: dayStart, end: dayEnd } = dayRangeNairobi(date);
const soldMatch: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId),
+9 -13
View File
@@ -2,26 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { authorize, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function todayRange() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const { start, end } = todayRange();
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const { start, end } = dayRangeNairobi(todayNairobi());
const matchStage: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId),
+16 -11
View File
@@ -3,21 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/expenses?branchId=&from=&to=
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is
// hard-scoped to their own branch regardless of query params.
// Managers and owners only. SUPER_ADMIN sees all (optionally filtered by
// branch); a BRANCH_MANAGER is hard-scoped to their own branch.
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch;
@@ -42,7 +46,7 @@ export async function GET(req: NextRequest) {
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
branchId: objectId.optional(),
category: z.enum(EXPENSE_CATEGORIES),
amount: z.number().positive().max(100_000_000),
description: z.string().trim().min(1).max(300),
@@ -50,12 +54,13 @@ const createSchema = z.object({
notes: z.string().trim().max(500).optional(),
});
// POST /api/expenses — any authenticated staff can record an expense, but
// only against a branch they are allowed to touch.
// POST /api/expenses — managers and owners only, and only against a branch
// they are allowed to touch.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
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);
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const dispatchSchema = z.object({
productId: z.string().min(1),
productId: objectId,
morningDispatched: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().optional(),
date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: objectId.optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null);
const parsed = dispatchSchema.safeParse(json);
@@ -31,18 +29,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant — blocks a crafted branchId from
// writing inventory into another business.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
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 });
const date = parsed.data.date ?? todayLocal();
// The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
return NextResponse.json({ inventory: row });
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const countSchema = z.object({
productId: z.string().min(1),
productId: objectId,
eveningPhysicalCount: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().optional(),
date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: objectId.optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null);
const parsed = countSchema.safeParse(json);
@@ -31,17 +29,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
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 });
const date = parsed.data.date ?? todayLocal();
// The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
return NextResponse.json({ inventory: row });
+2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
@@ -18,6 +19,7 @@ function isIpAllowed(ip: string): boolean {
}
export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb();
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
const sourceIp = getSourceIp(req);
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth } from "@/lib/server/auth";
import { queryStkStatus } from "@/lib/server/mpesa";
@@ -8,6 +9,7 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch";
@@ -31,6 +32,7 @@ function rateLimited(key: string): boolean {
}
export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
+9 -6
View File
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth";
import { authorize } from "@/lib/server/auth";
import { isValidObjectId } from "mongoose";
export const runtime = "nodejs";
@@ -12,14 +13,16 @@ const updateSchema = z.object({
unitPrice: z.number().nonnegative().optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
});
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// Owner only — product prices apply to every branch.
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Product not found" }, { status: 404 });
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
@@ -31,7 +34,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
}
const product = await Product.findOneAndUpdate(
{ _id: params.id, businessId: auth!.businessId },
{ _id: params.id, businessId: auth.businessId },
parsed.data,
{ new: true }
);
+23 -9
View File
@@ -1,18 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/products — the tenant's active catalogue. For branch-scoped
// staff the price returned is the price THEIR branch charges (the branch
// override when one exists), so the terminal shows exactly what the
// server will record.
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
if (auth.branchId) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) {
p.unitPrice = priceByProduct.get(p._id.toString()) ?? p.unitPrice;
}
}
return NextResponse.json({ products });
}
@@ -23,20 +35,22 @@ const createSchema = z.object({
category: z.string().trim().min(1).max(40).optional(),
unitPrice: z.number().nonnegative(),
sortOrder: z.number().int().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
});
// POST /api/products — owner only: the catalogue and its prices apply to
// every branch, so a branch manager must not be able to change them.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const gate = authorize(req, ["SUPER_ADMIN"]);
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 product = await Product.create({ ...parsed.data, businessId: auth!.businessId });
const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
return NextResponse.json({ product }, { status: 201 });
}
+13 -8
View File
@@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db";
import { Purchase } from "@/lib/server/models/Purchase";
import { Product } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,11 +13,14 @@ export const dynamic = "force-dynamic";
// GET /api/purchases?branchId=&from=&to=
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch;
@@ -41,9 +45,9 @@ export async function GET(req: NextRequest) {
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
branchId: objectId.optional(),
supplierName: z.string().trim().min(1).max(120),
productId: z.string().trim().min(1).optional(),
productId: objectId.optional(),
productLabel: z.string().trim().min(1).max(120).optional(),
quantity: z.number().int().positive().max(1_000_000),
unitCost: z.number().nonnegative().max(100_000_000),
@@ -57,8 +61,9 @@ const createSchema = z.object({
// branch stock.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
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);
+12 -7
View File
@@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase";
import { Expense } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
import { startOfMonthNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,17 +18,21 @@ export const dynamic = "force-dynamic";
// Net Profit = Gross Profit - Expenses
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
// Revenue, costs and profit are owner/manager information, never a cashier's.
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url);
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId"));
const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
// Default range: start of the current month -> now.
// Default range: start of the current business month (EAT) -> now.
const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
+9 -7
View File
@@ -1,25 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
import { authorize, branchParam } from "@/lib/server/auth";
import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (branchId) filter.branchId = branchId;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
filter.createdAt = { $gte: startOfDay };
filter.createdAt = { $gte: startOfTodayNairobi() };
const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 })
+86
View File
@@ -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 });
}
+21 -21
View File
@@ -4,7 +4,8 @@ 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 { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
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";
@@ -13,14 +14,14 @@ export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
const filter: Record<string, unknown> = { businessId: auth!.businessId };
if (branchId) filter.branchId = branchId;
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
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 });
}
@@ -28,7 +29,7 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({
name: z.string().trim().min(1).max(120),
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
branchId: z.string().min(1).optional(),
branchId: objectId.optional(),
phoneNumber: z.string().min(9).max(15),
email: z.string().email(),
password: z.string().min(8),
@@ -36,10 +37,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
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);
@@ -48,22 +48,22 @@ export async function POST(req: NextRequest) {
}
const data = parsed.data;
if (auth!.role === "BRANCH_MANAGER") {
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) {
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;
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 }).lean();
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 });
}
@@ -77,15 +77,15 @@ export async function POST(req: NextRequest) {
}
// Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth!.businessId);
const userErr = await userLimitError(auth!.businessId, tier);
const tier = await loadTier(auth.businessId);
const userErr = await userLimitError(auth.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
const passwordHash = await bcrypt.hash(data.password, 12);
try {
const user = await User.create({
businessId: auth!.businessId,
businessId: auth.businessId,
name: data.name,
role: data.role,
branchId: branchId ?? null,
@@ -96,7 +96,7 @@ export async function POST(req: NextRequest) {
if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findOneAndUpdate(
{ _id: branchId, businessId: auth!.businessId },
{ _id: branchId, businessId: auth.businessId },
{ managerId: user._id }
);
}