Files
ERP/src/app/api/branches/route.ts
T
denis_ireri 83ec5ecd36 Enforce tenant isolation in the data layer instead of per route
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().

- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
  find/update/delete/count/distinct, or an aggregate whose first stage is
  not a $match, that does not pin exactly one businessId throws
  TenantScopeError before reaching the database ($in, $exists and other
  operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
  queries that must span businesses - login's email lookup, refresh and
  invite-token lookups, the M-Pesa callback, the platform console and the
  maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
  restricted to the business.
- Every flagged query site now names the business (or is exempted with a
  reason).
- Tests: a check that every model with a businessId field carries the
  guard; the guard refusing unscoped queries of every kind; and a route
  sweep where business A sends business B's ids to every read route
  (nothing of B's comes back) and every write route (all refused, B's
  data unchanged). Test-side database inspections go through db(), an
  explicit test exemption. Removing one route's business filter makes the
  suite fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:52:35 +03:00

118 lines
4.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { authorize, getAuth } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
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") {
// Deactivated branches are only listed on request (the Branches page),
// so every picker elsewhere offers active branches only.
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, ...ACTIVE_BRANCH };
const branches = await Branch.find(filter).sort({ branchName: 1 }).lean();
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
const branch = await Branch.findOne({ _id: auth.branchId, businessId: auth.businessId }).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),
manager: z
.object({
name: z.string().trim().min(1).max(120),
email: z.string().email(),
phoneNumber: z.string().min(9).max(15),
// Omit to invite: the response carries a one-time link for the manager.
password: z.string().min(8, "Password must be at least 8 characters").optional(),
})
.optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
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 { branchName, locationCity, manager } = parsed.data;
const businessId = auth.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId);
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
let managerPhone: string | null = null;
if (manager) {
const userErr = await userLimitError(businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
try {
managerPhone = normalizeKenyanPhone(manager.phoneNumber);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
// Unique within this business only (see the User model).
const lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.exists({ businessId, email: lowerEmail });
if (emailTaken) {
return NextResponse.json({ error: "Someone in your business already uses this email." }, { status: 409 });
}
const phoneTaken = await User.exists({ businessId, phoneNumber: managerPhone });
if (phoneTaken) {
return NextResponse.json({ error: "Someone in your business already uses this phone number." }, { status: 409 });
}
}
const branch = await Branch.create({ branchName, locationCity, businessId });
if (manager && managerPhone) {
try {
const passwordHash = manager.password ? await bcrypt.hash(manager.password, 12) : await unusablePasswordHash();
const managerUser = new User({
businessId,
name: manager.name,
role: "BRANCH_MANAGER",
branchId: branch._id,
phoneNumber: managerPhone,
email: manager.email.toLowerCase(),
passwordHash,
});
const inviteUrl = manager.password ? null : issueInvite(managerUser, req);
await managerUser.save();
branch.managerId = managerUser._id;
await branch.save();
return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email }, inviteUrl },
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
}
}
return NextResponse.json({ branch }, { status: 201 });
}