Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync #1
+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