Add Milestones 1 & 2: full-stack POS foundation with admin UI

- Node/Express/TypeScript API under /api/v1 with JWT auth (login, refresh, logout, /me)
- Prisma schema: vendors, users, roles, products, categories, taxes, transactions
- SQLite for local dev; Postgres via docker-compose for production
- Full CRUD routes for vendors, users, categories, taxes, products with Zod validation and RBAC
- Paginated list endpoints scoped per vendor; refresh token rotation
- React/TypeScript admin SPA (Vite): login, protected routing, sidebar layout
- Pages: Dashboard, Catalog (tabbed Products/Categories/Taxes), Users, Vendor Settings
- Shared UI: Table, Modal, FormField, Btn, PageHeader components
- Multi-stage Dockerfile; docker-compose with Postgres healthcheck
- Seed script with demo vendor and owner account
- INSTRUCTIONS.md, ROADMAP.md, .claude/launch.json for dev server config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:18:04 -05:00
parent fb62439eab
commit d53c772dd6
4594 changed files with 1876068 additions and 0 deletions

50
server/src/app.ts Normal file
View File

@@ -0,0 +1,50 @@
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import healthRouter from "./routes/health.js";
import authRouter from "./routes/auth.js";
import vendorsRouter from "./routes/vendors.js";
import usersRouter from "./routes/users.js";
import categoriesRouter from "./routes/categories.js";
import taxesRouter from "./routes/taxes.js";
import productsRouter from "./routes/products.js";
import { errorHandler } from "./middleware/errorHandler.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function createApp() {
const app = express();
app.use(
cors({
origin: process.env.CORS_ORIGIN ?? "*",
credentials: true,
})
);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API routes
app.use("/api/v1", healthRouter);
app.use("/api/v1/auth", authRouter);
app.use("/api/v1/vendors", vendorsRouter);
app.use("/api/v1/users", usersRouter);
app.use("/api/v1/categories", categoriesRouter);
app.use("/api/v1/taxes", taxesRouter);
app.use("/api/v1/products", productsRouter);
// Serve React admin UI static assets in production
if (process.env.NODE_ENV === "production") {
const clientDist = path.join(__dirname, "../../client/dist");
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, "index.html"));
});
}
app.use(errorHandler);
return app;
}

35
server/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import "dotenv/config";
import { createApp } from "./app.js";
import { prisma } from "./lib/prisma.js";
const PORT = Number(process.env.PORT ?? 8080);
async function main() {
// Verify DB connectivity on startup
try {
await prisma.$connect();
console.log("Database connected");
} catch (err) {
console.error("Failed to connect to database:", err);
process.exit(1);
}
const app = createApp();
const server = app.listen(PORT, () => {
console.log(`POS API running on port ${PORT} [${process.env.NODE_ENV ?? "development"}]`);
});
const shutdown = async () => {
console.log("Shutting down...");
server.close(async () => {
await prisma.$disconnect();
process.exit(0);
});
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main();

View File

@@ -0,0 +1,27 @@
export interface PageParams {
page: number;
limit: number;
skip: number;
}
export function parsePage(query: Record<string, unknown>): PageParams {
const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(query.limit ?? "20"), 10) || 20));
return { page, limit, skip: (page - 1) * limit };
}
export function paginatedResponse<T>(
data: T[],
total: number,
{ page, limit }: PageParams
) {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}

18
server/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

View File

@@ -0,0 +1,43 @@
import { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { AuthPayload, AuthenticatedRequest } from "../types/index.js";
import { AppError } from "./errorHandler.js";
export function requireAuth(
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return next(new AppError(401, "UNAUTHORIZED", "Missing or invalid token"));
}
const token = authHeader.slice(7);
const secret = process.env.JWT_SECRET;
if (!secret) {
return next(new AppError(500, "CONFIG_ERROR", "JWT secret not configured"));
}
try {
const payload = jwt.verify(token, secret) as AuthPayload;
req.auth = payload;
next();
} catch {
next(new AppError(401, "UNAUTHORIZED", "Invalid or expired token"));
}
}
export function requireRole(...roles: string[]) {
return (req: AuthenticatedRequest, _res: Response, next: NextFunction) => {
if (!req.auth) {
return next(new AppError(401, "UNAUTHORIZED", "Not authenticated"));
}
if (!roles.includes(req.auth.roleName)) {
return next(
new AppError(403, "FORBIDDEN", "Insufficient permissions")
);
}
next();
};
}

View File

@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
this.name = "AppError";
}
}
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
return;
}
if (err instanceof ZodError) {
res.status(422).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid request data",
details: err.flatten(),
},
});
return;
}
console.error("Unhandled error:", err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
}

198
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Router, Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { AppError } from "../middleware/errorHandler.js";
import { requireAuth } from "../middleware/auth.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function signAccessToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: ACCESS_TOKEN_TTL });
}
function signRefreshToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: REFRESH_TOKEN_TTL });
}
// POST /api/v1/auth/login
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
router.post(
"/login",
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = LoginSchema.parse(req.body);
const user = await prisma.user.findUnique({
where: { email: body.email },
include: { role: true, vendor: true },
});
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password");
}
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
},
});
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/refresh
const RefreshSchema = z.object({
refreshToken: z.string().min(1),
});
router.post(
"/refresh",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = RefreshSchema.parse(req.body);
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError(500, "CONFIG_ERROR", "JWT secret not configured");
let decoded: { userId: string };
try {
decoded = jwt.verify(refreshToken, secret) as { userId: string };
} catch {
throw new AppError(401, "INVALID_TOKEN", "Invalid or expired refresh token");
}
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: { include: { role: true } } },
});
if (!stored || stored.userId !== decoded.userId || stored.expiresAt < new Date()) {
throw new AppError(401, "INVALID_TOKEN", "Refresh token not found or expired");
}
// Rotate refresh token
await prisma.refreshToken.delete({ where: { id: stored.id } });
const user = stored.user;
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const newAccessToken = signAccessToken(tokenPayload);
const newRefreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/logout
router.post(
"/logout",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { refreshToken } = z.object({ refreshToken: z.string().optional() }).parse(req.body);
if (refreshToken) {
await prisma.refreshToken.deleteMany({
where: { token: refreshToken, userId: authReq.auth.userId },
});
} else {
// Logout all devices
await prisma.refreshToken.deleteMany({
where: { userId: authReq.auth.userId },
});
}
res.json({ message: "Logged out successfully" });
} catch (err) {
next(err);
}
}
);
// GET /api/v1/auth/me
router.get(
"/me",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const user = await prisma.user.findUnique({
where: { id: authReq.auth.userId },
include: { role: true, vendor: true },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
});
} catch (err) {
next(err);
}
}
);
export default router;

View File

@@ -0,0 +1,90 @@
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 managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void;
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 [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);
}
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const cat = await prisma.category.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
});
if (!cat) throw new AppError(404, "NOT_FOUND", "Category not found");
res.json(cat);
} catch (err) {
next(err);
}
});
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = CategorySchema.parse(req.body);
const cat = await prisma.category.create({
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(cat);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.category.findFirst({
where: { id: req.params.id, 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);
}
});
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.category.findFirst({
where: { id: req.params.id, 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);
}
});
export default router;

View File

@@ -0,0 +1,25 @@
import { Router, Request, Response } from "express";
import { prisma } from "../lib/prisma.js";
const router = Router();
router.get("/health", async (_req: Request, res: Response) => {
let dbStatus = "ok";
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
dbStatus = "error";
}
const status = dbStatus === "ok" ? "ok" : "degraded";
res.status(status === "ok" ? 200 : 503).json({
status,
timestamp: new Date().toISOString(),
version: process.env.npm_package_version ?? "0.1.0",
services: {
database: dbStatus,
},
});
});
export default router;

View File

@@ -0,0 +1,116 @@
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 managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const ProductSchema = z.object({
name: z.string().min(1).max(200),
sku: z.string().max(100).optional(),
description: z.string().max(1000).optional(),
price: z.number().min(0),
categoryId: z.string().optional().nullable(),
taxId: z.string().optional().nullable(),
tags: z.string().max(500).optional(),
});
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 { categoryId, search } = req.query as { categoryId?: string; search?: string };
const where = {
vendorId: authReq.auth.vendorId,
...(categoryId ? { categoryId } : {}),
...(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.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const product = await prisma.product.findFirst({
where: { id: req.params.id, 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);
}
});
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = ProductSchema.parse(req.body);
const product = await prisma.product.create({
data: { ...body, vendorId: authReq.auth.vendorId },
include: { category: true, tax: true },
});
res.status(201).json(product);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.product.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
const body = ProductSchema.parse(req.body);
const product = await prisma.product.update({
where: { id: req.params.id },
data: { ...body, version: { increment: 1 } },
include: { category: true, tax: true },
});
res.json(product);
} catch (err) {
next(err);
}
});
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.product.findFirst({
where: { id: req.params.id, 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);
}
});
export default router;

View File

