Rename roles, add multi-vendor support, and Events system

Roles: owner→admin, manager→vendor, cashier→user across all routes,
seed, and client UI. Role badge colours updated in UsersPage.

Multi-vendor:
- GET /vendors and GET /users now return all records for admin role;
  vendor/user roles remain scoped to their vendorId
- POST /users: admin can specify vendorId to assign user to any vendor
- vendors/users now include vendor name in responses for admin context

Events (new):
- Prisma schema: Event, EventTax, EventProduct models; Transaction.eventId
- POST/GET/PUT/DELETE /api/v1/events — full CRUD, vendor-scoped
- PUT /events/:id/taxes + DELETE — upsert/remove per-event tax rate overrides
- POST/GET/DELETE /events/:id/products — product allowlist (empty=all)
- GET /events/:id/transactions — paginated list scoped to event
- GET /events/:id/reports/summary — revenue, avg tx, top products for event
- Transactions: eventId accepted in both single POST and batch POST
- Catalog sync: active/upcoming events included in /catalog/sync response

Client:
- Layout nav filtered by role (user role sees Catalog only)
- Dashboard cards filtered by role
- Events page: list, create/edit modal, detail modal with Configuration
  (tax overrides + product allowlist) and Reports tabs

DB: DATABASE_URL updated to file:./prisma/dev.db in .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 07:27:30 -05:00
parent c426b19b7c
commit 65eb405cf1
17 changed files with 1014 additions and 78 deletions

View File

@@ -9,7 +9,7 @@ import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
// Strip passwordHash from any user object before sending
function safe<T extends { passwordHash?: string }>(u: T): Omit<T, "passwordHash"> {
@@ -22,6 +22,7 @@ const CreateUserSchema = z.object({
password: z.string().min(8),
name: z.string().min(1).max(100),
roleId: z.string().min(1),
vendorId: z.string().min(1).optional(), // admin can assign to any vendor
});
const UpdateUserSchema = z.object({
@@ -30,20 +31,21 @@ const UpdateUserSchema = z.object({
password: z.string().min(8).optional(),
});
// GET /api/v1/users
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
// GET /api/v1/users — admin sees all users; vendor sees their own vendor
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const isAdmin = authReq.auth.roleName === "admin";
const where = isAdmin ? {} : { vendorId: authReq.auth.vendorId };
const where = { vendorId: authReq.auth.vendorId };
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { role: true },
include: { role: true, vendor: { select: { id: true, name: true } } },
}),
prisma.user.count({ where }),
]);
@@ -65,12 +67,13 @@ router.get("/roles/list", auth, async (_req: Request, res: Response, next: NextF
});
// GET /api/v1/users/:id
router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const user = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: { role: true },
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
include: { role: true, vendor: { select: { id: true, name: true } } },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json(safe(user));
@@ -80,9 +83,10 @@ router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
});
// POST /api/v1/users
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const body = CreateUserSchema.parse(req.body);
const existing = await prisma.user.findUnique({ where: { email: body.email } });
@@ -91,20 +95,22 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
// Managers cannot create owners
if (authReq.auth.roleName === "manager" && role.name === "owner") {
throw new AppError(403, "FORBIDDEN", "Managers cannot create owner accounts");
// Vendors cannot create admin accounts
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
throw new AppError(403, "FORBIDDEN", "Vendors cannot create admin accounts");
}
const targetVendorId = isAdmin && body.vendorId ? body.vendorId : authReq.auth.vendorId;
const user = await prisma.user.create({
data: {
email: body.email,
passwordHash: await bcrypt.hash(body.password, 10),
name: body.name,
vendorId: authReq.auth.vendorId,
vendorId: targetVendorId,
roleId: body.roleId,
},
include: { role: true },
include: { role: true, vendor: { select: { id: true, name: true } } },
});
res.status(201).json(safe(user));
@@ -114,13 +120,14 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
});
// PUT /api/v1/users/:id
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
const body = UpdateUserSchema.parse(req.body);
const existing = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
include: { role: true },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
@@ -128,8 +135,8 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
if (body.roleId) {
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
if (authReq.auth.roleName === "manager" && role.name === "owner") {
throw new AppError(403, "FORBIDDEN", "Managers cannot assign owner role");
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
throw new AppError(403, "FORBIDDEN", "Vendors cannot assign admin role");
}
}
@@ -141,7 +148,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
const user = await prisma.user.update({
where: { id: req.params.id },
data: updateData,
include: { role: true },
include: { role: true, vendor: { select: { id: true, name: true } } },
});
res.json(safe(user));
@@ -151,14 +158,15 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
});
// DELETE /api/v1/users/:id
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const isAdmin = authReq.auth.roleName === "admin";
if (req.params.id === authReq.auth.userId) {
throw new AppError(400, "BAD_REQUEST", "Cannot delete your own account");
}
const existing = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");