Files
ERP/src/app/api/auth/signup/route.ts
T
denis_ireri d4808b3e46 Enforce roles and tenant status on the server, and use the Kenyan business day
The admin pages redirected cashiers away, but the API itself let them read
profit, expenses and purchases and record expenses and purchases. Branch
managers could also change product prices for every branch. Every gated
route now goes through authorize(), which answers 401 when signed out and
403 for the wrong role.

- Owners and managers only: profit report, expenses, purchases, users,
  morning dispatch and evening count. Owner only: create and edit products,
  rename branches.
- PATCH /api/users/:id deactivates or reactivates staff (managers: their own
  cashiers only) and revokes the user's device sessions. PATCH
  /api/branches/:id renames a branch.
- Login and token refresh refuse inactive or SUSPENDED businesses.
- Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered
  operator email can no longer be claimed.
- M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the
  pilot): its routes return 404 and the Daraja variables are optional.
  Turning it on in production requires the callback IP allowlist.
- "Today" and the default month now follow Africa/Nairobi instead of the
  server's UTC clock, so the business day no longer starts at 03:00 EAT.
- Malformed ids in query strings and bodies return 400 instead of a 500,
  the inventory routes check the product belongs to the tenant, and the
  reconciliation lookups are tenant-scoped.
- Branch-scoped staff see their branch's override price from GET /api/products.
- Adds a vitest suite against an in-memory MongoDB (npm test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:34:35 +03:00

147 lines
5.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Business } from "@/lib/server/models/Business";
import { Product } from "@/lib/server/models/Product";
import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
import { isPlatformAdminEmail } from "@/lib/server/platform";
export const runtime = "nodejs";
const signupSchema = z.object({
businessName: z.string().min(2).max(120),
ownerName: z.string().min(2).max(120),
email: z.string().email(),
phoneNumber: z.string().min(1),
password: z.string().min(8, "Password must be at least 8 characters"),
deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(),
});
const DEFAULT_CATALOG = [
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
];
export async function POST(req: NextRequest) {
await connectDb();
const json = await req.json().catch(() => null);
const parsed = signupSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { businessName, ownerName, email, phoneNumber, password, deviceId, deviceLabel } = parsed.data;
let normalizedPhone: string;
try {
normalizedPhone = normalizeKenyanPhone(phoneNumber);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
// and signup does not verify email ownership, so an operator address
// must never be claimable through public signup. Operator accounts are
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
}
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12);
const ownerId = new mongoose.Types.ObjectId();
const businessId = new mongoose.Types.ObjectId();
const business = new Business({
_id: businessId,
name: businessName.trim(),
ownerId,
tier: DEFAULT_TIER,
subscriptionStatus: "TRIALING",
trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000),
currentPeriodEnd: null,
});
const owner = new User({
_id: ownerId,
businessId,
name: ownerName.trim(),
role: "SUPER_ADMIN",
branchId: null,
phoneNumber: normalizedPhone,
email: lowerEmail,
passwordHash,
});
try {
await business.save();
await owner.save();
} catch (e) {
await Business.deleteOne({ _id: businessId }).catch(() => {});
await User.deleteOne({ _id: ownerId }).catch(() => {});
const msg = (e as Error).message || "";
if (msg.includes("duplicate key")) {
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create your business. Please try again." }, { status: 500 });
}
await Product.insertMany(DEFAULT_CATALOG.map((p) => ({ ...p, businessId })));
const refreshToken = generateRefreshToken();
const refreshTokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + refreshTtlMs());
await UserSession.findOneAndUpdate(
{ userId: owner._id, deviceId },
{ refreshTokenHash, expiresAt, deviceLabel: deviceLabel ?? null, lastUsedAt: new Date() },
{ upsert: true, new: true }
);
const accessToken = signAccessToken({
sub: owner._id.toString(),
businessId: businessId.toString(),
role: owner.role,
branchId: null,
deviceId,
});
const res = NextResponse.json({
user: {
id: owner._id,
name: owner.name,
role: owner.role,
businessId,
businessName: business.name,
branchId: null,
branchName: null,
phoneNumber: owner.phoneNumber,
email: owner.email,
},
redirectTo: "/admin/dashboard",
});
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}