import { tenantGuard } from "@/lib/server/tenantGuard"; import { Schema, model, models, Model, Types, Document } from "mongoose"; import bcrypt from "bcryptjs"; export type UserRole = "DELIVERY" | "BRANCH_MANAGER" | "SUPER_ADMIN"; export const USER_ROLES: UserRole[] = ["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]; 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 (business owner) // Email and phone are unique WITHIN a business, not across the platform, // so one person can work for two businesses (each account has its own // password and role). Login asks which business when the same email and // password match accounts in more than one. 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 // Platform operator (cross-tenant console). Only set by `npm run seed` or // the identity migration, never through the app, and only honoured while // the email is also listed in PLATFORM_ADMIN_EMAILS. platformAdmin: boolean; passwordHash: string; isActive: boolean; failedLoginAttempts: number; lockedUntil: Date | null; // Pending invite: the person sets their own password through a one-time // link. Only the SHA-256 of the token is stored. Until they accept, the // account has a random unknown password, so it cannot be signed into. inviteTokenHash: string | null; inviteExpiresAt: Date | null; createdAt: Date; updatedAt: Date; comparePassword(password: string): Promise; } const userSchema = new Schema( { 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 }, phoneNumber: { type: String, required: true }, email: { type: String, required: true, lowercase: true, trim: true }, platformAdmin: { type: Boolean, default: false }, passwordHash: { type: String, required: true, select: false }, isActive: { type: Boolean, default: true }, failedLoginAttempts: { type: Number, default: 0 }, lockedUntil: { type: Date, default: null }, inviteTokenHash: { type: String, default: null, index: { sparse: true } }, inviteExpiresAt: { type: Date, default: null }, }, { timestamps: true } ); userSchema.index({ businessId: 1, email: 1 }, { unique: true }); userSchema.index({ businessId: 1, phoneNumber: 1 }, { unique: true }); // Login looks an email up across businesses. Named so it never clashes with // the old platform-wide unique `email_1` index, which the identity // migration (npm run migrate:identity) drops. userSchema.index({ email: 1 }, { name: "email_lookup" }); // Enforce role/branch invariants at the model layer — belt-and-braces // alongside API-level validation. Every role authenticates the same way // now (email + password on the single /login page), so there is no // per-role credential branching left here — just the branch scoping rule. userSchema.pre("validate", function (next) { if (this.role !== "SUPER_ADMIN" && !this.branchId) { return next(new Error("branchId is required for all roles except SUPER_ADMIN")); } if (this.role === "SUPER_ADMIN" && this.branchId) { return next(new Error("SUPER_ADMIN accounts must not be scoped to a single branchId")); } next(); }); userSchema.methods.comparePassword = async function (password: string): Promise { if (!this.passwordHash) return false; return bcrypt.compare(password, this.passwordHash); }; userSchema.plugin(tenantGuard); export const User = (models.User as Model) || model("User", userSchema);