Initial commit: NetsKey ERP (copper & cream redesign)

This commit is contained in:
EOH Developer
2026-10-02 23:48:11 -07:00
commit 3dc6eef18d
86 changed files with 9176 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { Branch } from "@/lib/server/models/Branch";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { getEnv } from "@/lib/server/env";
export const runtime = "nodejs";
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(),
});
async function enforceDeviceBinding(userId: string, deviceId: string) {
const env = getEnv();
const existing = await UserSession.find({ userId }).sort({ lastUsedAt: -1 }).lean();
const alreadyBound = existing.some((s) => s.deviceId === deviceId);
if (alreadyBound || existing.length < env.DELIVERY_MAX_DEVICES) {
return { allowed: true as const };
}
return {
allowed: false as const,
activeDevices: existing.map((s) => ({ deviceId: s.deviceId, label: s.deviceLabel, lastUsedAt: s.lastUsedAt })),
};
}
export async function POST(req: NextRequest) {
await connectDb();
const json = await req.json().catch(() => null);
const parsed = loginSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { email, password, deviceId, deviceLabel } = parsed.data;
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
if (!user || !user.isActive) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
}
const validPassword = await user.comparePassword(password);
if (!validPassword) {
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
user.failedLoginAttempts = 0;
}
await user.save();
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await user.save();
if (user.role === "DELIVERY") {
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
if (!binding.allowed) {
return NextResponse.json(
{
error: `This account is already active on ${getEnv().DELIVERY_MAX_DEVICES} device(s). Log out one to continue.`,
activeDevices: binding.activeDevices,
},
{ status: 409 }
);
}
}
const refreshToken = generateRefreshToken();
const refreshTokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + refreshTtlMs());
await UserSession.findOneAndUpdate(
{ userId: user._id, deviceId },
{ refreshTokenHash, expiresAt, deviceLabel: deviceLabel ?? null, lastUsedAt: new Date() },
{ upsert: true, new: true }
);
const accessToken = signAccessToken({
sub: user._id.toString(),
role: user.role,
branchId: user.branchId ? user.branchId.toString() : null,
deviceId,
});
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const res = NextResponse.json({
user: {
id: user._id,
name: user.name,
role: user.role,
branchId: user.branchId,
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
},
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
});
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth, ACCESS_COOKIE, REFRESH_COOKIE } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
const res = NextResponse.json({ ok: true });
if (auth) {
await UserSession.deleteOne({ userId: auth.sub, deviceId: auth.deviceId });
}
res.cookies.set(ACCESS_COOKIE, "", { path: "/", maxAge: 0 });
res.cookies.set(REFRESH_COOKIE, "", { path: "/api/auth", maxAge: 0 });
return res;
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth } from "@/lib/server/auth";
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 user = await User.findById(auth.sub).lean();
if (!user || !user.isActive) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
return NextResponse.json({
user: {
id: user._id,
name: user.name,
role: user.role,
branchId: user.branchId,
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
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 { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
await connectDb();
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const body = await req.json().catch(() => ({}));
const deviceId = body?.deviceId as string | undefined;
if (!refreshToken || !deviceId) {
return NextResponse.json({ error: "No active session" }, { status: 401 });
}
const tokenHash = hashRefreshToken(refreshToken);
const session = await UserSession.findOne({ deviceId, refreshTokenHash: tokenHash });
if (!session || session.expiresAt.getTime() < Date.now()) {
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
}
const user = await User.findById(session.userId);
if (!user || !user.isActive) {
await UserSession.deleteOne({ _id: session._id });
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
}
const newRefreshToken = generateRefreshToken();
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
session.lastUsedAt = new Date();
session.expiresAt = new Date(Date.now() + refreshTtlMs());
await session.save();
const accessToken = signAccessToken({
sub: user._id.toString(),
role: user.role,
branchId: user.branchId ? user.branchId.toString() : null,
deviceId,
});
const res = NextResponse.json({ ok: true });
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, newRefreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth } from "@/lib/server/auth";
export const runtime = "nodejs";
export async function DELETE(req: NextRequest, { params }: { params: { deviceId: string } }) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
await UserSession.deleteOne({ userId: auth.sub, deviceId: params.deviceId });
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { UserSession } from "@/lib/server/models/UserSession";
import { getAuth } from "@/lib/server/auth";
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 sessions = await UserSession.find({ userId: auth.sub }).sort({ lastUsedAt: -1 }).lean();
return NextResponse.json({
sessions: sessions.map((s) => ({
deviceId: s.deviceId,
label: s.deviceLabel,
lastUsedAt: s.lastUsedAt,
isCurrent: s.deviceId === auth.deviceId,
})),
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole } from "@/lib/server/auth";
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 });
if (auth.role === "SUPER_ADMIN") {
const branches = await Branch.find().sort({ branchName: 1 }).lean();
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
const branch = await Branch.findById(auth.branchId).lean();
return NextResponse.json({ branches: branch ? [branch] : [] });
}
const createSchema = z.object({
branchName: z.string().trim().min(1).max(120),
locationCity: z.string().trim().min(1).max(120),
});
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 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 branch = await Branch.create(parsed.data);
return NextResponse.json({ branch }, { status: 201 });
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
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";
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 branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
const invMatch: Record<string, unknown> = { date };
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" } },
{ $unwind: "$product" },
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } },
{ $unwind: "$branch" },
{
$project: {
_id: 0,
branchId: 1,
branchName: "$branch.branchName",
productId: 1,
productLabel: "$product.label",
sizeLiters: "$product.sizeLiters",
morningDispatched: 1,
eveningPhysicalCount: 1,
},
},
]);
const dayStart = new Date(date + "T00:00:00.000Z");
const dayEnd = new Date(dayStart);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
const soldMatch: Record<string, unknown> = {
createdAt: { $gte: dayStart, $lt: dayEnd },
paymentStatus: "SUCCESS",
};
if (branchId) soldMatch.branchId = new mongoose.Types.ObjectId(branchId);
const soldRows = await Transaction.aggregate([
{ $match: soldMatch },
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, totalSold: { $sum: "$quantity" } } },
]);
const soldMap = new Map(
soldRows.map((r) => [r._id.branchId.toString() + ":" + r._id.productId.toString(), r.totalSold as number])
);
const rows = inventoryRows.map((row: any) => {
const key = row.branchId.toString() + ":" + row.productId.toString();
const totalSold = soldMap.get(key) ?? 0;
const expectedRemaining = row.morningDispatched - totalSold;
const reconciledOk =
row.eveningPhysicalCount != null ? row.eveningPhysicalCount === expectedRemaining : null;
return { ...row, totalSold, expectedRemaining, reconciledOk };
});
return NextResponse.json({ date, rows });
}
+102
View File
@@ -0,0 +1,102 @@
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";
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 branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const { start, end } = todayRange();
const matchStage: Record<string, unknown> = { createdAt: { $gte: start, $lt: end } };
if (branchId) matchStage.branchId = new mongoose.Types.ObjectId(branchId);
const [totals] = await Transaction.aggregate([
{ $match: matchStage },
{
$group: {
_id: null,
cashTotal: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "CASH"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
"$amountTotal",
0,
],
},
},
mpesaTotal: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
"$amountTotal",
0,
],
},
},
totalSales: { $sum: { $cond: [{ $eq: ["$paymentStatus", "SUCCESS"] }, 1, 0] } },
pendingMpesa: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "PENDING"] }] },
1,
0,
],
},
},
},
},
]);
let branchRanking: any[] = [];
if (!branchId) {
branchRanking = await Transaction.aggregate([
{ $match: { createdAt: { $gte: start, $lt: end }, paymentStatus: "SUCCESS" } },
{
$group: {
_id: "$branchId",
revenue: { $sum: "$amountTotal" },
salesCount: { $sum: 1 },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
},
},
{ $sort: { revenue: -1 } },
{ $lookup: { from: "branches", localField: "_id", foreignField: "_id", as: "branch" } },
{ $unwind: "$branch" },
{
$project: {
_id: 0,
branchId: "$_id",
branchName: "$branch.branchName",
locationCity: "$branch.locationCity",
revenue: 1,
salesCount: 1,
cashTotal: 1,
mpesaTotal: 1,
},
},
]);
}
return NextResponse.json({
scope: branchId ? "branch" : "global",
totals: totals ?? { cashTotal: 0, mpesaTotal: 0, totalSales: 0, pendingMpesa: 0 },
branchRanking,
});
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } 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.
export async function GET(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const filter: Record<string, unknown> = {};
if (scopeBranch) filter.branchId = scopeBranch;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (from || to) {
const createdAt: Record<string, Date> = {};
if (from) createdAt.$gte = new Date(from);
if (to) createdAt.$lte = new Date(to);
filter.createdAt = createdAt;
}
const expenses = await Expense.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.sort({ createdAt: -1 })
.limit(500)
.lean();
return NextResponse.json({ expenses });
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
category: z.enum(EXPENSE_CATEGORIES),
amount: z.number().positive().max(100_000_000),
description: z.string().trim().min(1).max(300),
reference: z.string().trim().max(80).optional(),
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.
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
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 }
);
}
// Resolve the branch this expense is booked against, then enforce scope.
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = parsed.data.branchId ?? null;
if (!branchId) {
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
}
} else {
branchId = auth.branchId;
// A scoped user may not book an expense onto another branch.
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
}
}
const expense = await Expense.create({
branchId,
category: parsed.data.category,
amount: parsed.data.amount,
description: parsed.data.description,
reference: parsed.data.reference ?? null,
notes: parsed.data.notes ?? null,
recordedBy: auth.sub,
});
return NextResponse.json({ expense }, { status: 201 });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await connectDb();
return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });
} catch (err: any) {
return NextResponse.json({ status: "error", detail: err.message }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const dispatchSchema = z.object({
productId: z.string().min(1),
morningDispatched: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().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 json = await req.json().catch(() => null);
const parsed = dispatchSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
const date = parsed.data.date ?? todayLocal();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub },
{ upsert: true, new: true }
);
return NextResponse.json({ inventory: row });
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const countSchema = z.object({
productId: z.string().min(1),
eveningPhysicalCount: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
branchId: z.string().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 json = await req.json().catch(() => null);
const parsed = countSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
const date = parsed.data.date ?? todayLocal();
const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub },
{ upsert: true, new: true }
);
return NextResponse.json({ inventory: row });
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs";
function getSourceIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return "unknown";
}
function isIpAllowed(ip: string): boolean {
const allowlist = process.env.DARAJA_CALLBACK_IP_ALLOWLIST;
if (!allowlist) return true;
return allowlist.split(",").map((s) => s.trim()).includes(ip);
}
export async function POST(req: NextRequest) {
await connectDb();
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
const sourceIp = getSourceIp(req);
let payload: any;
try {
payload = await req.json();
} catch {
await MpesaCallbackLog.create({
sourceIp,
rawPayload: { unparsable: true },
processingNote: "Malformed JSON body",
});
return ack();
}
const callback = payload?.Body?.stkCallback;
const checkoutRequestId: string | undefined = callback?.CheckoutRequestID;
const resultCode: number | undefined = callback?.ResultCode;
const resultDesc: string = callback?.ResultDesc ?? "";
if (!isIpAllowed(sourceIp)) {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId: checkoutRequestId ?? null,
resultCode: resultCode ?? null,
rawPayload: payload,
processingNote: "Rejected: source IP not in DARAJA_CALLBACK_IP_ALLOWLIST",
});
return ack();
}
if (!checkoutRequestId) {
await MpesaCallbackLog.create({
sourceIp,
rawPayload: payload ?? {},
processingNote: "No CheckoutRequestID in payload",
});
return ack();
}
let mpesaReceiptNumber: string | null = null;
if (resultCode === 0 && callback.CallbackMetadata?.Item) {
const items: { Name: string; Value: string | number }[] = callback.CallbackMetadata.Item;
const receiptItem = items.find((i: any) => i.Name === "MpesaReceiptNumber");
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
}
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
if (!txn) {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
rawPayload: payload,
processingNote: "No matching transaction for CheckoutRequestID",
});
return ack();
}
if (txn.paymentStatus !== "PENDING") {
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
matchedTransactionId: txn._id,
rawPayload: payload,
processingNote: "Ignored: transaction already " + txn.paymentStatus,
});
return ack();
}
const newStatus = resultCode === 0 ? "SUCCESS" : "FAILED";
txn.paymentStatus = newStatus;
txn.mpesaDetails.resultCode = resultCode ?? null;
txn.mpesaDetails.resultDesc = resultDesc;
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
await txn.save();
await MpesaCallbackLog.create({
sourceIp,
checkoutRequestId,
resultCode: resultCode ?? null,
matchedTransactionId: txn._id,
rawPayload: payload,
processingNote: "Applied: transaction set to " + newStatus,
});
return ack();
}
export async function GET() {
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth } from "@/lib/server/auth";
import { queryStkStatus } from "@/lib/server/mpesa";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
await connectDb();
const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const txn = await Transaction.findById(params.transactionId);
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
const ageMs = Date.now() - txn.createdAt.getTime();
if (txn.paymentStatus === "PENDING" && txn.mpesaDetails.checkoutRequestId && ageMs > 15000) {
try {
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
const code = Number(result.ResultCode);
if (!Number.isNaN(code) && code !== 1032) {
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
if (fresh) {
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
fresh.mpesaDetails.resultCode = code;
fresh.mpesaDetails.resultDesc = result.ResultDesc;
await fresh.save();
return NextResponse.json(fresh);
}
}
} catch {
// Daraja query failed/still pending — fall through and return current row.
}
}
return NextResponse.json(txn);
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { initiateStkPush } from "@/lib/server/mpesa";
export const runtime = "nodejs";
const stkSchema = z.object({
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().min(9).max(15),
productId: z.string().min(1),
quantity: z.number().int().min(1).max(500),
});
const hits = new Map<string, { count: number; resetAt: number }>();
function rateLimited(key: string): boolean {
const now = Date.now();
const entry = hits.get(key);
if (!entry || now > entry.resetAt) {
hits.set(key, { count: 1, resetAt: now + 60_000 });
return false;
}
if (entry.count >= 5) return true;
entry.count += 1;
return false;
}
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
if (rateLimited(auth!.sub)) {
return NextResponse.json(
{ error: "Too many payment prompts sent recently. Wait a moment and try again." },
{ status: 429 }
);
}
const json = await req.json().catch(() => null);
const parsed = stkSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!);
if (!branchId) {
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
}
const { customerName, customerPhone, productId, quantity } = parsed.data;
let phone: string;
try {
phone = normalizeKenyanPhone(customerPhone);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
const product = await Product.findById(productId).lean();
if (!product || !product.isActive) {
return NextResponse.json({ error: "Selected product is not available" }, { status: 400 });
}
const override = await BranchPricing.findOne({ branchId, productId }).lean();
const unitPrice = override?.unitPrice ?? product.unitPrice;
const amount = Math.round(quantity * unitPrice * 100) / 100;
if (amount <= 0) {
return NextResponse.json({ error: "Invalid sale amount" }, { status: 400 });
}
const branch = await Branch.findById(branchId).lean();
const txn = await Transaction.create({
branchId,
staffId: auth!.sub,
customerName: customerName ?? null,
customerPhone: phone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity,
unitPrice,
amountTotal: amount,
paymentMethod: "MPESA",
paymentStatus: "PENDING",
clientUuid: `mpesa-${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
try {
const stk = await initiateStkPush({
phone,
amount,
accountReference: branch?.branchName ?? "Vessel",
transactionDesc: `${quantity}x${product.sizeLiters}L`,
});
if (stk.ResponseCode !== "0") {
txn.paymentStatus = "FAILED";
txn.mpesaDetails.resultDesc = stk.ResponseDescription;
await txn.save();
return NextResponse.json({ error: stk.ResponseDescription || "Payment prompt was rejected" }, { status: 502 });
}
txn.mpesaDetails.checkoutRequestId = stk.CheckoutRequestID;
txn.mpesaDetails.merchantRequestId = stk.MerchantRequestID;
await txn.save();
return NextResponse.json({
transactionId: txn._id,
checkoutRequestId: stk.CheckoutRequestID,
customerMessage: stk.CustomerMessage,
});
} catch (err: any) {
txn.paymentStatus = "FAILED";
txn.mpesaDetails.resultDesc = err?.response?.data?.errorMessage ?? err.message;
await txn.save();
return NextResponse.json({ error: "Could not reach M-Pesa. Please retry." }, { status: 502 });
}
}
+36
View File
@@ -0,0 +1,36 @@
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";
export const runtime = "nodejs";
const updateSchema = z.object({
label: z.string().trim().min(1).max(60).optional(),
sizeLiters: z.number().positive().max(1000).optional(),
unitPrice: z.number().nonnegative().optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().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 });
}
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 });
}
if (Object.keys(parsed.data).length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
}
const product = await Product.findByIdAndUpdate(params.id, parsed.data, { new: true });
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
return NextResponse.json({ product });
}
+42
View File
@@ -0,0 +1,42 @@
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";
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 products = await Product.find({ isActive: true }).sort({ sortOrder: 1 }).lean();
return NextResponse.json({ products });
}
const createSchema = z.object({
label: z.string().trim().min(1).max(60),
sizeLiters: z.number().positive().max(1000),
unit: z.string().trim().min(1).max(12).optional(),
category: z.string().trim().min(1).max(40).optional(),
unitPrice: z.number().nonnegative(),
sortOrder: z.number().int().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 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);
return NextResponse.json({ product }, { status: 201 });
}
+116
View File
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Purchase } from "@/lib/server/models/Purchase";
import { Product } from "@/lib/server/models/Product";
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
export const runtime = "nodejs";
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 url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
const filter: Record<string, unknown> = {};
if (scopeBranch) filter.branchId = scopeBranch;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (from || to) {
const createdAt: Record<string, Date> = {};
if (from) createdAt.$gte = new Date(from);
if (to) createdAt.$lte = new Date(to);
filter.createdAt = createdAt;
}
const purchases = await Purchase.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.sort({ createdAt: -1 })
.limit(500)
.lean();
return NextResponse.json({ purchases });
}
const createSchema = z.object({
branchId: z.string().trim().min(1).optional(),
supplierName: z.string().trim().min(1).max(120),
productId: z.string().trim().min(1).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),
reference: z.string().trim().max(80).optional(),
notes: z.string().trim().max(500).optional(),
});
// POST /api/purchases — records a supplier purchase. The buying price is
// snapshotted (unitCost/totalCost) so historical records never shift when
// product prices change later. A completed purchase conceptually increases
// 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 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 }
);
}
// Resolve branch + enforce scope.
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = parsed.data.branchId ?? null;
if (!branchId) {
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
}
} else {
branchId = auth.branchId;
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
}
}
// Resolve a human-readable product label snapshot. Prefer an explicit
// productId lookup; fall back to a free-text label for ad-hoc items.
let productLabel = parsed.data.productLabel ?? null;
let productId: string | null = parsed.data.productId ?? null;
if (productId) {
const product = await Product.findById(productId).lean();
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 400 });
}
productLabel = (product as { label: string }).label;
}
if (!productLabel) {
return NextResponse.json({ error: "productId or productLabel is required" }, { status: 400 });
}
const totalCost = parsed.data.quantity * parsed.data.unitCost;
const purchase = await Purchase.create({
branchId,
supplierName: parsed.data.supplierName,
productId,
productLabel,
quantity: parsed.data.quantity,
unitCost: parsed.data.unitCost,
totalCost,
reference: parsed.data.reference ?? null,
notes: parsed.data.notes ?? null,
recordedBy: auth.sub,
});
return NextResponse.json({ purchase }, { status: 201 });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
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";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Management / profit overview — NOT full accounting.
// Revenue = sum of SUCCESS sales in range
// Cost of Goods= sum of purchase cost in range (stock bought in)
// Gross Profit = Revenue - Cost of Goods
// Expenses = sum of operating expenses in range
// 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 });
const url = new URL(req.url);
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId"));
// Default range: start of the current month -> 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 to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const dateMatch = { createdAt: { $gte: from, $lte: to } };
const salesMatch: Record<string, unknown> = { ...dateMatch, paymentStatus: "SUCCESS" };
if (branchObjId) salesMatch.branchId = branchObjId;
const costMatch: Record<string, unknown> = { ...dateMatch };
if (branchObjId) costMatch.branchId = branchObjId;
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
Transaction.aggregate([
{ $match: salesMatch },
{
$group: {
_id: null,
revenue: { $sum: "$amountTotal" },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
salesCount: { $sum: 1 },
},
},
]),
Purchase.aggregate([
{ $match: costMatch },
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
]),
Expense.aggregate([
{ $match: costMatch },
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
]),
]);
const revenue = salesAgg[0]?.revenue ?? 0;
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
const expenses = expenseAgg[0]?.expenses ?? 0;
const grossProfit = revenue - costOfGoods;
const netProfit = grossProfit - expenses;
return NextResponse.json({
scope: branchId ? "branch" : "global",
range: { from: from.toISOString(), to: to.toISOString() },
revenue,
cashTotal: salesAgg[0]?.cashTotal ?? 0,
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
salesCount: salesAgg[0]?.salesCount ?? 0,
costOfGoods,
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
grossProfit,
expenses,
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
netProfit,
});
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
export const runtime = "nodejs";
const saleSchema = z.object({
clientUuid: z.string().uuid(),
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().min(9).max(15),
productId: z.string().min(1),
quantity: z.number().int().min(1).max(500),
clientSubmittedAt: z.string().datetime().optional(),
});
const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) });
export async function POST(req: NextRequest) {
await connectDb();
const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const json = await req.json().catch(() => null);
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
const parsed = bodySchema.safeParse(normalized);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branchId = resolveBranchScope(auth!);
if (!branchId) {
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
}
const { sales } = parsed.data;
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
const products = await Product.find({ _id: { $in: productIds } }).lean();
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds } }).lean();
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
const rejected: { clientUuid: string; error: string }[] = [];
const inserted: any[] = [];
for (const sale of sales) {
let phone: string;
try {
phone = normalizeKenyanPhone(sale.customerPhone);
} catch (e: any) {
rejected.push({ clientUuid: sale.clientUuid, error: e.message });
continue;
}
const product = productMap.get(sale.productId);
if (!product || !product.isActive) {
rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" });
continue;
}
const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice;
try {
const created = await Transaction.create({
branchId,
staffId: auth!.sub,
customerName: sale.customerName ?? null,
customerPhone: phone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity: sale.quantity,
unitPrice,
amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100,
paymentMethod: "CASH",
paymentStatus: "SUCCESS",
clientUuid: sale.clientUuid,
syncedFromOffline: !!sale.clientSubmittedAt,
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
});
inserted.push(created);
} catch (err: any) {
if (err?.code === 11000) continue;
rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" });
}
}
return NextResponse.json({ inserted, rejected });
}
+31
View File
@@ -0,0 +1,31 @@
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";
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 branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
const filter: Record<string, unknown> = {};
if (branchId) filter.branchId = branchId;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
filter.createdAt = { $gte: startOfDay };
const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.lean();
return NextResponse.json({ transactions });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
export const runtime = "nodejs";
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 branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
const filter: Record<string, unknown> = branchId ? { branchId } : {};
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
return NextResponse.json({ users });
}
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(),
phoneNumber: z.string().min(9).max(15),
email: z.string().email(),
password: z.string().min(8),
});
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 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 data = parsed.data;
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) {
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;
if (data.role !== "SUPER_ADMIN" && !branchId) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
}
let phone: string;
try {
phone = normalizeKenyanPhone(data.phoneNumber);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
const passwordHash = await bcrypt.hash(data.password, 12);
try {
const user = await User.create({
name: data.name,
role: data.role,
branchId: branchId ?? null,
phoneNumber: phone,
email: data.email.toLowerCase(),
passwordHash,
});
if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findByIdAndUpdate(branchId, { managerId: user._id });
}
const obj: any = user.toObject();
delete obj.passwordHash;
return NextResponse.json({ user: obj }, { status: 201 });
} catch (err: any) {
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
}
throw err;
}
}