Add multi-vendor capability with admin vendor management
- Add resolveVendorId() helper — admin can pass ?vendorId= to scope catalog operations to any vendor; other roles locked to JWT vendorId - Thread ?vendorId= through products, categories, taxes, events routes - Add DELETE /vendors/:id (admin only) with cascade-safe guard: blocks if vendor has users or transactions; otherwise cascade-deletes EventProduct → EventTax → Event → Product → Tax → Category → Vendor - Rewrite VendorPage: admin gets full CRUD list, vendor gets own settings - Add VendorFilter shared component (admin-only dropdown) - Integrate VendorFilter into Catalog, Users, and Events pages so admin can switch vendor context for all create/read operations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
server/src/lib/vendorScope.ts
Normal file
16
server/src/lib/vendorScope.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Resolves the effective vendorId for a request.
|
||||
* Admin users may pass ?vendorId= to operate on any vendor's data.
|
||||
* All other roles are locked to their own vendorId.
|
||||
*/
|
||||
export function resolveVendorId(
|
||||
authReq: AuthenticatedRequest,
|
||||
query: Record<string, unknown> = {}
|
||||
): string {
|
||||
if (authReq.auth.roleName === "admin" && typeof query.vendorId === "string" && query.vendorId) {
|
||||
return query.vendorId;
|
||||
}
|
||||
return authReq.auth.vendorId;
|
||||
}
|
||||
@@ -5,86 +5,76 @@ 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";
|
||||
import { resolveVendorId } from "../lib/vendorScope.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 CategorySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
const CategorySchema = z.object({ name: z.string().min(1).max(100) });
|
||||
|
||||
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 where = { vendorId: authReq.auth.vendorId };
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
const where = { vendorId };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.category.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
|
||||
prisma.category.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const cat = await prisma.category.findFirst({
|
||||
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
|
||||
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
|
||||
});
|
||||
if (!cat) throw new AppError(404, "NOT_FOUND", "Category not found");
|
||||
res.json(cat);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
const body = CategorySchema.parse(req.body);
|
||||
const cat = await prisma.category.create({
|
||||
data: { ...body, vendorId: authReq.auth.vendorId },
|
||||
});
|
||||
const cat = await prisma.category.create({ data: { ...body, vendorId } });
|
||||
res.status(201).json(cat);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
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 existing = await prisma.category.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", "Category not found");
|
||||
|
||||
const body = CategorySchema.parse(req.body);
|
||||
const cat = await prisma.category.update({ where: { id: req.params.id }, data: body });
|
||||
res.json(cat);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const existing = await prisma.category.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", "Category not found");
|
||||
await prisma.category.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { resolveVendorId } from "../lib/vendorScope.js";
|
||||
|
||||
const router = Router();
|
||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
@@ -71,11 +72,7 @@ router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextF
|
||||
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 targetVendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { resolveVendorId } from "../lib/vendorScope.js";
|
||||
|
||||
const router = Router();
|
||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
@@ -25,65 +26,55 @@ router.get("/", auth, async (req: Request, res: Response, next: NextFunction) =>
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
||||
const { categoryId, search } = req.query as { categoryId?: string; search?: string };
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
|
||||
const where = {
|
||||
vendorId: authReq.auth.vendorId,
|
||||
vendorId,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
...(search
|
||||
? { name: { contains: search } }
|
||||
: {}),
|
||||
...(search ? { name: { contains: search } } : {}),
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { name: "asc" },
|
||||
include: { category: true, tax: true },
|
||||
}),
|
||||
prisma.product.findMany({ where, skip, take: limit, orderBy: { name: "asc" }, include: { category: true, tax: true } }),
|
||||
prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
|
||||
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
|
||||
include: { category: true, tax: true },
|
||||
});
|
||||
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
|
||||
res.json(product);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
const body = ProductSchema.parse(req.body);
|
||||
const product = await prisma.product.create({
|
||||
data: { ...body, vendorId: authReq.auth.vendorId },
|
||||
data: { ...body, vendorId },
|
||||
include: { category: true, tax: true },
|
||||
});
|
||||
res.status(201).json(product);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
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 existing = await prisma.product.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", "Product not found");
|
||||
|
||||
@@ -94,23 +85,20 @@ router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: Nex
|
||||
include: { category: true, tax: true },
|
||||
});
|
||||
res.json(product);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const existing = await prisma.product.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", "Product not found");
|
||||
await prisma.product.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { resolveVendorId } from "../lib/vendorScope.js";
|
||||
|
||||
const router = Router();
|
||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||
@@ -19,73 +20,64 @@ 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 where = { vendorId: authReq.auth.vendorId };
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
const where = { vendorId };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.tax.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
|
||||
prisma.tax.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const tax = await prisma.tax.findFirst({
|
||||
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
|
||||
where: { id: req.params.id, ...(isAdmin ? {} : { vendorId: authReq.auth.vendorId }) },
|
||||
});
|
||||
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
|
||||
res.json(tax);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
|
||||
const body = TaxSchema.parse(req.body);
|
||||
const tax = await prisma.tax.create({
|
||||
data: { ...body, vendorId: authReq.auth.vendorId },
|
||||
});
|
||||
const tax = await prisma.tax.create({ data: { ...body, vendorId } });
|
||||
res.status(201).json(tax);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
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 existing = await prisma.tax.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", "Tax not found");
|
||||
|
||||
const body = TaxSchema.parse(req.body);
|
||||
const tax = await prisma.tax.update({ where: { id: req.params.id }, data: body });
|
||||
res.json(tax);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const isAdmin = authReq.auth.roleName === "admin";
|
||||
const existing = await prisma.tax.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", "Tax not found");
|
||||
await prisma.tax.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -71,6 +71,39 @@ router.post("/", auth, adminOnly, async (req: Request, res: Response, next: Next
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/vendors/:id — admin only
|
||||
router.delete("/:id", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const vendor = await prisma.vendor.findUnique({ where: { id: req.params.id } });
|
||||
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
|
||||
|
||||
// Check for dependent data before deleting
|
||||
const [users, transactions] = await Promise.all([
|
||||
prisma.user.count({ where: { vendorId: req.params.id } }),
|
||||
prisma.transaction.count({ where: { vendorId: req.params.id } }),
|
||||
]);
|
||||
if (users > 0 || transactions > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
"CONFLICT",
|
||||
`Cannot delete vendor with existing data (${users} user(s), ${transactions} transaction(s)). Remove all associated data first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Safe to delete — cascade via Prisma in order
|
||||
await prisma.eventProduct.deleteMany({ where: { event: { vendorId: req.params.id } } });
|
||||
await prisma.eventTax.deleteMany({ where: { event: { vendorId: req.params.id } } });
|
||||
await prisma.event.deleteMany({ where: { vendorId: req.params.id } });
|
||||
await prisma.product.deleteMany({ where: { vendorId: req.params.id } });
|
||||
await prisma.tax.deleteMany({ where: { vendorId: req.params.id } });
|
||||
await prisma.category.deleteMany({ where: { vendorId: req.params.id } });
|
||||
await prisma.vendor.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/v1/vendors/:id — admin or vendor (own only)
|
||||
router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user