83ec5ecd36
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>
118 lines
4.9 KiB
TypeScript
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 });
|
|
} |