feat: subscription tiers, branch-with-manager flow, platform command center

This commit is contained in:
EOH Developer
2026-10-05 10:20:04 -07:00
parent 04e3051eb6
commit fa375bde91
19 changed files with 958 additions and 75 deletions
+69 -4
View File
@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
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 { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,8 +21,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
// Branch-scoped users still get filtered by businessId as a backstop,
// so a stale/forged branchId from another tenant can never resolve.
const branch = await Branch.findOne({ _id: auth.branchId, businessId: auth.businessId }).lean();
return NextResponse.json({ branches: branch ? [branch] : [] });
}
@@ -26,6 +28,14 @@ export async function GET(req: NextRequest) {
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),
password: z.string().min(8, "Password must be at least 8 characters"),
})
.optional(),
});
export async function POST(req: NextRequest) {
@@ -40,6 +50,61 @@ export async function POST(req: NextRequest) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.create({ ...parsed.data, businessId: auth!.businessId });
const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier);
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 });
}
const lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
}
const branch = await Branch.create({ branchName, locationCity, businessId });
if (manager && managerPhone) {
try {
const passwordHash = await bcrypt.hash(manager.password, 12);
const managerUser = await User.create({
businessId,
name: manager.name,
role: "BRANCH_MANAGER",
branchId: branch._id,
phoneNumber: managerPhone,
email: manager.email.toLowerCase(),
passwordHash,
});
branch.managerId = managerUser._id;
await branch.save();
return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
}
}
return NextResponse.json({ branch }, { status: 201 });
}
}