@@ -0,0 +1,91 @@
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 managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const TaxSchema = z.object({
name: z.string().min(1).max(100),
rate: z.number().min(0).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 [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);
}
});
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const tax = await prisma.tax.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
});
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
res.json(tax);
} catch (err) {
next(err);
}
});
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.create({
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(tax);
} catch (err) {
next(err);
}
});
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, 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);
}
});
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const existing = await prisma.tax.findFirst({
where: { id: req.params.id, 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);
}
});
export default router;

172
server/src/routes/users.ts Normal file
View File

@@ -0,0 +1,172 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import bcrypt from "bcryptjs";
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 managerUp = requireRole("owner", "manager") 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"> {
const { passwordHash: _, ...rest } = u;
return rest;
}
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
roleId: z.string().min(1),
});
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
roleId: z.string().min(1).optional(),
password: z.string().min(8).optional(),
});
// GET /api/v1/users
router.get("/", auth, managerUp, 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 [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { role: true },
}),
prisma.user.count({ where }),
]);
res.json(paginatedResponse(users.map(safe), total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// GET /api/v1/users/roles/list — must be before /:id
router.get("/roles/list", auth, async (_req: Request, res: Response, next: NextFunction) => {
try {
const roles = await prisma.role.findMany({ orderBy: { name: "asc" } });
res.json(roles);
} catch (err) {
next(err);
}
});
// GET /api/v1/users/:id
router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const user = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: { role: true },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json(safe(user));
} catch (err) {
next(err);
}
});
// POST /api/v1/users
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = CreateUserSchema.parse(req.body);
const existing = await prisma.user.findUnique({ where: { email: body.email } });
if (existing) throw new AppError(409, "CONFLICT", "Email already in use");
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");
}
const user = await prisma.user.create({
data: {
email: body.email,
passwordHash: await bcrypt.hash(body.password, 10),
name: body.name,
vendorId: authReq.auth.vendorId,
roleId: body.roleId,
},
include: { role: true },
});
res.status(201).json(safe(user));
} catch (err) {
next(err);
}
});
// PUT /api/v1/users/:id
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const body = UpdateUserSchema.parse(req.body);
const existing = await prisma.user.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: { role: true },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
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");
}
}
const updateData: Record<string, unknown> = {};
if (body.name) updateData.name = body.name;
if (body.roleId) updateData.roleId = body.roleId;
if (body.password) updateData.passwordHash = await bcrypt.hash(body.password, 10);
const user = await prisma.user.update({
where: { id: req.params.id },
data: updateData,
include: { role: true },
});
res.json(safe(user));
} catch (err) {
next(err);
}
});
// DELETE /api/v1/users/:id
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
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 },
});
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
await prisma.user.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,92 @@
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 ownerOnly = requireRole("owner") as unknown as (r: Request, s: Response, n: NextFunction) => void;
const VendorSchema = z.object({
name: z.string().min(1).max(100),
businessNum: z.string().max(50).optional(),
taxSettings: z.record(z.unknown()).optional(),
});
// GET /api/v1/vendors — list (owner sees their own vendor)
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 [data, total] = await Promise.all([
prisma.vendor.findMany({
where: { id: authReq.auth.vendorId },
skip,
take: limit,
orderBy: { createdAt: "desc" },
}),
prisma.vendor.count({ where: { id: authReq.auth.vendorId } }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// GET /api/v1/vendors/:id
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const vendor = await prisma.vendor.findFirst({
where: { id: req.params.id, ...(authReq.auth.roleName !== "owner" ? { id: authReq.auth.vendorId } : {}) },
});
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
res.json(vendor);
} catch (err) {
next(err);
}
});
// POST /api/v1/vendors
router.post("/", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const body = VendorSchema.parse(req.body);
const vendor = await prisma.vendor.create({
data: {
...body,
taxSettings: body.taxSettings ? JSON.stringify(body.taxSettings) : null,
},
});
res.status(201).json(vendor);
} catch (err) {
next(err);
}
});
// PUT /api/v1/vendors/:id
router.put("/:id", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
if (req.params.id !== authReq.auth.vendorId) {
throw new AppError(403, "FORBIDDEN", "Cannot modify another vendor");
}
const body = VendorSchema.parse(req.body);
const vendor = await prisma.vendor.update({
where: { id: req.params.id },
data: {
...body,
taxSettings: body.taxSettings ? JSON.stringify(body.taxSettings) : undefined,
},
});
res.json(vendor);
} catch (err) {
next(err);
}
});
export default router;

33
server/src/types/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Request } from "express";
export interface AuthPayload {
userId: string;
vendorId: string;
roleId: string;
roleName: string;
}
export interface AuthenticatedRequest extends Request {
auth: AuthPayload;
}
export interface ApiError {
code: string;
message: string;
details?: unknown;
}
export interface PaginationQuery {
page?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}