Add multi-tenant SaaS: business signup, owner/branch-manager/staff hierarchy, full tenant isolation

This commit is contained in:
EOH Developer
2026-10-04 01:22:42 -07:00
parent 3b22ddf6fb
commit 04e3051eb6
38 changed files with 640 additions and 67 deletions
+2
View File
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IBranch extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this branch belongs to
branchName: string;
locationCity: string;
managerId: Types.ObjectId | null;
@@ -11,6 +12,7 @@ export interface IBranch extends Document {
const branchSchema = new Schema<IBranch>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
branchName: { type: String, required: true, trim: true },
locationCity: { type: String, required: true, trim: true },
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
+33
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IDailyInventory extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this inventory row belongs to
branchId: Types.ObjectId;
productId: Types.ObjectId;
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>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
date: { type: String, required: true },
+2
View File
@@ -15,6 +15,7 @@ export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
export interface IExpense extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this expense belongs to
branchId: Types.ObjectId;
category: ExpenseCategory;
amount: number;
@@ -28,6 +29,7 @@ export interface IExpense extends Document {
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 },
category: { type: String, enum: EXPENSE_CATEGORIES, required: true },
amount: { type: Number, required: true, min: 0 },
+4
View File
@@ -2,6 +2,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IProduct extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this product belongs to
label: string;
sizeLiters: number;
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>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
label: { type: String, required: true, trim: true },
sizeLiters: { type: Number, required: true, min: 0.1 },
unit: { type: String, default: "L", trim: true },
@@ -31,6 +33,7 @@ export const Product =
// Branch-specific price override — optional, falls back to Product.unitPrice.
export interface IBranchPricing extends Document {
businessId: Types.ObjectId;
branchId: Types.ObjectId;
productId: Types.ObjectId;
unitPrice: number;
@@ -39,6 +42,7 @@ export interface IBranchPricing extends Document {
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 },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
unitPrice: { type: Number, required: true, min: 0 },
+2
View File
@@ -5,6 +5,7 @@ import { Schema, model, models, Model, Types, Document } from "mongoose";
// historical purchases never change when a product's price changes later.
export interface IPurchase extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this purchase belongs to
branchId: Types.ObjectId;
supplierName: string;
productId: Types.ObjectId | null;
@@ -21,6 +22,7 @@ export interface IPurchase extends Document {
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 },
supplierName: { type: String, required: true, trim: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
+3
View File
@@ -5,6 +5,7 @@ export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface ITransaction extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this transaction belongs to
branchId: Types.ObjectId;
staffId: Types.ObjectId;
customerName: string | null;
@@ -45,6 +46,7 @@ export interface ITransaction extends Document {
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 },
staffId: { type: Schema.Types.ObjectId, ref: "User", required: 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
// indexes above combined.
transactionSchema.index({ branchId: 1, createdAt: -1 });
transactionSchema.index({ businessId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 });
export const Transaction =
+3 -1
View File
@@ -6,9 +6,10 @@ export const USER_ROLES: UserRole[] = ["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMI
export interface IUser extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this user belongs to
name: string;
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
email: string; // login identifier for every role, via the single /login page
passwordHash: string;
@@ -23,6 +24,7 @@ export interface IUser extends Document {
const userSchema = new Schema<IUser>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true, index: true },
name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },