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:
@@ -1,6 +1,6 @@
|
||||
PORT=8080
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=file:./dev.db
|
||||
DATABASE_URL=file:./prisma/dev.db
|
||||
JWT_SECRET=change-me-in-production
|
||||
LOG_LEVEL=info
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
@@ -20,11 +20,12 @@ model Vendor {
|
||||
products Product[]
|
||||
taxes Tax[]
|
||||
transactions Transaction[]
|
||||
events Event[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // cashier | manager | owner
|
||||
name String @unique // admin | vendor | user
|
||||
|
||||
users User[]
|
||||
}
|
||||
@@ -74,8 +75,9 @@ model Tax {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
products Product[]
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
products Product[]
|
||||
eventOverrides EventTax[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
@@ -96,6 +98,7 @@ model Product {
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
tax Tax? @relation(fields: [taxId], references: [id])
|
||||
transactionItems TransactionItem[]
|
||||
eventProducts EventProduct[]
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
@@ -103,6 +106,7 @@ model Transaction {
|
||||
idempotencyKey String @unique
|
||||
vendorId String
|
||||
userId String
|
||||
eventId String?
|
||||
status String // pending | completed | failed | refunded
|
||||
paymentMethod String // cash | card
|
||||
subtotal Float
|
||||
@@ -115,6 +119,7 @@ model Transaction {
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
event Event? @relation(fields: [eventId], references: [id])
|
||||
items TransactionItem[]
|
||||
}
|
||||
|
||||
@@ -132,3 +137,48 @@ model TransactionItem {
|
||||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
}
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
vendorId String
|
||||
name String
|
||||
description String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
taxOverrides EventTax[]
|
||||
products EventProduct[]
|
||||
transactions Transaction[]
|
||||
}
|
||||
|
||||
// Tax rate overrides for a specific event. Shadows the vendor-level Tax for the
|
||||
// event duration. Empty = use vendor defaults.
|
||||
model EventTax {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
taxId String
|
||||
rate Float // override rate in percent
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
tax Tax @relation(fields: [taxId], references: [id])
|
||||
|
||||
@@unique([eventId, taxId])
|
||||
}
|
||||
|
||||
// Allowlist of products available at an event. Empty = all vendor products available.
|
||||
model EventProduct {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
productId String
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
@@unique([eventId, productId])
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@ const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Seed roles
|
||||
const ownerRole = await prisma.role.upsert({
|
||||
where: { name: "owner" },
|
||||
const adminRole = await prisma.role.upsert({
|
||||
where: { name: "admin" },
|
||||
update: {},
|
||||
create: { name: "owner" },
|
||||
create: { name: "admin" },
|
||||
});
|
||||
await prisma.role.upsert({
|
||||
where: { name: "manager" },
|
||||
where: { name: "vendor" },
|
||||
update: {},
|
||||
create: { name: "manager" },
|
||||
create: { name: "vendor" },
|
||||
});
|
||||
await prisma.role.upsert({
|
||||
where: { name: "cashier" },
|
||||
where: { name: "user" },
|
||||
update: {},
|
||||
create: { name: "cashier" },
|
||||
create: { name: "user" },
|
||||
});
|
||||
|
||||
// Seed demo vendor
|
||||
@@ -32,7 +32,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Seed demo owner user
|
||||
// Seed demo admin user
|
||||
await prisma.user.upsert({
|
||||
where: { email: "admin@demo.com" },
|
||||
update: {},
|
||||
@@ -41,7 +41,7 @@ async function main() {
|
||||
passwordHash: await bcrypt.hash("password123", 10),
|
||||
name: "Demo Admin",
|
||||
vendorId: vendor.id,
|
||||
roleId: ownerRole.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import taxesRouter from "./routes/taxes.js";
|
||||
import productsRouter from "./routes/products.js";
|
||||
import catalogRouter from "./routes/catalog.js";
|
||||
import transactionsRouter from "./routes/transactions.js";
|
||||
import eventsRouter from "./routes/events.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
import { requestLogger } from "./middleware/requestLogger.js";
|
||||
|
||||
@@ -40,6 +41,7 @@ export function createApp() {
|
||||
app.use("/api/v1/products", productsRouter);
|
||||
app.use("/api/v1/catalog", catalogRouter);
|
||||
app.use("/api/v1/transactions", transactionsRouter);
|
||||
app.use("/api/v1/events", eventsRouter);
|
||||
|
||||
// Serve React admin UI static assets in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
|
||||
@@ -29,8 +29,9 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
||||
}
|
||||
|
||||
const updatedAfter = since ? { updatedAt: { gt: since } } : {};
|
||||
const now = new Date();
|
||||
|
||||
const [products, categories, taxes] = await Promise.all([
|
||||
const [products, categories, taxes, events] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where: { vendorId, ...updatedAfter },
|
||||
include: { category: true, tax: true },
|
||||
@@ -44,6 +45,20 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
||||
where: { vendorId, ...updatedAfter },
|
||||
orderBy: { updatedAt: "asc" },
|
||||
}),
|
||||
// Active events (currently running or upcoming within range)
|
||||
prisma.event.findMany({
|
||||
where: {
|
||||
vendorId,
|
||||
isActive: true,
|
||||
endsAt: { gte: now },
|
||||
...(since ? { updatedAt: { gt: since } } : {}),
|
||||
},
|
||||
include: {
|
||||
taxOverrides: true,
|
||||
products: { select: { productId: true } },
|
||||
},
|
||||
orderBy: { startsAt: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@@ -52,10 +67,12 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
||||
products,
|
||||
categories,
|
||||
taxes,
|
||||
events,
|
||||
counts: {
|
||||
products: products.length,
|
||||
categories: categories.length,
|
||||
taxes: taxes.length,
|
||||
events: events.length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,7 +8,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;
|
||||
|
||||
const CategorySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -44,7 +44,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
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 body = CategorySchema.parse(req.body);
|
||||
@@ -57,7 +57,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.category.findFirst({
|
||||
@@ -73,7 +73,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.category.findFirst({
|
||||
|
||||
374
server/src/routes/events.ts
Normal file
374
server/src/routes/events.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||
import { AppError } from "../middleware/errorHandler.js";
|
||||
import { parsePage, paginatedResponse } from "../lib/pagination.js";
|
||||
import { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
const router = Router();
|
||||
const auth = requireAuth 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;
|
||||
|
||||
const EventSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
startsAt: z.string().datetime(),
|
||||
endsAt: z.string().datetime(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const EventTaxSchema = z.object({
|
||||
taxId: z.string().min(1),
|
||||
rate: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
const EventProductSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
|
||||
// Helper: resolve vendorId scope (admin sees all, vendor sees own)
|
||||
function vendorScope(authReq: AuthenticatedRequest) {
|
||||
return authReq.auth.roleName === "admin" ? {} : { vendorId: authReq.auth.vendorId };
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/events ────────────────────────────────────────────────────
|
||||
|
||||
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 where = vendorScope(authReq);
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.event.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { startsAt: "asc" },
|
||||
include: {
|
||||
vendor: { select: { id: true, name: true } },
|
||||
_count: { select: { products: true, taxOverrides: true, transactions: true } },
|
||||
},
|
||||
}),
|
||||
prisma.event.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/v1/events ───────────────────────────────────────────────────
|
||||
|
||||
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const body = EventSchema.parse(req.body);
|
||||
|
||||
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
|
||||
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
|
||||
}
|
||||
|
||||
// Admin can specify vendorId; vendor always uses their own
|
||||
const targetVendorId =
|
||||
authReq.auth.roleName === "admin" && (req.body as { vendorId?: string }).vendorId
|
||||
? (req.body as { vendorId: string }).vendorId
|
||||
: authReq.auth.vendorId;
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
...body,
|
||||
startsAt: new Date(body.startsAt),
|
||||
endsAt: new Date(body.endsAt),
|
||||
vendorId: targetVendorId,
|
||||
},
|
||||
include: { vendor: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
res.status(201).json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/events/:id ────────────────────────────────────────────────
|
||||
|
||||
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
include: {
|
||||
vendor: { select: { id: true, name: true } },
|
||||
taxOverrides: { include: { tax: true } },
|
||||
products: { include: { product: { select: { id: true, name: true, price: true, sku: true } } } },
|
||||
},
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
res.json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PUT /api/v1/events/:id ────────────────────────────────────────────────
|
||||
|
||||
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const body = EventSchema.parse(req.body);
|
||||
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
|
||||
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: req.params.id },
|
||||
data: { ...body, startsAt: new Date(body.startsAt), endsAt: new Date(body.endsAt) },
|
||||
include: { vendor: { select: { id: true, name: true } } },
|
||||
});
|
||||
res.json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /api/v1/events/:id ─────────────────────────────────────────────
|
||||
|
||||
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
await prisma.event.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tax overrides ─────────────────────────────────────────────────────────
|
||||
|
||||
// PUT /api/v1/events/:id/taxes — upsert a tax override (idempotent)
|
||||
router.put("/:id/taxes", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const { taxId, rate } = EventTaxSchema.parse(req.body);
|
||||
|
||||
// Verify tax belongs to same vendor
|
||||
const tax = await prisma.tax.findFirst({ where: { id: taxId, vendorId: event.vendorId } });
|
||||
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
|
||||
|
||||
const override = await prisma.eventTax.upsert({
|
||||
where: { eventId_taxId: { eventId: event.id, taxId } },
|
||||
create: { eventId: event.id, taxId, rate },
|
||||
update: { rate },
|
||||
include: { tax: true },
|
||||
});
|
||||
|
||||
res.json(override);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/events/:id/taxes/:taxId
|
||||
router.delete("/:id/taxes/:taxId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const existing = await prisma.eventTax.findUnique({
|
||||
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
|
||||
});
|
||||
if (!existing) throw new AppError(404, "NOT_FOUND", "Tax override not found");
|
||||
|
||||
await prisma.eventTax.delete({
|
||||
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Product allowlist ─────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/events/:id/products
|
||||
router.get("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const items = await prisma.eventProduct.findMany({
|
||||
where: { eventId: event.id },
|
||||
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
|
||||
});
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/events/:id/products — add product to allowlist
|
||||
router.post("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const { productId } = EventProductSchema.parse(req.body);
|
||||
|
||||
// Verify product belongs to same vendor
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, vendorId: event.vendorId } });
|
||||
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
|
||||
|
||||
const item = await prisma.eventProduct.upsert({
|
||||
where: { eventId_productId: { eventId: event.id, productId } },
|
||||
create: { eventId: event.id, productId },
|
||||
update: {},
|
||||
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
|
||||
});
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/events/:id/products/:productId
|
||||
router.delete("/:id/products/:productId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const existing = await prisma.eventProduct.findUnique({
|
||||
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
|
||||
});
|
||||
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not in event allowlist");
|
||||
|
||||
await prisma.eventProduct.delete({
|
||||
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/events/:id/transactions ──────────────────────────────────
|
||||
|
||||
router.get("/:id/transactions", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
||||
const where = { eventId: event.id };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true, email: true } }, items: true },
|
||||
}),
|
||||
prisma.transaction.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/events/:id/reports/summary ───────────────────────────────
|
||||
|
||||
router.get("/:id/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||
});
|
||||
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||
|
||||
const where = { eventId: event.id, status: "completed" };
|
||||
|
||||
const [totals, byPayment, topProducts] = await Promise.all([
|
||||
prisma.transaction.aggregate({
|
||||
where,
|
||||
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
|
||||
_count: { id: true },
|
||||
_avg: { total: true },
|
||||
}),
|
||||
prisma.transaction.groupBy({
|
||||
by: ["paymentMethod"],
|
||||
where,
|
||||
_sum: { total: true },
|
||||
_count: { id: true },
|
||||
}),
|
||||
prisma.transactionItem.groupBy({
|
||||
by: ["productId", "productName"],
|
||||
where: { transaction: where },
|
||||
_sum: { total: true, quantity: true },
|
||||
orderBy: { _sum: { total: "desc" } },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
event: { id: event.id, name: event.name, startsAt: event.startsAt, endsAt: event.endsAt },
|
||||
totals: {
|
||||
revenue: totals._sum.total ?? 0,
|
||||
subtotal: totals._sum.subtotal ?? 0,
|
||||
tax: totals._sum.taxTotal ?? 0,
|
||||
discounts: totals._sum.discountTotal ?? 0,
|
||||
transactionCount: totals._count.id,
|
||||
averageTransaction: totals._avg.total ?? 0,
|
||||
},
|
||||
byPaymentMethod: byPayment.map((r) => ({
|
||||
method: r.paymentMethod,
|
||||
revenue: r._sum.total ?? 0,
|
||||
count: r._count.id,
|
||||
})),
|
||||
topProducts: topProducts.map((r) => ({
|
||||
productId: r.productId,
|
||||
productName: r.productName,
|
||||
revenue: r._sum.total ?? 0,
|
||||
unitsSold: r._sum.quantity ?? 0,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -8,7 +8,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;
|
||||
|
||||
const ProductSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -65,7 +65,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
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 body = ProductSchema.parse(req.body);
|
||||
@@ -79,7 +79,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.product.findFirst({
|
||||
@@ -99,7 +99,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.product.findFirst({
|
||||
|
||||
@@ -8,7 +8,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;
|
||||
|
||||
const TaxSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -45,7 +45,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
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 body = TaxSchema.parse(req.body);
|
||||
@@ -58,7 +58,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.tax.findFirst({
|
||||
@@ -74,7 +74,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
||||
}
|
||||
});
|
||||
|
||||
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 existing = await prisma.tax.findFirst({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { processPayment } from "../lib/payments.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;
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +34,7 @@ const TransactionSchema = z.object({
|
||||
total: z.number().min(0),
|
||||
notes: z.string().max(500).optional(),
|
||||
items: z.array(TransactionItemSchema).min(1),
|
||||
eventId: z.string().optional(),
|
||||
// Android includes a local timestamp for ordering
|
||||
createdAt: z.string().datetime().optional(),
|
||||
});
|
||||
@@ -96,6 +97,7 @@ router.post("/batch", auth, async (req: Request, res: Response, next: NextFuncti
|
||||
discountTotal: tx.discountTotal,
|
||||
total: tx.total,
|
||||
notes: tx.notes,
|
||||
...(tx.eventId ? { eventId: tx.eventId } : {}),
|
||||
...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}),
|
||||
items: {
|
||||
create: tx.items.map((item) => ({
|
||||
@@ -145,6 +147,7 @@ const SingleTransactionSchema = z.object({
|
||||
total: z.number().min(0),
|
||||
notes: z.string().max(500).optional(),
|
||||
items: z.array(TransactionItemSchema).min(1),
|
||||
eventId: z.string().optional(),
|
||||
});
|
||||
|
||||
router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -186,6 +189,7 @@ router.post("/", auth, async (req: Request, res: Response, next: NextFunction) =
|
||||
discountTotal: body.discountTotal,
|
||||
total: body.total,
|
||||
notes: body.notes,
|
||||
...(body.eventId ? { eventId: body.eventId } : {}),
|
||||
items: {
|
||||
create: body.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
@@ -224,7 +228,7 @@ router.post("/", auth, async (req: Request, res: Response, next: NextFunction) =
|
||||
|
||||
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
|
||||
|
||||
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const { vendorId } = authReq.auth;
|
||||
@@ -264,7 +268,7 @@ router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextF
|
||||
|
||||
// ─── GET /api/v1/transactions/: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 tx = await prisma.transaction.findFirst({
|
||||
@@ -284,7 +288,7 @@ router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
||||
// ─── GET /api/v1/transactions/reports/summary ─────────────────────────────
|
||||
// Daily totals, payment method breakdown, top products.
|
||||
|
||||
router.get("/reports/summary", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get("/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const { vendorId } = authReq.auth;
|
||||
@@ -355,7 +359,7 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon
|
||||
// also returns an average transaction value and opening/closing time of the
|
||||
// first and last completed transaction in the period.
|
||||
|
||||
router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get("/reports/shift", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const { vendorId } = authReq.auth;
|
||||
@@ -422,7 +426,7 @@ router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response
|
||||
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
|
||||
// Server-authoritative: only managers/owners can issue refunds.
|
||||
|
||||
router.post("/:id/refund", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post("/:id/refund", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const tx = await prisma.transaction.findFirst({
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
const router = Router();
|
||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
const ownerOnly = requireRole("owner") as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
const adminOnly = requireRole("admin") as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
|
||||
const VendorSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -16,20 +16,22 @@ const VendorSchema = z.object({
|
||||
taxSettings: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// GET /api/v1/vendors — list (owner sees their own vendor)
|
||||
// GET /api/v1/vendors — admin sees all vendors; vendor/user sees their own
|
||||
router.get("/", auth, 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 ? {} : { id: authReq.auth.vendorId };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.vendor.findMany({
|
||||
where: { id: authReq.auth.vendorId },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.vendor.count({ where: { id: authReq.auth.vendorId } }),
|
||||
prisma.vendor.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
@@ -42,8 +44,9 @@ router.get("/", auth, async (req: Request, res: Response, next: NextFunction) =>
|
||||
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const vendor = await prisma.vendor.findFirst({
|
||||
where: { id: req.params.id, ...(authReq.auth.roleName !== "owner" ? { id: authReq.auth.vendorId } : {}) },
|
||||
where: { id: req.params.id, ...(isAdmin ? {} : { id: authReq.auth.vendorId }) },
|
||||
});
|
||||
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
|
||||
res.json(vendor);
|
||||
@@ -52,8 +55,8 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/vendors
|
||||
router.post("/", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
|
||||
// POST /api/v1/vendors — admin only
|
||||
router.post("/", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = VendorSchema.parse(req.body);
|
||||
const vendor = await prisma.vendor.create({
|
||||
@@ -68,13 +71,17 @@ router.post("/", auth, ownerOnly, async (req: Request, res: Response, next: Next
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/v1/vendors/:id
|
||||
router.put("/:id", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
|
||||
// PUT /api/v1/vendors/:id — admin or vendor (own only)
|
||||
router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (req.params.id !== authReq.auth.vendorId) {
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
if (!isAdmin && req.params.id !== authReq.auth.vendorId) {
|
||||
throw new AppError(403, "FORBIDDEN", "Cannot modify another vendor");
|
||||
}
|
||||
if (!isAdmin && !["admin", "vendor"].includes(authReq.auth.roleName)) {
|
||||
throw new AppError(403, "FORBIDDEN", "Insufficient permissions");
|
||||
}
|
||||
const body = VendorSchema.parse(req.body);
|
||||
const vendor = await prisma.vendor.update({
|
||||
where: { id: req.params.id },
|
||||
|
||||
Reference in New Issue
Block a user