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:
+12
-1
@@ -12,7 +12,18 @@ JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
DELIVERY_MAX_DEVICES=2
|
||||
|
||||
# ---- Safaricom Daraja ----
|
||||
# ---- Platform operator ----
|
||||
# Comma-separated emails with cross-tenant access to /platform. Create these
|
||||
# accounts with `npm run seed`; public signup refuses these addresses.
|
||||
PLATFORM_ADMIN_EMAILS=
|
||||
|
||||
# ---- M-Pesa (optional, off for the pilot) ----
|
||||
# M-Pesa STK Push is out of scope for the pilot. While this is false every
|
||||
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
|
||||
# If you turn it on in production, DARAJA_CALLBACK_IP_ALLOWLIST is required.
|
||||
FEATURE_MPESA=false
|
||||
|
||||
# ---- Safaricom Daraja (only read when FEATURE_MPESA=true) ----
|
||||
DARAJA_ENV=sandbox
|
||||
DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx
|
||||
DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx
|
||||
|
||||
Generated
+1778
-1
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -8,7 +8,9 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"seed": "tsx scripts/seed.ts",
|
||||
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts"
|
||||
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
@@ -29,10 +31,12 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv": "^16.4.5",
|
||||
"mongodb-memory-server": "^10.1.4",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.3",
|
||||
"dotenv": "^16.4.5"
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
+45
-1
@@ -1,4 +1,5 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
|
||||
import { UserRole } from "@/lib/server/models/User";
|
||||
|
||||
@@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?:
|
||||
return auth.branchId;
|
||||
}
|
||||
|
||||
export const MANAGERS: UserRole[] = ["BRANCH_MANAGER", "SUPER_ADMIN"];
|
||||
|
||||
type Gate = { ok: true; auth: AccessTokenPayload } | { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* Authenticate the request and, when `roles` is given, authorize it.
|
||||
* 401 = not signed in; 403 = signed in with the wrong role. Every route
|
||||
* that limits roles must use this rather than relying on the admin pages
|
||||
* redirecting — the PRD requires enforcement on the backend too.
|
||||
*/
|
||||
export function authorize(req: NextRequest, roles?: UserRole[]): Gate {
|
||||
const auth = getAuth(req);
|
||||
if (!auth) {
|
||||
return { ok: false, response: NextResponse.json({ error: "Not authenticated" }, { status: 401 }) };
|
||||
}
|
||||
if (roles && !roles.includes(auth.role)) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: "You do not have permission to do this" }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, auth };
|
||||
}
|
||||
|
||||
type BranchParam = { ok: true; branchId: string | null } | { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* Resolve the `branchId` query parameter for a read route: owners may pick
|
||||
* a branch or ALL, everyone else is pinned to their own branch. Rejects a
|
||||
* malformed id with 400 instead of letting ObjectId() throw a 500.
|
||||
*/
|
||||
export function branchParam(auth: AccessTokenPayload, raw: string | null | undefined): BranchParam {
|
||||
const branchId = resolveBranchScope(auth, raw);
|
||||
if (branchId && !isValidObjectId(branchId)) {
|
||||
return { ok: false, response: NextResponse.json({ error: "Invalid branchId" }, { status: 400 }) };
|
||||
}
|
||||
return { ok: true, branchId };
|
||||
}
|
||||
|
||||
export function badRequest(message: string): NextResponse {
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
export function isProd(): boolean {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The business day for every tenant is the Kenyan calendar day
|
||||
// (Africa/Nairobi, EAT). EAT is a fixed UTC+3 with no daylight saving, so a
|
||||
// constant offset is exact — no timezone database needed.
|
||||
//
|
||||
// Never use `new Date().setHours(0)` or `toISOString().slice(0, 10)` for
|
||||
// "today" on the server: on Vercel the server clock is UTC, which would
|
||||
// start the business day at 03:00 EAT and push early-morning sales onto
|
||||
// the previous day.
|
||||
|
||||
const EAT_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Today's business day as YYYY-MM-DD. */
|
||||
export function todayNairobi(now: Date = new Date()): string {
|
||||
return new Date(now.getTime() + EAT_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isBusinessDate(value: string): boolean {
|
||||
if (!DATE_RE.test(value)) return false;
|
||||
const d = new Date(value + "T00:00:00.000Z");
|
||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
/** The UTC instants [start, end) covering one business day. */
|
||||
export function dayRangeNairobi(date: string): { start: Date; end: Date } {
|
||||
if (!isBusinessDate(date)) throw new Error(`Invalid business date: ${date}`);
|
||||
const start = new Date(new Date(date + "T00:00:00.000Z").getTime() - EAT_OFFSET_MS);
|
||||
return { start, end: new Date(start.getTime() + DAY_MS) };
|
||||
}
|
||||
|
||||
/** Start of the business day containing `now`. */
|
||||
export function startOfTodayNairobi(now: Date = new Date()): Date {
|
||||
return dayRangeNairobi(todayNairobi(now)).start;
|
||||
}
|
||||
|
||||
/** Start of the current business month (1st, 00:00 EAT). */
|
||||
export function startOfMonthNairobi(now: Date = new Date()): Date {
|
||||
return dayRangeNairobi(todayNairobi(now).slice(0, 8) + "01").start;
|
||||
}
|
||||
+72
-19
@@ -1,26 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
MONGODB_URI: z.string().min(1),
|
||||
const booleanFlag = z
|
||||
.enum(["true", "false", "1", "0", ""])
|
||||
.optional()
|
||||
.transform((v) => v === "true" || v === "1");
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||
JWT_REFRESH_TTL: z.string().default("30d"),
|
||||
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
||||
const schema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
MONGODB_URI: z.string().min(1),
|
||||
|
||||
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||
DARAJA_CONSUMER_KEY: z.string().min(1),
|
||||
DARAJA_CONSUMER_SECRET: z.string().min(1),
|
||||
DARAJA_SHORTCODE: z.string().min(1),
|
||||
DARAJA_PASSKEY: z.string().min(1),
|
||||
DARAJA_TRANSACTION_TYPE: z
|
||||
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
|
||||
.default("CustomerPayBillOnline"),
|
||||
DARAJA_CALLBACK_URL: z.string().url(),
|
||||
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
|
||||
});
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||
JWT_REFRESH_TTL: z.string().default("30d"),
|
||||
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
||||
|
||||
// M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it
|
||||
// is OFF unless explicitly enabled. When off, the Daraja variables are
|
||||
// not required and every /api/mpesa route answers 404.
|
||||
FEATURE_MPESA: booleanFlag,
|
||||
|
||||
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||
DARAJA_CONSUMER_KEY: z.string().optional(),
|
||||
DARAJA_CONSUMER_SECRET: z.string().optional(),
|
||||
DARAJA_SHORTCODE: z.string().optional(),
|
||||
DARAJA_PASSKEY: z.string().optional(),
|
||||
DARAJA_TRANSACTION_TYPE: z
|
||||
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
|
||||
.default("CustomerPayBillOnline"),
|
||||
DARAJA_CALLBACK_URL: z.string().url().optional(),
|
||||
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (!env.FEATURE_MPESA) return;
|
||||
const required = [
|
||||
"DARAJA_CONSUMER_KEY",
|
||||
"DARAJA_CONSUMER_SECRET",
|
||||
"DARAJA_SHORTCODE",
|
||||
"DARAJA_PASSKEY",
|
||||
"DARAJA_CALLBACK_URL",
|
||||
] as const;
|
||||
for (const key of required) {
|
||||
if (!env[key]) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "required when FEATURE_MPESA=true" });
|
||||
}
|
||||
}
|
||||
// An open callback endpoint lets anyone mark a pending sale as paid.
|
||||
if (env.NODE_ENV === "production" && !env.DARAJA_CALLBACK_IP_ALLOWLIST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["DARAJA_CALLBACK_IP_ALLOWLIST"],
|
||||
message: "required in production when FEATURE_MPESA=true",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type ServerEnv = z.infer<typeof schema>;
|
||||
let cached: ServerEnv | null = null;
|
||||
@@ -35,3 +69,22 @@ export function getEnv(): ServerEnv {
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function isMpesaEnabled(): boolean {
|
||||
return getEnv().FEATURE_MPESA;
|
||||
}
|
||||
|
||||
/** Daraja settings, only valid when FEATURE_MPESA is on (validated above). */
|
||||
export function getDarajaEnv() {
|
||||
const env = getEnv();
|
||||
if (!env.FEATURE_MPESA) throw new Error("M-Pesa is disabled (FEATURE_MPESA is not true)");
|
||||
return {
|
||||
DARAJA_ENV: env.DARAJA_ENV,
|
||||
DARAJA_CONSUMER_KEY: env.DARAJA_CONSUMER_KEY!,
|
||||
DARAJA_CONSUMER_SECRET: env.DARAJA_CONSUMER_SECRET!,
|
||||
DARAJA_SHORTCODE: env.DARAJA_SHORTCODE!,
|
||||
DARAJA_PASSKEY: env.DARAJA_PASSKEY!,
|
||||
DARAJA_TRANSACTION_TYPE: env.DARAJA_TRANSACTION_TYPE,
|
||||
DARAJA_CALLBACK_URL: env.DARAJA_CALLBACK_URL!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface IProduct extends Document {
|
||||
unitPrice: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ const productSchema = new Schema<IProduct>(
|
||||
unitPrice: { type: Number, required: true, min: 0 },
|
||||
sortOrder: { type: Number, default: 0 },
|
||||
isActive: { type: Boolean, default: true, index: true },
|
||||
lowStockThreshold: { type: Number, default: 10, min: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { getEnv } from "@/lib/server/env";
|
||||
import { getDarajaEnv as getEnv } from "@/lib/server/env";
|
||||
|
||||
function baseUrl(): string {
|
||||
return getEnv().DARAJA_ENV === "production"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Types } from "mongoose";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Business, IBusiness } from "@/lib/server/models/Business";
|
||||
import { TIERS, type TierId } from "@/lib/tiers";
|
||||
import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
||||
return (business.tier as TierId) ?? "STARTER";
|
||||
@@ -52,4 +52,19 @@ export async function userLimitError(
|
||||
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
|
||||
const biz = await Business.findById(businessId).select("tier").lean();
|
||||
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-facing reason this tenant may not use the app right now,
|
||||
* or null when access is allowed. Checked at login and on every token
|
||||
* refresh, so a suspension takes effect within one access-token lifetime
|
||||
* (15 minutes) even for devices that are already signed in.
|
||||
*/
|
||||
export async function tenantAccessError(businessId: Types.ObjectId | string): Promise<string | null> {
|
||||
const biz = await Business.findById(businessId).select("isActive subscriptionStatus").lean();
|
||||
if (!biz || !biz.isActive) return "This business account has been deactivated.";
|
||||
if (!isUsable((biz.subscriptionStatus as SubscriptionStatus) ?? "TRIALING")) {
|
||||
return "This business account is suspended. Contact support to restore access.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
|
||||
/** A MongoDB ObjectId string. Validating up front turns a would-be CastError 500 into a 400. */
|
||||
export const objectId = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => isValidObjectId(v) && /^[0-9a-f]{24}$/i.test(v), { message: "Invalid id" });
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import * as profit from "@/app/api/reports/profit/route";
|
||||
import * as expenses from "@/app/api/expenses/route";
|
||||
import * as purchases from "@/app/api/purchases/route";
|
||||
import * as products from "@/app/api/products/route";
|
||||
import * as productById from "@/app/api/products/[id]/route";
|
||||
import * as userById from "@/app/api/users/[id]/route";
|
||||
import * as branchById from "@/app/api/branches/[id]/route";
|
||||
import * as summary from "@/app/api/dashboard/summary/route";
|
||||
import * as login from "@/app/api/auth/login/route";
|
||||
import * as signup from "@/app/api/auth/signup/route";
|
||||
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||
import * as callback from "@/app/api/mpesa/callback/route";
|
||||
import { json, makeTenant, params, req, uuid } from "./helpers";
|
||||
|
||||
describe("role-based access on the backend", () => {
|
||||
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||
const t = await makeTenant();
|
||||
const as = t.cashierA;
|
||||
const responses = await Promise.all([
|
||||
profit.GET(req("GET", "/api/reports/profit", { as })),
|
||||
expenses.GET(req("GET", "/api/expenses", { as })),
|
||||
purchases.GET(req("GET", "/api/purchases", { as })),
|
||||
expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })),
|
||||
purchases.POST(
|
||||
req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } })
|
||||
),
|
||||
]);
|
||||
expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]);
|
||||
});
|
||||
|
||||
it("still lets a branch manager use them for their own branch", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await expenses.POST(
|
||||
req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } })
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401, not 403, when signed out", async () => {
|
||||
expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("only lets the owner create or reprice products", async () => {
|
||||
const t = await makeTenant();
|
||||
const body = { label: "1L", sizeLiters: 1, unitPrice: 20 };
|
||||
expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403);
|
||||
expect(
|
||||
(await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 })))
|
||||
.status
|
||||
).toBe(403);
|
||||
expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects a malformed branchId with 400 instead of crashing", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user deactivation", () => {
|
||||
it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => {
|
||||
const t = await makeTenant();
|
||||
await UserSession.create({
|
||||
userId: t.cashierA.id,
|
||||
deviceId: uuid(),
|
||||
refreshTokenHash: "h",
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
});
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||
params({ id: t.cashierA.id })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
|
||||
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let a manager touch another branch's staff", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||
params({ id: t.cashierB.id })
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not let the owner deactivate themselves", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.owner, body: { isActive: false } }),
|
||||
params({ id: t.owner.id })
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenant status", () => {
|
||||
it("refuses login for a suspended business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
|
||||
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||
|
||||
const res = await login.POST(
|
||||
req("POST", "/api/auth/login", {
|
||||
body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() },
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("still allows login for an active business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
|
||||
const res = await login.POST(
|
||||
req("POST", "/api/auth/login", {
|
||||
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform and M-Pesa hardening", () => {
|
||||
it("refuses public signup with a platform-operator email", async () => {
|
||||
const res = await signup.POST(
|
||||
req("POST", "/api/auth/signup", {
|
||||
body: {
|
||||
businessName: "Takeover Ltd",
|
||||
ownerName: "Mallory",
|
||||
email: "operator@aquaflow.test",
|
||||
phoneNumber: "0712345678",
|
||||
password: "password123",
|
||||
deviceId: uuid(),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("hides every M-Pesa route when FEATURE_MPESA is off", async () => {
|
||||
const t = await makeTenant();
|
||||
const push = await stkpush.POST(
|
||||
req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } })
|
||||
);
|
||||
const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } }));
|
||||
expect([push.status, cb.status]).toEqual([404, 404]);
|
||||
});
|
||||
|
||||
it("lets only the owner rename a branch", async () => {
|
||||
const t = await makeTenant();
|
||||
const body = { branchName: "Renamed" };
|
||||
expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403);
|
||||
const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA })));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.branch.branchName).toBe("Renamed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay";
|
||||
|
||||
describe("Nairobi business day", () => {
|
||||
it("rolls over at midnight EAT, not midnight UTC", () => {
|
||||
// 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct.
|
||||
expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12");
|
||||
// 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct.
|
||||
expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11");
|
||||
});
|
||||
|
||||
it("maps a business date to the UTC range 21:00 -> 21:00", () => {
|
||||
const { start, end } = dayRangeNairobi("2026-10-12");
|
||||
expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z");
|
||||
expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("starts the month on the 1st at 00:00 EAT", () => {
|
||||
expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z");
|
||||
expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects malformed dates", () => {
|
||||
expect(isBusinessDate("2026-02-30")).toBe(false);
|
||||
expect(isBusinessDate("2026-2-3")).toBe(false);
|
||||
expect(isBusinessDate("2026-02-28")).toBe(true);
|
||||
expect(() => dayRangeNairobi("nope")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MongoMemoryServer } from "mongodb-memory-server";
|
||||
|
||||
let server: MongoMemoryServer | null = null;
|
||||
|
||||
export async function setup({ provide }: { provide: (key: string, value: string) => void }) {
|
||||
server = await MongoMemoryServer.create();
|
||||
provide("mongoUri", server.getUri("aquaflow-test"));
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
await server?.stop();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import mongoose from "mongoose";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { User, UserRole } from "@/lib/server/models/User";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { signAccessToken } from "@/lib/server/jwt";
|
||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||
|
||||
let phoneSeq = 0;
|
||||
function nextPhone(): string {
|
||||
phoneSeq += 1;
|
||||
return "2547" + String(10_000_000 + phoneSeq).slice(-8);
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
id: string;
|
||||
businessId: string;
|
||||
role: UserRole;
|
||||
branchId: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
businessId: string;
|
||||
owner: Actor;
|
||||
branchA: string;
|
||||
branchB: string;
|
||||
managerA: Actor;
|
||||
cashierA: Actor;
|
||||
cashierB: Actor;
|
||||
water20: string;
|
||||
water5: string;
|
||||
}
|
||||
|
||||
async function makeUser(
|
||||
businessId: string,
|
||||
role: UserRole,
|
||||
branchId: string | null,
|
||||
label: string
|
||||
): Promise<Actor> {
|
||||
const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`;
|
||||
const user = await User.create({
|
||||
businessId,
|
||||
name: label,
|
||||
role,
|
||||
branchId,
|
||||
phoneNumber: nextPhone(),
|
||||
email,
|
||||
passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in
|
||||
});
|
||||
return { id: user._id.toString(), businessId, role, branchId, email };
|
||||
}
|
||||
|
||||
/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */
|
||||
export async function makeTenant(name = "Tenant"): Promise<Tenant> {
|
||||
const business = await Business.create({
|
||||
name,
|
||||
ownerId: new mongoose.Types.ObjectId(),
|
||||
tier: "ENTERPRISE",
|
||||
subscriptionStatus: "ACTIVE",
|
||||
});
|
||||
const businessId = business._id.toString();
|
||||
const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner");
|
||||
business.ownerId = new mongoose.Types.ObjectId(owner.id);
|
||||
await business.save();
|
||||
|
||||
const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" });
|
||||
const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" });
|
||||
const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA");
|
||||
const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA");
|
||||
const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB");
|
||||
|
||||
const [p20, p5] = await Product.insertMany([
|
||||
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 },
|
||||
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 },
|
||||
]);
|
||||
|
||||
return {
|
||||
businessId,
|
||||
owner,
|
||||
branchA: a._id.toString(),
|
||||
branchB: b._id.toString(),
|
||||
managerA,
|
||||
cashierA,
|
||||
cashierB,
|
||||
water20: p20._id.toString(),
|
||||
water5: p5._id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string {
|
||||
return signAccessToken({
|
||||
sub: actor.id,
|
||||
businessId: actor.businessId,
|
||||
role: actor.role,
|
||||
branchId: actor.branchId,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function req(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: { as?: Actor; body?: unknown; cookies?: Record<string, string> } = {}
|
||||
): NextRequest {
|
||||
const cookies: Record<string, string> = { ...(opts.cookies ?? {}) };
|
||||
if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as);
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(new URL(path, "http://localhost"), {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
},
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
});
|
||||
}
|
||||
|
||||
export function params<T extends Record<string, string>>(p: T) {
|
||||
return { params: p };
|
||||
}
|
||||
|
||||
export async function json<T = any>(res: Response): Promise<{ status: number; body: T }> {
|
||||
return { status: res.status, body: (await res.json()) as T };
|
||||
}
|
||||
|
||||
export function uuid(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterAll, beforeEach, inject } from "vitest";
|
||||
import mongoose from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface ProvidedContext {
|
||||
mongoUri: string;
|
||||
}
|
||||
}
|
||||
|
||||
process.env.MONGODB_URI = inject("mongoUri");
|
||||
process.env.JWT_ACCESS_SECRET = "test-access-secret-0123456789";
|
||||
process.env.JWT_REFRESH_SECRET = "test-refresh-secret-0123456789";
|
||||
process.env.PLATFORM_ADMIN_EMAILS = "operator@aquaflow.test";
|
||||
|
||||
beforeEach(async () => {
|
||||
await connectDb();
|
||||
const collections = await mongoose.connection.db!.collections();
|
||||
await Promise.all(collections.map((c) => c.deleteMany({})));
|
||||
await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes()));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
global.__mongooseCache = { conn: null, promise: null };
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
globalSetup: ["tests/globalSetup.ts"],
|
||||
setupFiles: ["tests/setup.ts"],
|
||||
// Route handlers share one cached mongoose connection per worker, so
|
||||
// run files sequentially against the single in-memory server.
|
||||
fileParallelism: false,
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 120_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user