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:
50
server/src/app.ts
Normal file
50
server/src/app.ts
Normal 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
35
server/src/index.ts
Normal 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();
|
||||
27
server/src/lib/pagination.ts
Normal file
27
server/src/lib/pagination.ts
Normal 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
18
server/src/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
43
server/src/middleware/auth.ts
Normal file
43
server/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
51
server/src/middleware/errorHandler.ts
Normal file
51
server/src/middleware/errorHandler.ts
Normal 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
198
server/src/routes/auth.ts
Normal 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;
|
||||
90
server/src/routes/categories.ts
Normal file
90
server/src/routes/categories.ts
Normal 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;
|
||||
25
server/src/routes/health.ts
Normal file
25
server/src/routes/health.ts
Normal 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;
|
||||
116
server/src/routes/products.ts
Normal file
116
server/src/routes/products.ts
Normal 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;
|
||||
91
server/src/routes/taxes.ts
Normal file
91
server/src/routes/taxes.ts
Normal 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
172
server/src/routes/users.ts
Normal 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;
|
||||
92
server/src/routes/vendors.ts
Normal file
92
server/src/routes/vendors.ts
Normal 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
33
server/src/types/index.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user