Enforce tenant isolation in the data layer instead of per route
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().
- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
find/update/delete/count/distinct, or an aggregate whose first stage is
not a $match, that does not pin exactly one businessId throws
TenantScopeError before reaching the database ($in, $exists and other
operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
queries that must span businesses - login's email lookup, refresh and
invite-token lookups, the M-Pesa callback, the platform console and the
maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
restricted to the business.
- Every flagged query site now names the business (or is exempted with a
reason).
- Tests: a check that every model with a businessId field carries the
guard; the guard refusing unscoped queries of every kind; and a route
sweep where business A sends business B's ids to every read route
(nothing of B's comes back) and every write route (all refused, B's
data unchanged). Test-side database inspections go through db(), an
explicit test exemption. Removing one route's business filter makes the
suite fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-3
@@ -157,10 +157,31 @@ platform-wide unique indexes for per-business ones. Until it runs,
|
|||||||
operators cannot open the Platform console and a second business cannot
|
operators cannot open the Platform console and a second business cannot
|
||||||
reuse an email. It is safe to re-run.
|
reuse an email. It is safe to re-run.
|
||||||
|
|
||||||
|
## Tenant isolation is enforced
|
||||||
|
|
||||||
|
Every model that holds one business's data carries a guard
|
||||||
|
(`src/lib/server/tenantGuard.ts`). A query that does not name exactly one
|
||||||
|
business (`businessId: <id>`) throws `TenantScopeError` before it reaches
|
||||||
|
the database, so a route that forgets the business filter fails loudly in
|
||||||
|
development and in the tests instead of reading or changing another
|
||||||
|
business's data.
|
||||||
|
|
||||||
|
- Guarded: User, Branch, Product, BranchPricing, Transaction,
|
||||||
|
DailyInventory, Expense, Purchase, StockMovement, StockTransfer. A test
|
||||||
|
fails if a new model with a `businessId` field is added without the guard.
|
||||||
|
- Queries that genuinely span businesses must be wrapped in
|
||||||
|
`crossTenant("reason", ...)`: login's email lookup, refresh-session and
|
||||||
|
invite-token lookups, the M-Pesa callback, the platform console, and the
|
||||||
|
maintenance scripts. `git grep crossTenant` lists every one.
|
||||||
|
- Related records are loaded with `scopedPopulate`, so a populated branch,
|
||||||
|
product or staff name is also restricted to the business.
|
||||||
|
- `tests/isolation.test.ts` sweeps every read route with another business's
|
||||||
|
ids (nothing of theirs comes back) and every write route (all refused,
|
||||||
|
their data unchanged).
|
||||||
|
- Not guarded, by design: Business (the tenant itself, always looked up by
|
||||||
|
the session's id), UserSession, MpesaCallbackLog and StockLock.
|
||||||
|
|
||||||
## Still open
|
## Still open
|
||||||
- **Isolation by convention.** Tenant filters are still written per route.
|
|
||||||
The tests cover the sales, stock, transfer and access paths, not every
|
|
||||||
route.
|
|
||||||
- **Cost of goods is an average.** It uses each product's average buying
|
- **Cost of goods is an average.** It uses each product's average buying
|
||||||
price across the business, not first-in-first-out per branch. Units sold
|
price across the business, not first-in-first-out per branch. Units sold
|
||||||
with no purchase on record are counted and flagged, but have no cost.
|
with no purchase on record are counted and flagged, but have no cost.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Expense } from "../src/lib/server/models/Expense";
|
|||||||
import { Purchase } from "../src/lib/server/models/Purchase";
|
import { Purchase } from "../src/lib/server/models/Purchase";
|
||||||
import { Business } from "../src/lib/server/models/Business";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
async function migrate() {
|
async function migrate() {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
@@ -75,7 +76,7 @@ async function migrate() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate().catch((err) => {
|
crossTenant("one-off migration of data from before businesses existed", migrate).catch((err) => {
|
||||||
console.error("migration failed:", err.message);
|
console.error("migration failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -6,6 +6,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 { platformAdminEmails } from "../src/lib/server/platform";
|
import { platformAdminEmails } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-off, safe to re-run. Moves user identity from platform-wide to
|
* One-off, safe to re-run. Moves user identity from platform-wide to
|
||||||
@@ -43,7 +44,7 @@ async function main() {
|
|||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
crossTenant("identity migration spans every business", main).catch((err) => {
|
||||||
console.error("migration failed:", err.message);
|
console.error("migration failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -14,6 +14,7 @@ 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";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
import { isPlatformAdminEmail } from "../src/lib/server/platform";
|
import { isPlatformAdminEmail } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run once against a fresh database: `npm run seed`.
|
* Run once against a fresh database: `npm run seed`.
|
||||||
@@ -107,7 +108,7 @@ async function seed() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
seed().catch((err) => {
|
crossTenant("seeding may find a legacy admin outside any business", seed).catch((err) => {
|
||||||
console.error("seed failed:", err.message);
|
console.error("seed failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
|
|||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
import { isPlatformAdmin } from "@/lib/server/platform";
|
import { isPlatformAdmin } from "@/lib/server/platform";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { getEnv } from "@/lib/server/env";
|
import { getEnv } from "@/lib/server/env";
|
||||||
import { tenantAccessError } from "@/lib/server/subscription";
|
import { tenantAccessError } from "@/lib/server/subscription";
|
||||||
|
|
||||||
@@ -54,7 +55,9 @@ export async function POST(req: NextRequest) {
|
|||||||
// several businesses, each with its own password.
|
// several businesses, each with its own password.
|
||||||
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
|
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
|
||||||
if (businessId) filter.businessId = businessId;
|
if (businessId) filter.businessId = businessId;
|
||||||
const candidates = await User.find(filter).select("+passwordHash");
|
const candidates = await crossTenant("login looks an email up in every business", () =>
|
||||||
|
User.find(filter).select("+passwordHash").exec()
|
||||||
|
);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -149,7 +152,7 @@ export async function POST(req: NextRequest) {
|
|||||||
deviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
|
||||||
const business = await Business.findById(user.businessId).lean();
|
const business = await Business.findById(user.businessId).lean();
|
||||||
|
|
||||||
const res = NextResponse.json({
|
const res = NextResponse.json({
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ 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 user = await User.findById(auth.sub).lean();
|
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).lean();
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
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.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
|
||||||
const business = await Business.findById(user.businessId).lean();
|
const business = await Business.findById(user.businessId).lean();
|
||||||
|
|
||||||
const tier = (business?.tier as TierId) ?? "STARTER";
|
const tier = (business?.tier as TierId) ?? "STARTER";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { User } from "@/lib/server/models/User";
|
|||||||
import { UserSession } from "@/lib/server/models/UserSession";
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
import { tenantAccessError } from "@/lib/server/subscription";
|
import { tenantAccessError } from "@/lib/server/subscription";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -24,7 +25,9 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
|
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findById(session.userId);
|
const user = await crossTenant("a refresh session identifies its user before the business is known", () =>
|
||||||
|
User.findById(session.userId).exec()
|
||||||
|
);
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
await UserSession.deleteOne({ _id: session._id });
|
await UserSession.deleteOne({ _id: session._id });
|
||||||
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
|
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export async function POST(req: NextRequest) {
|
|||||||
await owner.save();
|
await owner.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await Business.deleteOne({ _id: businessId }).catch(() => {});
|
await Business.deleteOne({ _id: businessId }).catch(() => {});
|
||||||
await User.deleteOne({ _id: ownerId }).catch(() => {});
|
await User.deleteOne({ _id: ownerId, businessId }).catch(() => {});
|
||||||
const msg = (e as Error).message || "";
|
const msg = (e as Error).message || "";
|
||||||
if (msg.includes("duplicate key")) {
|
if (msg.includes("duplicate key")) {
|
||||||
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
|
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = await BranchPricing.findOneAndUpdate(
|
const row = await BranchPricing.findOneAndUpdate(
|
||||||
{ branchId, productId },
|
{ businessId: auth.businessId, branchId, productId },
|
||||||
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
|
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
).lean();
|
).lean();
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function POST(req: NextRequest) {
|
|||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
|
await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
|
||||||
if (err?.code === 11000) {
|
if (err?.code === 11000) {
|
||||||
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
|
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expenses = await Expense.find(filter)
|
const expenses = await Expense.find(filter)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(500)
|
.limit(500)
|
||||||
.lean();
|
.lean();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const date = parsed.data.date ?? todayNairobi();
|
const date = parsed.data.date ?? todayNairobi();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
|
||||||
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
|
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const date = parsed.data.date ?? todayNairobi();
|
const date = parsed.data.date ?? todayNairobi();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
|
||||||
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
|
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { hashInviteToken } from "@/lib/server/invites";
|
import { hashInviteToken } from "@/lib/server/invites";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
async function findInvitee(token: string) {
|
async function findInvitee(token: string) {
|
||||||
if (!token || token.length > 100) return null;
|
if (!token || token.length > 100) return null;
|
||||||
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
|
const user = await crossTenant("an invite token identifies its account before sign-in", () =>
|
||||||
|
User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true }).exec()
|
||||||
|
);
|
||||||
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { isMpesaEnabled } from "@/lib/server/env";
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { recordSaleMovement } from "@/lib/server/stock";
|
import { recordSaleMovement } from "@/lib/server/stock";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
|
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -69,7 +70,9 @@ export async function POST(req: NextRequest) {
|
|||||||
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
|
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
|
const txn = await crossTenant("the payment provider's callback is matched by checkout id", () =>
|
||||||
|
Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId }).exec()
|
||||||
|
);
|
||||||
|
|
||||||
if (!txn) {
|
if (!txn) {
|
||||||
await MpesaCallbackLog.create({
|
await MpesaCallbackLog.create({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
|
|||||||
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
|
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
|
||||||
const code = Number(result.ResultCode);
|
const code = Number(result.ResultCode);
|
||||||
if (!Number.isNaN(code) && code !== 1032) {
|
if (!Number.isNaN(code) && code !== 1032) {
|
||||||
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
|
const fresh = await Transaction.findOne({ _id: txn._id, businessId: auth.businessId, paymentStatus: "PENDING" });
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
|
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
|
||||||
fresh.mpesaDetails.resultCode = code;
|
fresh.mpesaDetails.resultCode = code;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Business } from "@/lib/server/models/Business";
|
|||||||
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 { requirePlatformAdmin } from "@/lib/server/platform";
|
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -23,23 +24,26 @@ export async function GET(req: NextRequest) {
|
|||||||
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
|
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
|
||||||
|
|
||||||
// Usage counts per business in two grouped queries instead of N+1.
|
// Usage counts per business in two grouped queries instead of N+1.
|
||||||
const [userCounts, branchCounts] = await Promise.all([
|
const [userCounts, branchCounts] = await crossTenant("the platform console counts every business", () =>
|
||||||
User.aggregate<{ _id: unknown; count: number }>([
|
Promise.all([
|
||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
User.aggregate<{ _id: unknown; count: number }>([{ $group: { _id: "$businessId", count: { $sum: 1 } } }]).exec(),
|
||||||
]),
|
|
||||||
Branch.aggregate<{ _id: unknown; count: number }>([
|
Branch.aggregate<{ _id: unknown; count: number }>([
|
||||||
{ $match: { isActive: { $ne: false } } },
|
{ $match: { isActive: { $ne: false } } },
|
||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||||
]),
|
]).exec(),
|
||||||
]);
|
])
|
||||||
|
);
|
||||||
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
|
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
|
||||||
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
|
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
|
||||||
|
|
||||||
// Owner contact lookup.
|
// Owner contact lookup.
|
||||||
const ownerIds = businesses.map((b) => b.ownerId);
|
const ownerIds = businesses.map((b) => b.ownerId);
|
||||||
const owners = await User.find({ _id: { $in: ownerIds } })
|
const owners = await crossTenant("the platform console lists every business's owner", () =>
|
||||||
|
User.find({ _id: { $in: ownerIds } })
|
||||||
.select("name email phoneNumber")
|
.select("name email phoneNumber")
|
||||||
.lean();
|
.lean()
|
||||||
|
.exec()
|
||||||
|
);
|
||||||
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
|
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
|
||||||
|
|
||||||
const rows = businesses.map((b) => {
|
const rows = businesses.map((b) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { recordMovement } from "@/lib/server/stock";
|
import { recordMovement } from "@/lib/server/stock";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const purchases = await Purchase.find(filter)
|
const purchases = await Purchase.find(filter)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(500)
|
.limit(500)
|
||||||
.lean();
|
.lean();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const lockedBranch = branchId;
|
const lockedBranch = branchId;
|
||||||
try {
|
try {
|
||||||
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
||||||
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
|
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
|
||||||
if (!already) {
|
if (!already) {
|
||||||
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
||||||
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { StockMovement } from "@/lib/server/models/StockMovement";
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,9 +24,9 @@ export async function GET(req: NextRequest) {
|
|||||||
const movements = await StockMovement.find(filter)
|
const movements = await StockMovement.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("productId", "label unit")
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
return NextResponse.json({ movements });
|
return NextResponse.json({ movements });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
||||||
|
|
||||||
@@ -27,10 +28,10 @@ export async function GET(req: NextRequest) {
|
|||||||
const transfers = await StockTransfer.find(filter)
|
const transfers = await StockTransfer.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.populate("fromBranchId", "branchName")
|
.populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
|
||||||
.populate("toBranchId", "branchName")
|
.populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
|
||||||
.populate("productId", "label unit")
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
return NextResponse.json({ transfers });
|
return NextResponse.json({ transfers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,10 +167,8 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if ((err as { code?: number })?.code !== 11000) throw err;
|
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||||
// Already recorded by an earlier attempt: reuse it.
|
// Already recorded by an earlier attempt: reuse it.
|
||||||
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` });
|
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
|
||||||
if (!existing || existing.businessId.toString() !== auth.businessId) {
|
if (!existing) return reject("This sale id is already in use.");
|
||||||
return reject("This sale id is already in use.");
|
|
||||||
}
|
|
||||||
txn = existing;
|
txn = existing;
|
||||||
}
|
}
|
||||||
// Idempotent, and also repairs a line whose stock movement was not
|
// Idempotent, and also repairs a line whose stock movement was not
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { authorize, branchParam } from "@/lib/server/auth";
|
import { authorize, branchParam } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
import { startOfTodayNairobi } from "@/lib/server/businessDay";
|
import { startOfTodayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const transactions = await Transaction.find(filter)
|
const transactions = await Transaction.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
return NextResponse.json({ transactions });
|
return NextResponse.json({ transactions });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IBranch extends Document {
|
export interface IBranch extends Document {
|
||||||
@@ -30,5 +31,7 @@ const branchSchema = new Schema<IBranch>(
|
|||||||
/** Filter for branches that are in use. */
|
/** Filter for branches that are in use. */
|
||||||
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||||
|
|
||||||
|
branchSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Branch =
|
export const Branch =
|
||||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IDailyInventory extends Document {
|
export interface IDailyInventory extends Document {
|
||||||
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
|
|||||||
// One ledger row per branch+product+day.
|
// One ledger row per branch+product+day.
|
||||||
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
||||||
|
|
||||||
|
dailyInventorySchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const DailyInventory =
|
export const DailyInventory =
|
||||||
(models.DailyInventory as Model<IDailyInventory>) ||
|
(models.DailyInventory as Model<IDailyInventory>) ||
|
||||||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// Business expense categories. Kept as a constrained string set so the UI
|
// Business expense categories. Kept as a constrained string set so the UI
|
||||||
@@ -46,5 +47,7 @@ expenseSchema.index({ category: 1 });
|
|||||||
|
|
||||||
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
||||||
// re-invocations where the module is evaluated more than once.
|
// re-invocations where the module is evaluated more than once.
|
||||||
|
expenseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Expense =
|
export const Expense =
|
||||||
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IProduct extends Document {
|
export interface IProduct extends Document {
|
||||||
@@ -32,6 +33,8 @@ const productSchema = new Schema<IProduct>(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
productSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Product =
|
export const Product =
|
||||||
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
|
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ const branchPricingSchema = new Schema<IBranchPricing>(
|
|||||||
);
|
);
|
||||||
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
||||||
|
|
||||||
|
branchPricingSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const BranchPricing =
|
export const BranchPricing =
|
||||||
(models.BranchPricing as Model<IBranchPricing>) ||
|
(models.BranchPricing as Model<IBranchPricing>) ||
|
||||||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// A recorded stock purchase from a supplier. Completing a purchase is what
|
// A recorded stock purchase from a supplier. Completing a purchase is what
|
||||||
@@ -40,5 +41,7 @@ const purchaseSchema = new Schema<IPurchase>(
|
|||||||
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
||||||
purchaseSchema.index({ productId: 1 });
|
purchaseSchema.index({ productId: 1 });
|
||||||
|
|
||||||
|
purchaseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Purchase =
|
export const Purchase =
|
||||||
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
||||||
@@ -46,6 +47,8 @@ stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
|
|||||||
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
||||||
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockMovementSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const StockMovement =
|
export const StockMovement =
|
||||||
(models.StockMovement as Model<IStockMovement>) ||
|
(models.StockMovement as Model<IStockMovement>) ||
|
||||||
model<IStockMovement>("StockMovement", stockMovementSchema);
|
model<IStockMovement>("StockMovement", stockMovementSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IStockTransfer extends Document {
|
export interface IStockTransfer extends Document {
|
||||||
@@ -35,6 +36,8 @@ const stockTransferSchema = new Schema<IStockTransfer>(
|
|||||||
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
||||||
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockTransferSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const StockTransfer =
|
export const StockTransfer =
|
||||||
(models.StockTransfer as Model<IStockTransfer>) ||
|
(models.StockTransfer as Model<IStockTransfer>) ||
|
||||||
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export type PaymentMethod = "CASH" | "MPESA";
|
export type PaymentMethod = "CASH" | "MPESA";
|
||||||
@@ -105,6 +106,8 @@ transactionSchema.index({ businessId: 1, createdAt: -1 });
|
|||||||
transactionSchema.index({ paymentStatus: 1 });
|
transactionSchema.index({ paymentStatus: 1 });
|
||||||
transactionSchema.index({ businessId: 1, saleId: 1 });
|
transactionSchema.index({ businessId: 1, saleId: 1 });
|
||||||
|
|
||||||
|
transactionSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Transaction =
|
export const Transaction =
|
||||||
(models.Transaction as Model<ITransaction>) ||
|
(models.Transaction as Model<ITransaction>) ||
|
||||||
model<ITransaction>("Transaction", transactionSchema);
|
model<ITransaction>("Transaction", transactionSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
@@ -80,5 +81,7 @@ userSchema.methods.comparePassword = async function (password: string): Promise<
|
|||||||
return bcrypt.compare(password, this.passwordHash);
|
return bcrypt.compare(password, this.passwordHash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const User =
|
export const User =
|
||||||
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
|
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export async function requirePlatformAdmin(
|
|||||||
): Promise<{ userId: string; email: string } | null> {
|
): Promise<{ userId: string; email: string } | null> {
|
||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return null;
|
if (!auth) return null;
|
||||||
const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean();
|
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId })
|
||||||
|
.select("email isActive platformAdmin")
|
||||||
|
.lean();
|
||||||
if (!user || !user.isActive) return null;
|
if (!user || !user.isActive) return null;
|
||||||
if (!isPlatformAdmin(user)) return null;
|
if (!isPlatformAdmin(user)) return null;
|
||||||
return { userId: auth.sub, email: user.email };
|
return { userId: auth.sub, email: user.email };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { StockLock } from "@/lib/server/models/StockLock";
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||||
|
|
||||||
type Id = Types.ObjectId | string;
|
type Id = Types.ObjectId | string;
|
||||||
@@ -26,7 +27,7 @@ export interface MovementInput {
|
|||||||
*/
|
*/
|
||||||
export async function recordMovement(m: MovementInput): Promise<void> {
|
export async function recordMovement(m: MovementInput): Promise<void> {
|
||||||
await StockMovement.updateOne(
|
await StockMovement.updateOne(
|
||||||
{ type: m.type, refId: m.refId },
|
{ type: m.type, refId: m.refId, businessId: m.businessId },
|
||||||
{
|
{
|
||||||
$setOnInsert: {
|
$setOnInsert: {
|
||||||
businessId: m.businessId,
|
businessId: m.businessId,
|
||||||
@@ -155,11 +156,14 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
|||||||
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||||
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||||
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
|
const purchases = businessId
|
||||||
|
? await Purchase.find(filter).lean()
|
||||||
|
: await crossTenant("the backfill runs over every business", () => Purchase.find(filter).lean().exec());
|
||||||
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for await (const p of Purchase.find(filter).lean().cursor()) {
|
for (const p of purchases) {
|
||||||
const refId = p._id.toString();
|
const refId = p._id.toString();
|
||||||
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
|
if (await StockMovement.exists({ type: "PURCHASE", refId, businessId: p.businessId })) continue;
|
||||||
await recordMovement({
|
await recordMovement({
|
||||||
businessId: p.businessId,
|
businessId: p.businessId,
|
||||||
branchId: p.branchId,
|
branchId: p.branchId,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { AsyncLocalStorage } from "async_hooks";
|
||||||
|
import { Types, type MongooseQueryMiddleware, type Schema } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant isolation, enforced instead of remembered.
|
||||||
|
*
|
||||||
|
* Every model that holds one business's data is guarded: a query against
|
||||||
|
* it that does not name exactly one business (`businessId: <id>`) throws
|
||||||
|
* TenantScopeError before it reaches the database. A route that forgets
|
||||||
|
* the business filter therefore fails loudly in development and in the
|
||||||
|
* test suite, instead of quietly reading or changing another business's
|
||||||
|
* data in production.
|
||||||
|
*
|
||||||
|
* The few queries that legitimately span businesses (login looking an
|
||||||
|
* email up everywhere, an invite token, the payment provider's callback,
|
||||||
|
* the platform console, maintenance scripts) must say so explicitly:
|
||||||
|
*
|
||||||
|
* await crossTenant("login looks the email up in every business", () => User.find(...));
|
||||||
|
*
|
||||||
|
* Guarded: find, findOne (and findById / exists), findOneAndUpdate /
|
||||||
|
* Delete / Replace, updateOne / updateMany, replaceOne, deleteOne /
|
||||||
|
* deleteMany, countDocuments, distinct, and aggregate (its first stage
|
||||||
|
* must be a $match naming the business). populate() runs a find on the
|
||||||
|
* referenced model, so populate through `scopedPopulate` below.
|
||||||
|
*
|
||||||
|
* Not guarded, by design: Business (the tenant itself, always looked up by
|
||||||
|
* the id from the session), UserSession (keyed by the signed-in user),
|
||||||
|
* MpesaCallbackLog (provider audit log), StockLock (key already contains
|
||||||
|
* the business). Document saves and inserts are covered by `businessId`
|
||||||
|
* being a required field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class TenantScopeError extends Error {
|
||||||
|
constructor(model: string, op: string) {
|
||||||
|
super(
|
||||||
|
`Tenant isolation: ${model}.${op} must filter by a single businessId ` +
|
||||||
|
`(or run inside crossTenant("why", ...) if it really spans businesses).`
|
||||||
|
);
|
||||||
|
this.name = "TenantScopeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossTenantReason = new AsyncLocalStorage<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn`, whose queries deliberately span businesses. `reason` documents
|
||||||
|
* why. The result is awaited inside the scope, so a lazy Mongoose query
|
||||||
|
* returned by `fn` executes while the exemption applies.
|
||||||
|
*/
|
||||||
|
export function crossTenant<T>(reason: string, fn: () => PromiseLike<T>): Promise<T> {
|
||||||
|
if (!reason.trim()) throw new Error("crossTenant needs a reason");
|
||||||
|
return crossTenantReason.run(reason, async () => await fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the filter pins exactly one business. */
|
||||||
|
export function namesOneBusiness(filter: unknown): boolean {
|
||||||
|
if (!filter || typeof filter !== "object") return false;
|
||||||
|
const f = filter as Record<string, unknown>;
|
||||||
|
const v = f.businessId;
|
||||||
|
if (typeof v === "string" || v instanceof Types.ObjectId) return true;
|
||||||
|
if (v && typeof v === "object" && "$eq" in (v as object)) {
|
||||||
|
const eq = (v as { $eq: unknown }).$eq;
|
||||||
|
return typeof eq === "string" || eq instanceof Types.ObjectId;
|
||||||
|
}
|
||||||
|
return Array.isArray(f.$and) && f.$and.some(namesOneBusiness);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_OPS: MongooseQueryMiddleware[] = [
|
||||||
|
"find",
|
||||||
|
"findOne",
|
||||||
|
"findOneAndUpdate",
|
||||||
|
"findOneAndDelete",
|
||||||
|
"findOneAndReplace",
|
||||||
|
"updateOne",
|
||||||
|
"updateMany",
|
||||||
|
"replaceOne",
|
||||||
|
"deleteOne",
|
||||||
|
"deleteMany",
|
||||||
|
"countDocuments",
|
||||||
|
"distinct",
|
||||||
|
];
|
||||||
|
|
||||||
|
const guardedSchemas = new WeakSet<Schema>();
|
||||||
|
|
||||||
|
/** Whether a schema has the guard (the test suite checks every business-scoped model does). */
|
||||||
|
export function isTenantGuarded(schema: Schema): boolean {
|
||||||
|
return guardedSchemas.has(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mongoose plugin: apply to every schema with a `businessId` path. */
|
||||||
|
export function tenantGuard(schema: Schema): void {
|
||||||
|
guardedSchemas.add(schema);
|
||||||
|
|
||||||
|
schema.pre(QUERY_OPS, function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
if (!namesOneBusiness(this.getFilter())) throw new TenantScopeError(this.model.modelName, this.op);
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.pre("aggregate", function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
const first = this.pipeline()[0];
|
||||||
|
if (!first || !namesOneBusiness(first.$match)) {
|
||||||
|
throw new TenantScopeError(this._model?.modelName ?? "Model", "aggregate");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* populate() options that keep the referenced documents inside the
|
||||||
|
* business too (populate queries the referenced model, which is guarded).
|
||||||
|
*/
|
||||||
|
export function scopedPopulate(path: string, select: string, businessId: string | Types.ObjectId) {
|
||||||
|
return { path, select, match: { businessId } };
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import * as login from "@/app/api/auth/login/route";
|
|||||||
import * as signup from "@/app/api/auth/signup/route";
|
import * as signup from "@/app/api/auth/signup/route";
|
||||||
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||||
import * as callback from "@/app/api/mpesa/callback/route";
|
import * as callback from "@/app/api/mpesa/callback/route";
|
||||||
import { json, makeTenant, params, req, uuid } from "./helpers";
|
import { json, makeTenant, params, req, uuid, db } from "./helpers";
|
||||||
|
|
||||||
describe("role-based access on the backend", () => {
|
describe("role-based access on the backend", () => {
|
||||||
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||||
@@ -78,7 +78,7 @@ describe("user deactivation", () => {
|
|||||||
params({ id: t.cashierA.id })
|
params({ id: t.cashierA.id })
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
|
expect((await db(() => User.findById(t.cashierA.id)))!.isActive).toBe(false);
|
||||||
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ describe("tenant status", () => {
|
|||||||
it("refuses login for a suspended business", async () => {
|
it("refuses login for a suspended business", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
|
await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
|
||||||
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||||
|
|
||||||
const res = await login.POST(
|
const res = await login.POST(
|
||||||
@@ -119,7 +119,7 @@ describe("tenant status", () => {
|
|||||||
it("still allows login for an active business", async () => {
|
it("still allows login for an active business", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
|
await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
|
||||||
const res = await login.POST(
|
const res = await login.POST(
|
||||||
req("POST", "/api/auth/login", {
|
req("POST", "/api/auth/login", {
|
||||||
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BranchPricing } from "@/lib/server/models/Product";
|
|||||||
import * as prices from "@/app/api/branch-prices/route";
|
import * as prices from "@/app/api/branch-prices/route";
|
||||||
import * as products from "@/app/api/products/route";
|
import * as products from "@/app/api/products/route";
|
||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
||||||
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
||||||
@@ -44,7 +44,7 @@ describe("branch prices", () => {
|
|||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await setPrice(t.owner, t.branchA, t.water20, 180);
|
await setPrice(t.owner, t.branchA, t.water20, 180);
|
||||||
await setPrice(t.owner, t.branchA, t.water20, 200);
|
await setPrice(t.owner, t.branchA, t.water20, 200);
|
||||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
|
|
||||||
await setPrice(t.owner, t.branchA, t.water5, 55);
|
await setPrice(t.owner, t.branchA, t.water5, 55);
|
||||||
const reset = await prices.DELETE(
|
const reset = await prices.DELETE(
|
||||||
@@ -85,6 +85,6 @@ describe("branch prices", () => {
|
|||||||
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
|
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
|
||||||
expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404);
|
expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404);
|
||||||
expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
|
expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
|
||||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { StockTransfer } from "@/lib/server/models/StockTransfer";
|
|||||||
import { StockLock } from "@/lib/server/models/StockLock";
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
import * as transfers from "@/app/api/stock/transfers/route";
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
import { makeTenant, req, uuid, type Tenant } from "./helpers";
|
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
async function openingStock(t: Tenant, quantity: number) {
|
async function openingStock(t: Tenant, quantity: number) {
|
||||||
await adjustments.POST(
|
await adjustments.POST(
|
||||||
@@ -35,7 +35,7 @@ describe("concurrent stock changes", () => {
|
|||||||
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
|
||||||
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
||||||
expect(await StockTransfer.countDocuments({})).toBe(2);
|
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("holds across repeated races, not just once", async () => {
|
it("holds across repeated races, not just once", async () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import * as profit from "@/app/api/reports/profit/route";
|
|||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
import * as transfers from "@/app/api/stock/transfers/route";
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
|
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
|
||||||
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
|
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
|
||||||
@@ -39,7 +39,7 @@ describe("F-01 trial branch allowance", () => {
|
|||||||
it("counts only active branches, and a paid Starter plan allows one", async () => {
|
it("counts only active branches, and a paid Starter plan allows one", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await setPlan(t, "STARTER", "TRIALING");
|
await setPlan(t, "STARTER", "TRIALING");
|
||||||
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||||
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
|
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
|
||||||
|
|
||||||
await setPlan(t, "STARTER", "ACTIVE");
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
@@ -70,7 +70,7 @@ describe("F-06 branch deactivation", () => {
|
|||||||
expect(withStaff.status).toBe(409);
|
expect(withStaff.status).toBe(409);
|
||||||
expect((await withStaff.json()).error).toMatch(/active staff/);
|
expect((await withStaff.json()).error).toMatch(/active staff/);
|
||||||
|
|
||||||
await User.updateOne({ _id: t.cashierB.id }, { isActive: false });
|
await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
|
||||||
expect((await deactivate()).status).toBe(200);
|
expect((await deactivate()).status).toBe(200);
|
||||||
|
|
||||||
// Hidden from pickers, kept on the Branches page.
|
// Hidden from pickers, kept on the Branches page.
|
||||||
@@ -82,7 +82,7 @@ describe("F-06 branch deactivation", () => {
|
|||||||
|
|
||||||
it("will not reactivate a branch past the plan's limit", async () => {
|
it("will not reactivate a branch past the plan's limit", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||||
await setPlan(t, "STARTER", "ACTIVE");
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
|
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
@@ -152,7 +152,7 @@ describe("F-04 / F-05 / CAT-12 catalogue", () => {
|
|||||||
await adjustments.POST(
|
await adjustments.POST(
|
||||||
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
|
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
|
||||||
);
|
);
|
||||||
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
|
||||||
|
|
||||||
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
||||||
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
|
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
|
||||||
@@ -228,13 +228,13 @@ describe("STA-04 staff invites", () => {
|
|||||||
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
||||||
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
|
||||||
|
|
||||||
await User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) });
|
await db(() => User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
|
||||||
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not let a manager re-invite another branch's staff", async () => {
|
it("does not let a manager re-invite another branch's staff", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
|
await db(() => User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) }));
|
||||||
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
|
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { User, UserRole } from "@/lib/server/models/User";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { signAccessToken } from "@/lib/server/jwt";
|
import { signAccessToken } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
let phoneSeq = 0;
|
let phoneSeq = 0;
|
||||||
function nextPhone(): string {
|
function nextPhone(): string {
|
||||||
@@ -131,3 +132,8 @@ export async function json<T = any>(res: Response): Promise<{ status: number; bo
|
|||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return randomUUID();
|
return randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tests may inspect and arrange any business's data directly. */
|
||||||
|
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
|
||||||
|
return crossTenant("test inspects the database directly", fn);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as login from "@/app/api/auth/login/route";
|
|||||||
import * as me from "@/app/api/auth/me/route";
|
import * as me from "@/app/api/auth/me/route";
|
||||||
import * as platformBusinesses from "@/app/api/platform/businesses/route";
|
import * as platformBusinesses from "@/app/api/platform/businesses/route";
|
||||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
|
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
|
||||||
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
|
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
|
||||||
@@ -35,7 +35,7 @@ describe("email and phone are unique per business", () => {
|
|||||||
const b = await makeTenant("Beta");
|
const b = await makeTenant("Beta");
|
||||||
expect((await hire(a, a.owner)).status).toBe(201);
|
expect((await hire(a, a.owner)).status).toBe(201);
|
||||||
expect((await hire(b, b.owner)).status).toBe(201);
|
expect((await hire(b, b.owner)).status).toBe(201);
|
||||||
expect(await User.countDocuments({ email: PERSON.email })).toBe(2);
|
expect(await db(() => User.countDocuments({ email: PERSON.email }))).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still refuses a duplicate inside the same business", async () => {
|
it("still refuses a duplicate inside the same business", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { readdirSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { crossTenant, isTenantGuarded, TenantScopeError } from "@/lib/server/tenantGuard";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
|
import { Expense } from "@/lib/server/models/Expense";
|
||||||
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
|
import { stockOf } from "@/lib/server/stock";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import * as transactions from "@/app/api/transactions/route";
|
||||||
|
import * as expenses from "@/app/api/expenses/route";
|
||||||
|
import * as purchases from "@/app/api/purchases/route";
|
||||||
|
import * as stock from "@/app/api/stock/route";
|
||||||
|
import * as movements from "@/app/api/stock/movements/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
|
import * as summary from "@/app/api/dashboard/summary/route";
|
||||||
|
import * as reconciliation from "@/app/api/dashboard/reconciliation/route";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import * as users from "@/app/api/users/route";
|
||||||
|
import * as userById from "@/app/api/users/[id]/route";
|
||||||
|
import * as reinvite from "@/app/api/users/[id]/invite/route";
|
||||||
|
import * as branchById from "@/app/api/branches/[id]/route";
|
||||||
|
import * as productById from "@/app/api/products/[id]/route";
|
||||||
|
import * as branchPrices from "@/app/api/branch-prices/route";
|
||||||
|
import * as dispatch from "@/app/api/inventory/dispatch/route";
|
||||||
|
import * as eveningCount from "@/app/api/inventory/evening-count/route";
|
||||||
|
import { db, json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
||||||
|
|
||||||
|
// Models that hold one business's data but are deliberately unguarded (see tenantGuard.ts).
|
||||||
|
const UNGUARDED = new Set(["Business", "UserSession", "MpesaCallbackLog", "StockLock"]);
|
||||||
|
|
||||||
|
describe("the tenant guard", () => {
|
||||||
|
it("is on every model that stores a businessId", async () => {
|
||||||
|
// Load every model file so none can be skipped by accident.
|
||||||
|
const dir = path.resolve(__dirname, "../src/lib/server/models");
|
||||||
|
for (const file of readdirSync(dir)) await import(path.join(dir, file));
|
||||||
|
|
||||||
|
const scoped = Object.values(mongoose.models).filter((m) => m.schema.path("businessId") && !UNGUARDED.has(m.modelName));
|
||||||
|
expect(scoped.length).toBeGreaterThanOrEqual(10);
|
||||||
|
const unguarded = scoped.filter((m) => !isTenantGuarded(m.schema)).map((m) => m.modelName);
|
||||||
|
expect(unguarded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses queries that do not name exactly one business", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const attempts: [string, () => PromiseLike<unknown>][] = [
|
||||||
|
["find", () => Transaction.find({ branchId: t.branchA })],
|
||||||
|
["findById", () => User.findById(t.cashierA.id)],
|
||||||
|
["exists", () => StockMovement.exists({ type: "SALE" })],
|
||||||
|
["updateMany", () => Expense.updateMany({}, { amount: 0 })],
|
||||||
|
["deleteMany", () => Transaction.deleteMany({ paymentMethod: "CASH" })],
|
||||||
|
["countDocuments", () => User.countDocuments({})],
|
||||||
|
["$in over businesses", () => User.find({ businessId: { $in: [t.businessId, new mongoose.Types.ObjectId()] } })],
|
||||||
|
["$exists", () => User.find({ businessId: { $exists: true } })],
|
||||||
|
["aggregate without $match", () => Transaction.aggregate([{ $group: { _id: "$businessId" } }])],
|
||||||
|
["aggregate matching later", () => Transaction.aggregate([{ $sort: { createdAt: -1 } }, { $match: { businessId: t.businessId } }])],
|
||||||
|
["unscoped populate", () => Expense.find({ businessId: t.businessId }).populate("branchId")],
|
||||||
|
];
|
||||||
|
await Expense.create({ businessId: t.businessId, branchId: t.branchA, category: "Rent", amount: 1, description: "x", recordedBy: t.owner.id });
|
||||||
|
for (const [label, run] of attempts) {
|
||||||
|
await expect(Promise.resolve().then(run), label).rejects.toBeInstanceOf(TenantScopeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows scoped queries, and cross-business ones only with a stated reason", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
expect(await User.countDocuments({ businessId: t.businessId })).toBe(4);
|
||||||
|
expect(await User.countDocuments({ $and: [{ businessId: t.businessId }, { role: "DELIVERY" }] })).toBe(2);
|
||||||
|
expect(await crossTenant("test", () => User.countDocuments({}))).toBe(4);
|
||||||
|
expect(() => crossTenant(" ", () => User.countDocuments({}))).toThrow(/reason/);
|
||||||
|
// The exemption ends with the callback.
|
||||||
|
await expect(Promise.resolve().then(() => User.countDocuments({}))).rejects.toBeInstanceOf(TenantScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Give tenant B one of everything, so a leak into tenant A's view would show. */
|
||||||
|
async function seedVictim(b: Tenant) {
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "DELTA", quantity: 50 } })
|
||||||
|
);
|
||||||
|
await cash.POST(
|
||||||
|
req("POST", "/x", { as: b.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 3 }] }] } })
|
||||||
|
);
|
||||||
|
await expenses.POST(req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, category: "Rent", amount: 7777, description: "B rent" } }));
|
||||||
|
await purchases.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, supplierName: "B supplier", productId: b.water20, quantity: 5, unitCost: 99 } })
|
||||||
|
);
|
||||||
|
await transfers.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: b.branchB, productId: b.water20, quantity: 2 } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("route sweep: another business's ids get nothing", () => {
|
||||||
|
it("every read route returns none of business B's data to business A, whatever ids A sends", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await seedVictim(b);
|
||||||
|
|
||||||
|
const reads: [string, Response][] = [];
|
||||||
|
const get = async (label: string, handler: (r: any) => Promise<Response>, url: string) =>
|
||||||
|
reads.push([label, await handler(req("GET", url, { as: a.owner }))]);
|
||||||
|
const q = "?branchId=" + b.branchA;
|
||||||
|
await get("transactions", transactions.GET, "/api/transactions" + q);
|
||||||
|
await get("expenses", expenses.GET, "/api/expenses" + q);
|
||||||
|
await get("purchases", purchases.GET, "/api/purchases" + q);
|
||||||
|
await get("stock", stock.GET, "/api/stock" + q);
|
||||||
|
await get("movements", movements.GET, "/api/stock/movements" + q);
|
||||||
|
await get("transfers", transfers.GET, "/api/stock/transfers" + q);
|
||||||
|
await get("summary", summary.GET, "/api/dashboard/summary" + q);
|
||||||
|
await get("reconciliation", reconciliation.GET, "/api/dashboard/reconciliation" + q);
|
||||||
|
await get("profit", profit.GET, "/api/reports/profit" + q);
|
||||||
|
await get("users", users.GET, "/api/users" + q);
|
||||||
|
await get("branch prices", branchPrices.GET, "/api/branch-prices" + q);
|
||||||
|
|
||||||
|
const bIds = [b.businessId, b.branchA, b.branchB, b.water20, b.owner.id, b.cashierA.id];
|
||||||
|
for (const [label, res] of reads) {
|
||||||
|
const text = await res.text();
|
||||||
|
for (const id of bIds) expect(text.includes(id), `${label} leaked ${id}`).toBe(false);
|
||||||
|
expect(text.includes("7777") || text.includes("B supplier"), `${label} leaked B's figures`).toBe(false);
|
||||||
|
}
|
||||||
|
// Totals A sees are A's own (zero), not B's sale.
|
||||||
|
const totals = JSON.parse(await (await summary.GET(req("GET", "/api/dashboard/summary" + q, { as: a.owner }))).text());
|
||||||
|
expect(totals.totals.cashTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every write route refuses business B's ids and leaves B's data untouched", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await seedVictim(b);
|
||||||
|
const before = {
|
||||||
|
stockA: await stockOf(b.businessId, b.branchA, b.water20),
|
||||||
|
cashier: await db(() => User.findById(b.cashierA.id).lean()),
|
||||||
|
sales: await Transaction.countDocuments({ businessId: b.businessId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const as = a.owner;
|
||||||
|
const writes: [string, Response][] = [
|
||||||
|
["deactivate B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { isActive: false } }), params({ id: b.cashierA.id }))],
|
||||||
|
["move B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { branchId: a.branchA } }), params({ id: b.cashierA.id }))],
|
||||||
|
["re-invite B's cashier", await reinvite.POST(req("POST", "/x", { as }), params({ id: b.cashierA.id }))],
|
||||||
|
["rename B's branch", await branchById.PATCH(req("PATCH", "/x", { as, body: { branchName: "Pwned" } }), params({ id: b.branchA }))],
|
||||||
|
["reprice B's product", await productById.PATCH(req("PATCH", "/x", { as, body: { unitPrice: 1 } }), params({ id: b.water20 }))],
|
||||||
|
["branch price on B", await branchPrices.PUT(req("PUT", "/x", { as, body: { branchId: b.branchA, productId: b.water20, unitPrice: 1 } }))],
|
||||||
|
["adjust B's stock", await adjustments.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "COUNT", quantity: 0 } }))],
|
||||||
|
["transfer out of B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: a.branchA, productId: b.water20, quantity: 1 } }))],
|
||||||
|
["transfer into B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: a.branchA, toBranchId: b.branchA, productId: a.water20, quantity: 1 } }))],
|
||||||
|
["expense on B", await expenses.POST(req("POST", "/x", { as, body: { branchId: b.branchA, category: "Rent", amount: 1, description: "x" } }))],
|
||||||
|
["purchase into B", await purchases.POST(req("POST", "/x", { as, body: { branchId: b.branchA, supplierName: "x", productId: b.water20, quantity: 1, unitCost: 1 } }))],
|
||||||
|
["dispatch at B", await dispatch.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, morningDispatched: 1 } }))],
|
||||||
|
["count at B", await eveningCount.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, eveningPhysicalCount: 1 } }))],
|
||||||
|
["sale of B's product", await cash.POST(req("POST", "/x", { as: a.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 1 }] }] } }))],
|
||||||
|
];
|
||||||
|
for (const [label, res] of writes) {
|
||||||
|
if (label.startsWith("sale")) {
|
||||||
|
expect((await json(res)).body.results[0].status, label).toBe("rejected");
|
||||||
|
} else {
|
||||||
|
expect(res.status, label).toBeGreaterThanOrEqual(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await stockOf(b.businessId, b.branchA, b.water20)).toBe(before.stockA);
|
||||||
|
expect(await db(() => User.findById(b.cashierA.id).lean())).toMatchObject({
|
||||||
|
isActive: before.cashier!.isActive,
|
||||||
|
branchId: before.cashier!.branchId,
|
||||||
|
});
|
||||||
|
expect(await Transaction.countDocuments({ businessId: b.businessId })).toBe(before.sales);
|
||||||
|
expect(await Expense.countDocuments({ businessId: b.businessId })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-7
@@ -4,7 +4,7 @@ import { Product, BranchPricing } from "@/lib/server/models/Product";
|
|||||||
import { stockOf } from "@/lib/server/stock";
|
import { stockOf } from "@/lib/server/stock";
|
||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import type { SaleResult } from "@/lib/syncProtocol";
|
import type { SaleResult } from "@/lib/syncProtocol";
|
||||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
|
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -31,7 +31,7 @@ describe("offline sale sync", () => {
|
|||||||
const { status, body } = await post(t.cashierA, [s]);
|
const { status, body } = await post(t.cashierA, [s]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
|
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
|
||||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2);
|
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(2);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
|
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ describe("offline sale sync", () => {
|
|||||||
await post(t.cashierA, [s]);
|
await post(t.cashierA, [s]);
|
||||||
const replay = await post(t.cashierA, [s]);
|
const replay = await post(t.cashierA, [s]);
|
||||||
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
|
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
|
||||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1);
|
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(1);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ describe("offline sale sync", () => {
|
|||||||
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
|
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
|
||||||
const { body } = await post(t.cashierA, [otherCashiersSale]);
|
const { body } = await post(t.cashierA, [otherCashiersSale]);
|
||||||
expect(body.results[0].status).toBe("rejected");
|
expect(body.results[0].status).toBe("rejected");
|
||||||
expect(await Transaction.countDocuments({})).toBe(0);
|
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
|
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
|
||||||
@@ -86,7 +86,7 @@ describe("offline sale sync", () => {
|
|||||||
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
|
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
|
||||||
const moved = { ...t.cashierA, branchId: t.branchB };
|
const moved = { ...t.cashierA, branchId: t.branchB };
|
||||||
await post(moved, [s]);
|
await post(moved, [s]);
|
||||||
const txn = await Transaction.findOne({ saleId: s.clientUuid });
|
const txn = await db(() => Transaction.findOne({ saleId: s.clientUuid }));
|
||||||
expect(txn!.branchId.toString()).toBe(t.branchA);
|
expect(txn!.branchId.toString()).toBe(t.branchA);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ describe("offline sale sync", () => {
|
|||||||
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
|
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
|
||||||
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
|
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
|
||||||
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
||||||
expect(await Transaction.countDocuments({})).toBe(0);
|
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
|
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
|
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
|
||||||
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
|
||||||
const s = sale(t, t.cashierA, [
|
const s = sale(t, t.cashierA, [
|
||||||
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
|
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
|
||||||
{ productId: t.water5, quantity: 1, unitPrice: 60 },
|
{ productId: t.water5, quantity: 1, unitPrice: 60 },
|
||||||
|
|||||||
Reference in New Issue
Block a user