Add multi-tenant SaaS: business signup, owner/branch-manager/staff hierarchy, full tenant isolation
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
# set them all in Vercel's Project Settings -> Environment Variables.
|
# set them all in Vercel's Project Settings -> Environment Variables.
|
||||||
|
|
||||||
# ---- MongoDB ----
|
# ---- MongoDB ----
|
||||||
MONGODB_URI=mongodb+srv://stiles:Stilesmurimi2047@cluster0.opm7xuy.mongodb.net/aquaflow?appName=Cluster0
|
MONGODB_URI=mongodb+srv://user:password@cluster0.mongodb.net/aquaflow?retryWrites=true&w=majority
|
||||||
|
|
||||||
# ---- Auth / JWT ----
|
# ---- Auth / JWT ----
|
||||||
JWT_ACCESS_SECRET=replace_with_a_long_random_string
|
JWT_ACCESS_SECRET=replace_with_a_long_random_string
|
||||||
|
|||||||
+36
-4
@@ -1,6 +1,38 @@
|
|||||||
node_modules/
|
# dependencies
|
||||||
.next/
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
.env
|
.env
|
||||||
|
.env*.local
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.development.local
|
||||||
*.log
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"js/ts.tsdk.path": "node_modules\\typescript\\lib"
|
||||||
|
}
|
||||||
+49
-24
@@ -12,6 +12,7 @@ import mongoose from "mongoose";
|
|||||||
import { connectDb } from "../src/lib/server/db";
|
import { connectDb } from "../src/lib/server/db";
|
||||||
import { User } from "../src/lib/server/models/User";
|
import { User } from "../src/lib/server/models/User";
|
||||||
import { Product } from "../src/lib/server/models/Product";
|
import { Product } from "../src/lib/server/models/Product";
|
||||||
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run once against a fresh database: `npm run seed`.
|
* Run once against a fresh database: `npm run seed`.
|
||||||
@@ -26,41 +27,65 @@ async function seed() {
|
|||||||
const email = process.env.SEED_SUPER_ADMIN_EMAIL;
|
const email = process.env.SEED_SUPER_ADMIN_EMAIL;
|
||||||
const password = process.env.SEED_SUPER_ADMIN_PASSWORD;
|
const password = process.env.SEED_SUPER_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
// Everything the demo seed creates belongs to one demo Business tenant.
|
||||||
|
// In the live product, businesses are created via owner self-signup at
|
||||||
|
// /signup; this seed just mirrors that shape for a fresh local DB.
|
||||||
|
const demoBusinessName = process.env.SEED_BUSINESS_NAME || "Demo Water & Eggs Co.";
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
console.error("SEED_SUPER_ADMIN_EMAIL / SEED_SUPER_ADMIN_PASSWORD not set — nothing to seed for auth.");
|
console.error("SEED_SUPER_ADMIN_EMAIL / SEED_SUPER_ADMIN_PASSWORD not set — nothing to seed for auth.");
|
||||||
} else {
|
await mongoose.disconnect();
|
||||||
const existing = await User.findOne({ email });
|
process.exit(0);
|
||||||
if (existing) {
|
|
||||||
console.log(`Super admin (${email}) already exists, skipping`);
|
|
||||||
} else {
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
|
||||||
await User.create({
|
|
||||||
name: "Super Admin",
|
|
||||||
role: "SUPER_ADMIN",
|
|
||||||
branchId: null,
|
|
||||||
phoneNumber: "254700000000",
|
|
||||||
email,
|
|
||||||
passwordHash,
|
|
||||||
});
|
|
||||||
console.log(`Super admin created (${email}) — change the password after first login`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingProducts = await Product.countDocuments();
|
let business = await Business.findOne({ name: demoBusinessName });
|
||||||
|
const existing = await User.findOne({ email });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(`Super admin (${email}) already exists, skipping`);
|
||||||
|
if (!business) {
|
||||||
|
// Backfill a business for a legacy single-tenant admin if needed.
|
||||||
|
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
||||||
|
existing.businessId = business._id;
|
||||||
|
await existing.save();
|
||||||
|
console.log(`Linked existing super admin to new business "${demoBusinessName}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
// Create the owner first with a placeholder business, then the
|
||||||
|
// business, then point them at each other — mirrors signup ordering.
|
||||||
|
const owner = new User({
|
||||||
|
name: "Super Admin",
|
||||||
|
role: "SUPER_ADMIN",
|
||||||
|
branchId: null,
|
||||||
|
phoneNumber: "254700000000",
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
||||||
|
});
|
||||||
|
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
||||||
|
owner.businessId = business._id;
|
||||||
|
await owner.save();
|
||||||
|
console.log(`Business "${demoBusinessName}" + super admin created (${email}) — change the password after first login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = business!._id;
|
||||||
|
|
||||||
|
const existingProducts = await Product.countDocuments({ businessId });
|
||||||
if (existingProducts === 0) {
|
if (existingProducts === 0) {
|
||||||
await Product.insertMany([
|
await Product.insertMany([
|
||||||
// Water catalog
|
// Water catalog
|
||||||
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
||||||
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
{ businessId, label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
||||||
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
||||||
// Eggs catalog
|
// Eggs catalog
|
||||||
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
{ businessId, label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||||
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
{ businessId, label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
||||||
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
{ businessId, label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
||||||
]);
|
]);
|
||||||
console.log("Seeded default product catalog (water 5L/10L/20L + eggs tray/half/single)");
|
console.log("Seeded default product catalog (water 5L/10L/20L + eggs tray/half/single)");
|
||||||
} else {
|
} else {
|
||||||
console.log("Products already exist, skipping catalog seed");
|
console.log("Products already exist for this business, skipping catalog seed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
|
|||||||
@@ -115,9 +115,15 @@ export default function AdminDashboardPage() {
|
|||||||
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
|
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
<p className="text-xs uppercase tracking-wide text-flow-400">
|
||||||
|
{me.businessName ?? "Vessel"}
|
||||||
|
</p>
|
||||||
<h1 className="font-display text-xl font-bold">
|
<h1 className="font-display text-xl font-bold">
|
||||||
{me.role === "SUPER_ADMIN" ? "Global Control Center" : "Branch Dashboard"}
|
{me.role === "SUPER_ADMIN"
|
||||||
|
? "Global Control Center"
|
||||||
|
: me.branchName
|
||||||
|
? `${me.branchName} Dashboard`
|
||||||
|
: "Branch Dashboard"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { UserSession } from "@/lib/server/models/UserSession";
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
import { getEnv } from "@/lib/server/env";
|
import { getEnv } from "@/lib/server/env";
|
||||||
@@ -93,18 +94,22 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const accessToken = signAccessToken({
|
const accessToken = signAccessToken({
|
||||||
sub: user._id.toString(),
|
sub: user._id.toString(),
|
||||||
|
businessId: user.businessId.toString(),
|
||||||
role: user.role,
|
role: user.role,
|
||||||
branchId: user.branchId ? user.branchId.toString() : null,
|
branchId: user.branchId ? user.branchId.toString() : null,
|
||||||
deviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
||||||
|
const business = await Business.findById(user.businessId).lean();
|
||||||
|
|
||||||
const res = NextResponse.json({
|
const res = NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
businessId: user.businessId,
|
||||||
|
businessName: business?.name ?? null,
|
||||||
branchId: user.branchId,
|
branchId: user.branchId,
|
||||||
branchName: branch?.branchName ?? null,
|
branchName: branch?.branchName ?? null,
|
||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { getAuth } from "@/lib/server/auth";
|
import { getAuth } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -17,11 +18,14 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
||||||
|
const business = await Business.findById(user.businessId).lean();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
businessId: user.businessId,
|
||||||
|
businessName: business?.name ?? null,
|
||||||
branchId: user.branchId,
|
branchId: user.branchId,
|
||||||
branchName: branch?.branchName ?? null,
|
branchName: branch?.branchName ?? null,
|
||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const accessToken = signAccessToken({
|
const accessToken = signAccessToken({
|
||||||
sub: user._id.toString(),
|
sub: user._id.toString(),
|
||||||
|
businessId: user.businessId.toString(),
|
||||||
role: user.role,
|
role: user.role,
|
||||||
branchId: user.branchId ? user.branchId.toString() : null,
|
branchId: user.branchId ? user.branchId.toString() : null,
|
||||||
deviceId,
|
deviceId,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
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";
|
||||||
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner self-signup — the ONLY way a new tenant (Business) enters the
|
||||||
|
* system. It atomically:
|
||||||
|
* 1. creates the Business,
|
||||||
|
* 2. creates the owner as a SUPER_ADMIN (branchId = null) bound to it,
|
||||||
|
* 3. seeds a default water + eggs product catalog scoped to the business,
|
||||||
|
* 4. logs the owner straight in (session + cookies), same as /login.
|
||||||
|
*
|
||||||
|
* Branches and branch managers are created LATER by the owner from the
|
||||||
|
* admin UI; delivery staff are then added by branch managers. Signup only
|
||||||
|
* bootstraps the tenant and its first user.
|
||||||
|
*/
|
||||||
|
const signupSchema = z.object({
|
||||||
|
businessName: z.string().min(2).max(120),
|
||||||
|
ownerName: z.string().min(2).max(120),
|
||||||
|
email: z.string().email(),
|
||||||
|
phoneNumber: z.string().min(1),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
deviceId: z.string().uuid(),
|
||||||
|
deviceLabel: z.string().max(120).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_CATALOG = [
|
||||||
|
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
||||||
|
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
||||||
|
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
||||||
|
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||||
|
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
||||||
|
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
|
||||||
|
const json = await req.json().catch(() => null);
|
||||||
|
const parsed = signupSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { businessName, ownerName, email, phoneNumber, password, deviceId, deviceLabel } = parsed.data;
|
||||||
|
|
||||||
|
let normalizedPhone: string;
|
||||||
|
try {
|
||||||
|
normalizedPhone = normalizeKenyanPhone(phoneNumber);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
// Email + phone are globally unique across all tenants (login is by
|
||||||
|
// email alone, with no tenant selector), so reject collisions early
|
||||||
|
// with a friendly message rather than a raw duplicate-key error.
|
||||||
|
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: normalizedPhone }).lean();
|
||||||
|
if (phoneTaken) {
|
||||||
|
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create owner first (with a temporary businessId), then the business,
|
||||||
|
// then point the owner at the real business id and persist. This avoids
|
||||||
|
// a chicken-and-egg between User.businessId (required) and
|
||||||
|
// Business.ownerId (required).
|
||||||
|
const ownerId = new mongoose.Types.ObjectId();
|
||||||
|
const businessId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const business = new Business({ _id: businessId, name: businessName.trim(), ownerId });
|
||||||
|
const owner = new User({
|
||||||
|
_id: ownerId,
|
||||||
|
businessId,
|
||||||
|
name: ownerName.trim(),
|
||||||
|
role: "SUPER_ADMIN",
|
||||||
|
branchId: null,
|
||||||
|
phoneNumber: normalizedPhone,
|
||||||
|
email: lowerEmail,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await business.save();
|
||||||
|
await owner.save();
|
||||||
|
} catch (e) {
|
||||||
|
// Roll back a partial create so a failed signup never leaves an
|
||||||
|
// orphaned business or user behind.
|
||||||
|
await Business.deleteOne({ _id: businessId }).catch(() => {});
|
||||||
|
await User.deleteOne({ _id: ownerId }).catch(() => {});
|
||||||
|
const msg = (e as Error).message || "";
|
||||||
|
if (msg.includes("duplicate key")) {
|
||||||
|
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Could not create your business. Please try again." }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the default catalog for the new tenant.
|
||||||
|
await Product.insertMany(DEFAULT_CATALOG.map((p) => ({ ...p, businessId })));
|
||||||
|
|
||||||
|
// Auto-login: create the session + issue cookies, exactly like /login.
|
||||||
|
const refreshToken = generateRefreshToken();
|
||||||
|
const refreshTokenHash = hashRefreshToken(refreshToken);
|
||||||
|
const expiresAt = new Date(Date.now() + refreshTtlMs());
|
||||||
|
await UserSession.findOneAndUpdate(
|
||||||
|
{ userId: owner._id, deviceId },
|
||||||
|
{ refreshTokenHash, expiresAt, deviceLabel: deviceLabel ?? null, lastUsedAt: new Date() },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = signAccessToken({
|
||||||
|
sub: owner._id.toString(),
|
||||||
|
businessId: businessId.toString(),
|
||||||
|
role: owner.role,
|
||||||
|
branchId: null,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: owner._id,
|
||||||
|
name: owner.name,
|
||||||
|
role: owner.role,
|
||||||
|
businessId,
|
||||||
|
businessName: business.name,
|
||||||
|
branchId: null,
|
||||||
|
branchName: null,
|
||||||
|
phoneNumber: owner.phoneNumber,
|
||||||
|
email: owner.email,
|
||||||
|
},
|
||||||
|
redirectTo: "/admin/dashboard",
|
||||||
|
});
|
||||||
|
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||||
|
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
if (auth.role === "SUPER_ADMIN") {
|
if (auth.role === "SUPER_ADMIN") {
|
||||||
const branches = await Branch.find().sort({ branchName: 1 }).lean();
|
const branches = await Branch.find({ businessId: auth.businessId }).sort({ branchName: 1 }).lean();
|
||||||
return NextResponse.json({ branches });
|
return NextResponse.json({ branches });
|
||||||
}
|
}
|
||||||
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
||||||
const branch = await Branch.findById(auth.branchId).lean();
|
// 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] : [] });
|
return NextResponse.json({ branches: branch ? [branch] : [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,6 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const branch = await Branch.create(parsed.data);
|
const branch = await Branch.create({ ...parsed.data, businessId: auth!.businessId });
|
||||||
return NextResponse.json({ branch }, { status: 201 });
|
return NextResponse.json({ branch }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
|
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const invMatch: Record<string, unknown> = { date };
|
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
|
||||||
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
||||||
|
|
||||||
const inventoryRows = await DailyInventory.aggregate([
|
const inventoryRows = await DailyInventory.aggregate([
|
||||||
@@ -44,6 +44,7 @@ export async function GET(req: NextRequest) {
|
|||||||
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
|
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
|
||||||
|
|
||||||
const soldMatch: Record<string, unknown> = {
|
const soldMatch: Record<string, unknown> = {
|
||||||
|
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
||||||
createdAt: { $gte: dayStart, $lt: dayEnd },
|
createdAt: { $gte: dayStart, $lt: dayEnd },
|
||||||
paymentStatus: "SUCCESS",
|
paymentStatus: "SUCCESS",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export async function GET(req: NextRequest) {
|
|||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const { start, end } = todayRange();
|
const { start, end } = todayRange();
|
||||||
|
|
||||||
const matchStage: Record<string, unknown> = { createdAt: { $gte: start, $lt: end } };
|
const matchStage: Record<string, unknown> = {
|
||||||
|
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
||||||
|
createdAt: { $gte: start, $lt: end },
|
||||||
|
};
|
||||||
if (branchId) matchStage.branchId = new mongoose.Types.ObjectId(branchId);
|
if (branchId) matchStage.branchId = new mongoose.Types.ObjectId(branchId);
|
||||||
|
|
||||||
const [totals] = await Transaction.aggregate([
|
const [totals] = await Transaction.aggregate([
|
||||||
@@ -66,7 +69,7 @@ export async function GET(req: NextRequest) {
|
|||||||
let branchRanking: any[] = [];
|
let branchRanking: any[] = [];
|
||||||
if (!branchId) {
|
if (!branchId) {
|
||||||
branchRanking = await Transaction.aggregate([
|
branchRanking = await Transaction.aggregate([
|
||||||
{ $match: { createdAt: { $gte: start, $lt: end }, paymentStatus: "SUCCESS" } },
|
{ $match: { businessId: new mongoose.Types.ObjectId(auth.businessId), createdAt: { $gte: start, $lt: end }, paymentStatus: "SUCCESS" } },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: "$branchId",
|
_id: "$branchId",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -18,7 +19,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
||||||
|
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (scopeBranch) filter.branchId = scopeBranch;
|
if (scopeBranch) filter.branchId = scopeBranch;
|
||||||
|
|
||||||
const from = url.searchParams.get("from");
|
const from = url.searchParams.get("from");
|
||||||
@@ -80,7 +81,14 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The target branch MUST belong to this tenant.
|
||||||
|
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 expense = await Expense.create({
|
const expense = await Expense.create({
|
||||||
|
businessId: auth.businessId,
|
||||||
branchId,
|
branchId,
|
||||||
category: parsed.data.category,
|
category: parsed.data.category,
|
||||||
amount: parsed.data.amount,
|
amount: parsed.data.amount,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -33,11 +34,16 @@ export async function POST(req: NextRequest) {
|
|||||||
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 });
|
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();
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
const date = parsed.data.date ?? todayLocal();
|
const date = parsed.data.date ?? todayLocal();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ branchId, productId: parsed.data.productId, date },
|
||||||
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub },
|
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId },
|
||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
return NextResponse.json({ inventory: row });
|
return NextResponse.json({ inventory: row });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -33,11 +34,15 @@ export async function POST(req: NextRequest) {
|
|||||||
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 });
|
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();
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
const date = parsed.data.date ?? todayLocal();
|
const date = parsed.data.date ?? todayLocal();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ branchId, productId: parsed.data.productId, date },
|
||||||
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub },
|
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId },
|
||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
return NextResponse.json({ inventory: row });
|
return NextResponse.json({ inventory: row });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
|
|||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
const txn = await Transaction.findById(params.transactionId);
|
const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId });
|
||||||
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
|
|
||||||
const ageMs = Date.now() - txn.createdAt.getTime();
|
const ageMs = Date.now() - txn.createdAt.getTime();
|
||||||
|
|||||||
@@ -64,20 +64,21 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await Product.findById(productId).lean();
|
const product = await Product.findOne({ _id: productId, businessId: auth!.businessId }).lean();
|
||||||
if (!product || !product.isActive) {
|
if (!product || !product.isActive) {
|
||||||
return NextResponse.json({ error: "Selected product is not available" }, { status: 400 });
|
return NextResponse.json({ error: "Selected product is not available" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const override = await BranchPricing.findOne({ branchId, productId }).lean();
|
const override = await BranchPricing.findOne({ branchId, productId, businessId: auth!.businessId }).lean();
|
||||||
const unitPrice = override?.unitPrice ?? product.unitPrice;
|
const unitPrice = override?.unitPrice ?? product.unitPrice;
|
||||||
const amount = Math.round(quantity * unitPrice * 100) / 100;
|
const amount = Math.round(quantity * unitPrice * 100) / 100;
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
return NextResponse.json({ error: "Invalid sale amount" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid sale amount" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = await Branch.findById(branchId).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
||||||
|
|
||||||
const txn = await Transaction.create({
|
const txn = await Transaction.create({
|
||||||
|
businessId: auth!.businessId,
|
||||||
branchId,
|
branchId,
|
||||||
staffId: auth!.sub,
|
staffId: auth!.sub,
|
||||||
customerName: customerName ?? null,
|
customerName: customerName ?? null,
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await Product.findByIdAndUpdate(params.id, parsed.data, { new: true });
|
const product = await Product.findOneAndUpdate(
|
||||||
|
{ _id: params.id, businessId: auth!.businessId },
|
||||||
|
parsed.data,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
|
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
|
||||||
return NextResponse.json({ product });
|
return NextResponse.json({ product });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
const products = await Product.find({ isActive: true }).sort({ sortOrder: 1 }).lean();
|
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
|
||||||
return NextResponse.json({ products });
|
return NextResponse.json({ products });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,6 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const product = await Product.create(parsed.data);
|
const product = await Product.create({ ...parsed.data, businessId: auth!.businessId });
|
||||||
return NextResponse.json({ product }, { status: 201 });
|
return NextResponse.json({ product }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -17,7 +18,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
||||||
|
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (scopeBranch) filter.branchId = scopeBranch;
|
if (scopeBranch) filter.branchId = scopeBranch;
|
||||||
|
|
||||||
const from = url.searchParams.get("from");
|
const from = url.searchParams.get("from");
|
||||||
@@ -82,12 +83,18 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The target branch MUST belong to this tenant.
|
||||||
|
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||||
|
if (!branchDoc) {
|
||||||
|
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a human-readable product label snapshot. Prefer an explicit
|
// Resolve a human-readable product label snapshot. Prefer an explicit
|
||||||
// productId lookup; fall back to a free-text label for ad-hoc items.
|
// productId lookup; fall back to a free-text label for ad-hoc items.
|
||||||
let productLabel = parsed.data.productLabel ?? null;
|
let productLabel = parsed.data.productLabel ?? null;
|
||||||
let productId: string | null = parsed.data.productId ?? null;
|
let productId: string | null = parsed.data.productId ?? null;
|
||||||
if (productId) {
|
if (productId) {
|
||||||
const product = await Product.findById(productId).lean();
|
const product = await Product.findOne({ _id: productId, businessId: auth.businessId }).lean();
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return NextResponse.json({ error: "Product not found" }, { status: 400 });
|
return NextResponse.json({ error: "Product not found" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -100,6 +107,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const totalCost = parsed.data.quantity * parsed.data.unitCost;
|
const totalCost = parsed.data.quantity * parsed.data.unitCost;
|
||||||
|
|
||||||
const purchase = await Purchase.create({
|
const purchase = await Purchase.create({
|
||||||
|
businessId: auth.businessId,
|
||||||
branchId,
|
branchId,
|
||||||
supplierName: parsed.data.supplierName,
|
supplierName: parsed.data.supplierName,
|
||||||
productId,
|
productId,
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ export async function GET(req: NextRequest) {
|
|||||||
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
||||||
|
|
||||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||||
|
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
||||||
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
||||||
|
|
||||||
const salesMatch: Record<string, unknown> = { ...dateMatch, paymentStatus: "SUCCESS" };
|
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" };
|
||||||
if (branchObjId) salesMatch.branchId = branchObjId;
|
if (branchObjId) salesMatch.branchId = branchObjId;
|
||||||
|
|
||||||
const costMatch: Record<string, unknown> = { ...dateMatch };
|
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId };
|
||||||
if (branchObjId) costMatch.branchId = branchObjId;
|
if (branchObjId) costMatch.branchId = branchObjId;
|
||||||
|
|
||||||
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const { sales } = parsed.data;
|
const { sales } = parsed.data;
|
||||||
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
|
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
|
||||||
const products = await Product.find({ _id: { $in: productIds } }).lean();
|
const products = await Product.find({ _id: { $in: productIds }, businessId: auth!.businessId }).lean();
|
||||||
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds } }).lean();
|
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds }, businessId: auth!.businessId }).lean();
|
||||||
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
||||||
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await Transaction.create({
|
const created = await Transaction.create({
|
||||||
|
businessId: auth!.businessId,
|
||||||
branchId,
|
branchId,
|
||||||
staffId: auth!.sub,
|
staffId: auth!.sub,
|
||||||
customerName: sale.customerName ?? null,
|
customerName: sale.customerName ?? null,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
||||||
|
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (branchId) filter.branchId = branchId;
|
if (branchId) filter.branchId = branchId;
|
||||||
|
|
||||||
const startOfDay = new Date();
|
const startOfDay = new Date();
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
|
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
|
||||||
const filter: Record<string, unknown> = branchId ? { branchId } : {};
|
// businessId is the hard tenant boundary; branchId narrows within it.
|
||||||
|
const filter: Record<string, unknown> = { businessId: auth!.businessId };
|
||||||
|
if (branchId) filter.branchId = branchId;
|
||||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({ users });
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,16 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
|
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any branch supplied (by a SUPER_ADMIN creating a manager/staff for a
|
||||||
|
// specific branch) MUST belong to this business — otherwise a crafted
|
||||||
|
// branchId from another tenant could attach a user cross-tenant.
|
||||||
|
if (branchId) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let phone: string;
|
let phone: string;
|
||||||
try {
|
try {
|
||||||
phone = normalizeKenyanPhone(data.phoneNumber);
|
phone = normalizeKenyanPhone(data.phoneNumber);
|
||||||
@@ -71,6 +83,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.create({
|
const user = await User.create({
|
||||||
|
businessId: auth!.businessId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
branchId: branchId ?? null,
|
branchId: branchId ?? null,
|
||||||
@@ -80,7 +93,10 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (data.role === "BRANCH_MANAGER" && branchId) {
|
if (data.role === "BRANCH_MANAGER" && branchId) {
|
||||||
await Branch.findByIdAndUpdate(branchId, { managerId: user._id });
|
await Branch.findOneAndUpdate(
|
||||||
|
{ _id: branchId, businessId: auth!.businessId },
|
||||||
|
{ managerId: user._id }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj: any = user.toObject();
|
const obj: any = user.toObject();
|
||||||
|
|||||||
+8
-6
@@ -2,14 +2,16 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ToastProvider } from "@/components/ui/Toast";
|
import { ToastProvider } from "@/components/ui/Toast";
|
||||||
|
|
||||||
// We deliberately do NOT use `next/font/google` here. That loader downloads
|
// NOTE: We deliberately do NOT use `next/font/google` here. That loader
|
||||||
// fonts at build time, which fails in restricted build environments (e.g.
|
// downloads the font files from Google at *build time*, which fails in
|
||||||
// Render) with "Cannot read properties of null (reading '1')". Instead we
|
// restricted CI/build environments (e.g. Render) with
|
||||||
|
// "TypeError: Cannot read properties of null (reading '1')". Instead we
|
||||||
// load the same fonts via a standard <link> at runtime. The matching CSS
|
// load the same fonts via a standard <link> at runtime. The matching CSS
|
||||||
// variables live in globals.css, so the typography is unchanged.
|
// variables (--font-display / --font-body / --font-mono) are declared in
|
||||||
|
// globals.css, so the typography is unchanged.
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Vessel — Branch Delivery Console",
|
title: "Vessel \u2014 Branch Delivery Console",
|
||||||
description: "Multi-branch 20L jerrycan delivery & payment operations",
|
description: "Multi-branch 20L jerrycan delivery & payment operations",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,4 +38,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,14 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-depth-400">
|
<p className="mt-6 text-center text-sm text-depth-500">
|
||||||
|
New here?{" "}
|
||||||
|
<a href="/signup" className="font-semibold text-flow-600 hover:text-flow-700">
|
||||||
|
Create a business account
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-3 text-center text-xs text-depth-400">
|
||||||
Water & eggs · multi-branch · one system.
|
Water & eggs · multi-branch · one system.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
||||||
|
import VesselLogo from "@/components/ui/VesselLogo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner self-signup — the entry point for a brand-new business (tenant).
|
||||||
|
* Submitting creates the Business + the owner (SUPER_ADMIN), seeds a
|
||||||
|
* default water/eggs catalog, and logs the owner straight into the admin
|
||||||
|
* dashboard. From there the owner creates branches and branch managers;
|
||||||
|
* branch managers in turn add delivery staff.
|
||||||
|
*/
|
||||||
|
export default function SignupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [ownerName, setOwnerName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ redirectTo: string }>("/api/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
businessName,
|
||||||
|
ownerName,
|
||||||
|
email,
|
||||||
|
phoneNumber,
|
||||||
|
password,
|
||||||
|
deviceId: getDeviceId(),
|
||||||
|
deviceLabel: getDeviceLabel(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
router.replace(result.redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 placeholder:text-depth-400 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-4 py-10">
|
||||||
|
<div className="w-full max-w-sm animate-fade-up">
|
||||||
|
{/* Brand lockup */}
|
||||||
|
<div className="mb-8 flex items-center justify-center gap-2.5">
|
||||||
|
<VesselLogo size={34} />
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight text-depth-900">
|
||||||
|
vessel<span className="text-flow-500">.</span>
|
||||||
|
<span className="ml-1 font-body text-sm font-normal text-depth-500">erp</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-depth-200 bg-white p-8 shadow-soft sm:p-10">
|
||||||
|
<h1 className="font-display text-3xl font-bold tracking-tight text-depth-900">
|
||||||
|
Create your business
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-depth-500">
|
||||||
|
Set up your company account. You'll be the owner and can add branches
|
||||||
|
and branch managers next.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||||
|
{/* Business name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="businessName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Business name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="businessName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
placeholder="e.g. Clearwater Supplies"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ownerName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Your name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ownerName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
value={ownerName}
|
||||||
|
onChange={(e) => setOwnerName(e.target.value)}
|
||||||
|
placeholder="Jane Owner"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phoneNumber" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Phone number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="phoneNumber"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
required
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
|
placeholder="07XXXXXXXX"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
className="tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 pr-11 text-depth-900 placeholder:text-depth-400 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
className="absolute inset-y-0 right-3.5 flex items-center text-depth-400 hover:text-depth-700"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.5 5.2A10.8 10.8 0 0 1 12 5c5 0 9 4 10 7-.4 1.1-1.1 2.3-2.1 3.4M6.6 6.6C4.6 8 3.2 9.9 2 12c1 3 5 7 10 7 1.3 0 2.5-.2 3.6-.7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary CTA — copper gradient pill */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="tap-target group relative w-full overflow-hidden rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white shadow-glow transition active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||||
|
{loading && (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||||
|
)}
|
||||||
|
{loading ? "Creating\u2026" : "Create business account"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-depth-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login" className="font-semibold text-flow-600 hover:text-flow-700">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { UserRole } from "@/lib/server/models/User";
|
|||||||
|
|
||||||
export interface AccessTokenPayload {
|
export interface AccessTokenPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
|
businessId: string; // tenant the user belongs to — scopes every query
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
|
|||||||
|
|
||||||
export interface IBranch extends Document {
|
export interface IBranch extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this branch belongs to
|
||||||
branchName: string;
|
branchName: string;
|
||||||
locationCity: string;
|
locationCity: string;
|
||||||
managerId: Types.ObjectId | null;
|
managerId: Types.ObjectId | null;
|
||||||
@@ -11,6 +12,7 @@ export interface IBranch extends Document {
|
|||||||
|
|
||||||
const branchSchema = new Schema<IBranch>(
|
const branchSchema = new Schema<IBranch>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchName: { type: String, required: true, trim: true },
|
branchName: { type: String, required: true, trim: true },
|
||||||
locationCity: { type: String, required: true, trim: true },
|
locationCity: { type: String, required: true, trim: true },
|
||||||
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Business is the top-level tenant in the system. Every other record
|
||||||
|
* (users, branches, products, sales, inventory, expenses, purchases)
|
||||||
|
* belongs to exactly one Business via a required `businessId`, and every
|
||||||
|
* query is scoped by it. This is what lets many independent companies use
|
||||||
|
* the same deployment without ever seeing each other's data.
|
||||||
|
*
|
||||||
|
* The owner is the first user created at sign-up: a SUPER_ADMIN with no
|
||||||
|
* branch assignment (branchId = null) who can see and manage ALL branches
|
||||||
|
* within their own business.
|
||||||
|
*/
|
||||||
|
export interface IBusiness extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
ownerId: Types.ObjectId; // the SUPER_ADMIN who created the business
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessSchema = new Schema<IBusiness>(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, trim: true },
|
||||||
|
ownerId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
|
isActive: { type: Boolean, default: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Business =
|
||||||
|
(models.Business as Model<IBusiness>) || model<IBusiness>("Business", businessSchema);
|
||||||
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
|
|||||||
|
|
||||||
export interface IDailyInventory extends Document {
|
export interface IDailyInventory extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this inventory row belongs to
|
||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
productId: Types.ObjectId;
|
productId: Types.ObjectId;
|
||||||
date: string; // YYYY-MM-DD, branch-local calendar day — see note below
|
date: string; // YYYY-MM-DD, branch-local calendar day — see note below
|
||||||
@@ -21,6 +22,7 @@ export interface IDailyInventory extends Document {
|
|||||||
*/
|
*/
|
||||||
const dailyInventorySchema = new Schema<IDailyInventory>(
|
const dailyInventorySchema = new Schema<IDailyInventory>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
date: { type: String, required: true },
|
date: { type: String, required: true },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
|
|||||||
|
|
||||||
export interface IExpense extends Document {
|
export interface IExpense extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this expense belongs to
|
||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
category: ExpenseCategory;
|
category: ExpenseCategory;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -28,6 +29,7 @@ export interface IExpense extends Document {
|
|||||||
|
|
||||||
const expenseSchema = new Schema<IExpense>(
|
const expenseSchema = new Schema<IExpense>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
category: { type: String, enum: EXPENSE_CATEGORIES, required: true },
|
category: { type: String, enum: EXPENSE_CATEGORIES, required: true },
|
||||||
amount: { type: Number, required: true, min: 0 },
|
amount: { type: Number, required: true, min: 0 },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
|
|||||||
|
|
||||||
export interface IProduct extends Document {
|
export interface IProduct extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this product belongs to
|
||||||
label: string;
|
label: string;
|
||||||
sizeLiters: number;
|
sizeLiters: number;
|
||||||
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
|
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
|
||||||
@@ -15,6 +16,7 @@ export interface IProduct extends Document {
|
|||||||
|
|
||||||
const productSchema = new Schema<IProduct>(
|
const productSchema = new Schema<IProduct>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
label: { type: String, required: true, trim: true },
|
label: { type: String, required: true, trim: true },
|
||||||
sizeLiters: { type: Number, required: true, min: 0.1 },
|
sizeLiters: { type: Number, required: true, min: 0.1 },
|
||||||
unit: { type: String, default: "L", trim: true },
|
unit: { type: String, default: "L", trim: true },
|
||||||
@@ -31,6 +33,7 @@ export const Product =
|
|||||||
|
|
||||||
// Branch-specific price override — optional, falls back to Product.unitPrice.
|
// Branch-specific price override — optional, falls back to Product.unitPrice.
|
||||||
export interface IBranchPricing extends Document {
|
export interface IBranchPricing extends Document {
|
||||||
|
businessId: Types.ObjectId;
|
||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
productId: Types.ObjectId;
|
productId: Types.ObjectId;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
@@ -39,6 +42,7 @@ export interface IBranchPricing extends Document {
|
|||||||
|
|
||||||
const branchPricingSchema = new Schema<IBranchPricing>(
|
const branchPricingSchema = new Schema<IBranchPricing>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
unitPrice: { type: Number, required: true, min: 0 },
|
unitPrice: { type: Number, required: true, min: 0 },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
|
|||||||
// historical purchases never change when a product's price changes later.
|
// historical purchases never change when a product's price changes later.
|
||||||
export interface IPurchase extends Document {
|
export interface IPurchase extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this purchase belongs to
|
||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
supplierName: string;
|
supplierName: string;
|
||||||
productId: Types.ObjectId | null;
|
productId: Types.ObjectId | null;
|
||||||
@@ -21,6 +22,7 @@ export interface IPurchase extends Document {
|
|||||||
|
|
||||||
const purchaseSchema = new Schema<IPurchase>(
|
const purchaseSchema = new Schema<IPurchase>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
supplierName: { type: String, required: true, trim: true },
|
supplierName: { type: String, required: true, trim: true },
|
||||||
productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
|
productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
|
|||||||
|
|
||||||
export interface ITransaction extends Document {
|
export interface ITransaction extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this transaction belongs to
|
||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
staffId: Types.ObjectId;
|
staffId: Types.ObjectId;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
@@ -45,6 +46,7 @@ export interface ITransaction extends Document {
|
|||||||
|
|
||||||
const transactionSchema = new Schema<ITransaction>(
|
const transactionSchema = new Schema<ITransaction>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
customerName: { type: String, default: null, trim: true },
|
customerName: { type: String, default: null, trim: true },
|
||||||
@@ -88,6 +90,7 @@ transactionSchema.index({ clientUuid: 1 }, { unique: true });
|
|||||||
// today" — a compound index serves that far better than the two single
|
// today" — a compound index serves that far better than the two single
|
||||||
// indexes above combined.
|
// indexes above combined.
|
||||||
transactionSchema.index({ branchId: 1, createdAt: -1 });
|
transactionSchema.index({ branchId: 1, createdAt: -1 });
|
||||||
|
transactionSchema.index({ businessId: 1, createdAt: -1 });
|
||||||
transactionSchema.index({ paymentStatus: 1 });
|
transactionSchema.index({ paymentStatus: 1 });
|
||||||
|
|
||||||
export const Transaction =
|
export const Transaction =
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ export const USER_ROLES: UserRole[] = ["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMI
|
|||||||
|
|
||||||
export interface IUser extends Document {
|
export interface IUser extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId; // tenant this user belongs to
|
||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN
|
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner)
|
||||||
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
|
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
|
||||||
email: string; // login identifier for every role, via the single /login page
|
email: string; // login identifier for every role, via the single /login page
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
@@ -23,6 +24,7 @@ export interface IUser extends Document {
|
|||||||
|
|
||||||
const userSchema = new Schema<IUser>(
|
const userSchema = new Schema<IUser>(
|
||||||
{
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
|
||||||
name: { type: String, required: true, trim: true },
|
name: { type: String, required: true, trim: true },
|
||||||
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
|
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface AuthUser {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
businessId?: string;
|
||||||
|
businessName?: string | null;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
branchName?: string | null;
|
branchName?: string | null;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user