Add multi-tenant SaaS: business signup, owner/branch-manager/staff hierarchy, full tenant isolation
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
_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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user