Compare commits

...

4 Commits

Author SHA1 Message Date
31e539102b Vendor-assign events and scope event catalog to vendor
Some checks failed
CI / Server — typecheck & build (push) Has been cancelled
CI / Client — typecheck & build (push) Has been cancelled
CI / Docker build (smoke test) (push) Has been cancelled
- Add vendorWhereClause() helper: admin + ?vendorId= filters to that
  vendor; admin with no filter sees all; other roles locked to own
- Fix events GET / to use vendorWhereClause so vendor filter works
- EventFormModal: admin sees a Vendor picker when creating a new event,
  pre-populated from the active VendorFilter; POST includes ?vendorId=
- EventConfigPanel: scope /taxes and /products fetches to event.vendorId
  so only the event's vendor's catalog items are selectable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:48:44 -05:00
e1b1a82e07 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>
2026-03-21 07:59:58 -05:00
65eb405cf1 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>
2026-03-21 07:27:30 -05:00
c426b19b7c Add token auto-refresh, single-transaction endpoint, shift summary, and CI
- client/api/client.ts: shared refreshPromise prevents concurrent refresh races;
  dispatches auth:logout event when refresh fails
- client/context/AuthContext.tsx: listen for auth:logout to clear user state
- server/routes/transactions.ts: POST / real-time single transaction through
  payment abstraction (201 completed, 202 pending); GET /reports/shift shift
  window totals with averageTransaction, shiftOpen/shiftClose timestamps
- .github/workflows/ci.yml: server typecheck+build, client typecheck+build,
  Docker smoke-test on push/PR to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:06:59 -05:00
25 changed files with 1733 additions and 284 deletions

75
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
server:
name: Server — typecheck & build
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Typecheck
run: npx tsc --noEmit
- name: Build
run: npm run build
client:
name: Client — typecheck & build
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Build
run: npm run build
docker:
name: Docker build (smoke test)
runs-on: ubuntu-latest
needs: [server, client]
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build \
--build-arg NODE_ENV=production \
-t vendor-pos:ci .

View File

@@ -91,6 +91,7 @@ All endpoints live under `/api/v1`.
| POST | /transactions/:id/refund | manager+ | Refund a completed transaction |
| GET | /transactions/:id/receipt | Bearer | Structured receipt payload |
| GET | /transactions/reports/summary | manager+ | Revenue/tax/top-product summary |
| GET | /transactions/reports/shift | manager+ | Shift window totals + avg tx value |
---

View File

@@ -7,6 +7,7 @@ import UsersPage from "./pages/UsersPage";
import CatalogPage from "./pages/CatalogPage";
import VendorPage from "./pages/VendorPage";
import ReportsPage from "./pages/ReportsPage";
import EventsPage from "./pages/EventsPage";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -43,6 +44,7 @@ export default function App() {
>
<Route index element={<DashboardPage />} />
<Route path="catalog" element={<CatalogPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="vendor" element={<VendorPage />} />
<Route path="reports" element={<ReportsPage />} />

View File

@@ -1,9 +1,5 @@
const BASE = "/api/v1";
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
@@ -14,22 +10,67 @@ export function clearTokens() {
localStorage.removeItem("refreshToken");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes
let refreshPromise: Promise<string | null> | null = null;
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
clearTokens();
return null;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const doFetch = async (token: string | null) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${BASE}${path}`, { ...options, headers });
};
let res = await doFetch(getToken());
// On 401, attempt one token refresh then retry
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => { refreshPromise = null; });
}
const newToken = await refreshPromise;
if (!newToken) {
// Refresh failed — signal the app to go to login
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new Error("Session expired. Please sign in again.");
}
res = await doFetch(newToken);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = body?.error?.message ?? `HTTP ${res.status}`;
const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`;
throw new Error(message);
}
@@ -37,12 +78,9 @@ async function request<T>(
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -3,11 +3,12 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
const NAV = [
{ to: "/", label: "Dashboard", exact: true },
{ to: "/catalog", label: "Catalog" },
{ to: "/users", label: "Users" },
{ to: "/vendor", label: "Vendor" },
{ to: "/reports", label: "Reports" },
{ to: "/", label: "Dashboard", exact: true, roles: ["admin", "vendor", "user"] },
{ to: "/catalog", label: "Catalog", roles: ["admin", "vendor", "user"] },
{ to: "/events", label: "Events", roles: ["admin", "vendor"] },
{ to: "/users", label: "Users", roles: ["admin", "vendor"] },
{ to: "/vendor", label: "Vendor", roles: ["admin", "vendor"] },
{ to: "/reports", label: "Reports", roles: ["admin", "vendor"] },
];
export default function Layout() {
@@ -19,12 +20,14 @@ export default function Layout() {
navigate("/login", { replace: true });
};
const visibleNav = NAV.filter((item) => item.roles.includes(user?.role ?? ""));
return (
<div style={s.shell}>
<aside style={s.sidebar}>
<div style={s.brand}>POS Admin</div>
<nav style={s.nav}>
{NAV.map((item) => (
{visibleNav.map((item) => (
<NavLink
key={item.to}
to={item.to}

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
interface Vendor { id: string; name: string; }
interface Props {
vendorId: string;
onChange: (vendorId: string) => void;
}
/**
* Dropdown that lets admin users switch vendor context.
* Renders nothing for non-admin roles.
*/
export function VendorFilter({ vendorId, onChange }: Props) {
const { user } = useAuth();
const [vendors, setVendors] = useState<Vendor[]>([]);
useEffect(() => {
if (user?.role !== "admin") return;
api.get<{ data: Vendor[] }>("/vendors?limit=200")
.then((r) => setVendors(r.data))
.catch(console.error);
}, [user?.role]);
if (user?.role !== "admin" || vendors.length === 0) return null;
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>Vendor:</span>
<select
style={sel}
value={vendorId}
onChange={(e) => onChange(e.target.value)}
>
{vendors.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
);
}
const sel: React.CSSProperties = {
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "5px 8px",
fontSize: 13,
background: "var(--color-surface)",
cursor: "pointer",
minWidth: 140,
};

View File

@@ -41,6 +41,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} else {
setState({ user: null, loading: false });
}
// When the API layer exhausts its refresh retry, force logout
const onForcedLogout = () => setState({ user: null, loading: false });
window.addEventListener("auth:logout", onForcedLogout);
return () => window.removeEventListener("auth:logout", onForcedLogout);
}, [fetchMe]);
const login = async (email: string, password: string) => {

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
import { VendorFilter } from "../components/VendorFilter";
interface Category { id: string; name: string; }
interface Tax { id: string; name: string; rate: number; }
@@ -16,31 +18,36 @@ interface ApiList<T> { data: T[]; }
type Tab = "products" | "categories" | "taxes";
export default function CatalogPage() {
const { user } = useAuth();
const [tab, setTab] = useState<Tab>("products");
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<div style={tabs}>
{(["products", "categories", "taxes"] as Tab[]).map((t) => (
<button
key={t}
style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }}
onClick={() => setTab(t)}
>
<button key={t} style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "products" && <ProductsTab />}
{tab === "categories" && <CategoriesTab />}
{tab === "taxes" && <TaxesTab />}
{tab === "products" && <ProductsTab vendorId={vendorId} />}
{tab === "categories" && <CategoriesTab vendorId={vendorId} />}
{tab === "taxes" && <TaxesTab vendorId={vendorId} />}
</div>
);
}
function qs(vendorId: string) {
return vendorId ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
}
// ─── Products ──────────────────────────────────────────────────────────────
function ProductsTab() {
function ProductsTab({ vendorId }: { vendorId: string }) {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [taxes, setTaxes] = useState<Tax[]>([]);
@@ -53,16 +60,17 @@ function ProductsTab() {
const load = useCallback(async () => {
setLoading(true);
const q = qs(vendorId);
const [p, c, t] = await Promise.all([
api.get<ApiList<Product>>("/products"),
api.get<ApiList<Category>>("/categories"),
api.get<ApiList<Tax>>("/taxes"),
api.get<ApiList<Product>>(`/products${q}`),
api.get<ApiList<Category>>(`/categories${q}`),
api.get<ApiList<Tax>>(`/taxes${q}`),
]);
setProducts(p.data);
setCategories(c.data);
setTaxes(t.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -70,8 +78,7 @@ function ProductsTab() {
const openEdit = (p: Product) => {
setSelected(p);
setForm({ name: p.name, sku: p.sku ?? "", price: String(p.price), categoryId: p.category?.id ?? "", taxId: p.tax?.id ?? "", description: p.description ?? "" });
setError("");
setModal("edit");
setError(""); setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -80,8 +87,9 @@ function ProductsTab() {
setError("");
try {
const payload = { ...form, price: parseFloat(form.price), categoryId: form.categoryId || null, taxId: form.taxId || null };
if (modal === "create") await api.post("/products", payload);
else if (selected) await api.put(`/products/${selected.id}`, payload);
const q = qs(vendorId);
if (modal === "create") await api.post(`/products${q}`, payload);
else if (selected) await api.put(`/products/${selected.id}${q}`, payload);
setModal(null);
load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
@@ -110,9 +118,7 @@ function ProductsTab() {
return (
<>
<div style={{ marginBottom: 16 }}>
<Btn onClick={openCreate}>+ Add Product</Btn>
</div>
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Product</Btn></div>
<Table columns={columns} data={products} keyField="id" loading={loading} />
{modal && (
<Modal title={modal === "create" ? "Add Product" : "Edit Product"} onClose={() => setModal(null)}>
@@ -122,12 +128,8 @@ function ProductsTab() {
<input style={inputStyle} value={form.name} onChange={f("name", setForm)} required />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="SKU">
<input style={inputStyle} value={form.sku} onChange={f("sku", setForm)} />
</FormField>
<FormField label="Price" required>
<input style={inputStyle} type="number" min="0" step="0.01" value={form.price} onChange={f("price", setForm)} required />
</FormField>
<FormField label="SKU"><input style={inputStyle} value={form.sku} onChange={f("sku", setForm)} /></FormField>
<FormField label="Price" required><input style={inputStyle} type="number" min="0" step="0.01" value={form.price} onChange={f("price", setForm)} required /></FormField>
</div>
<FormField label="Description">
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} value={form.description} onChange={f("description", setForm)} />
@@ -158,7 +160,7 @@ function ProductsTab() {
}
// ─── Categories ────────────────────────────────────────────────────────────
function CategoriesTab() {
function CategoriesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
@@ -169,10 +171,10 @@ function CategoriesTab() {
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Category>>("/categories");
const res = await api.get<ApiList<Category>>(`/categories${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -181,13 +183,12 @@ function CategoriesTab() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaving(true); setError("");
try {
if (modal === "create") await api.post("/categories", { name });
else if (selected) await api.put(`/categories/${selected.id}`, { name });
setModal(null);
load();
const q = qs(vendorId);
if (modal === "create") await api.post(`/categories${q}`, { name });
else if (selected) await api.put(`/categories/${selected.id}${q}`, { name });
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
@@ -225,7 +226,7 @@ function CategoriesTab() {
}
// ─── Taxes ─────────────────────────────────────────────────────────────────
function TaxesTab() {
function TaxesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Tax[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
@@ -236,10 +237,10 @@ function TaxesTab() {
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Tax>>("/taxes");
const res = await api.get<ApiList<Tax>>(`/taxes${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -248,14 +249,13 @@ function TaxesTab() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaving(true); setError("");
try {
const payload = { name: form.name, rate: parseFloat(form.rate) };
if (modal === "create") await api.post("/taxes", payload);
else if (selected) await api.put(`/taxes/${selected.id}`, payload);
setModal(null);
load();
const q = qs(vendorId);
if (modal === "create") await api.post(`/taxes${q}`, payload);
else if (selected) await api.put(`/taxes/${selected.id}${q}`, payload);
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
@@ -311,20 +311,6 @@ const errStyle: React.CSSProperties = {
color: "var(--color-danger)", borderRadius: "var(--radius)",
padding: "10px 12px", fontSize: 13, marginBottom: 16,
};
const tabs: React.CSSProperties = {
display: "flex", gap: 4, marginBottom: 20,
borderBottom: "1px solid var(--color-border)", paddingBottom: 0,
};
const tabBtn: React.CSSProperties = {
padding: "8px 16px", background: "none", border: "none",
borderBottom: "2px solid transparent", cursor: "pointer",
fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)",
marginBottom: -1,
};
const tabBtnActive: React.CSSProperties = {
color: "var(--color-primary)",
borderBottomColor: "var(--color-primary)",
};
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)" };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };

View File

@@ -1,15 +1,18 @@
import React from "react";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
const CARDS = [
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog" },
{ label: "Users", desc: "Manage roles and access", to: "/users" },
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor" },
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports" },
const ALL_CARDS = [
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog", roles: ["admin", "vendor", "user"] },
{ label: "Events", desc: "Events, tax overrides, reports", to: "/events", roles: ["admin", "vendor"] },
{ label: "Users", desc: "Manage roles and access", to: "/users", roles: ["admin", "vendor"] },
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor", roles: ["admin", "vendor"] },
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports", roles: ["admin", "vendor"] },
];
export default function DashboardPage() {
const { user } = useAuth();
const cards = ALL_CARDS.filter((c) => c.roles.includes(user?.role ?? ""));
return (
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
@@ -18,7 +21,7 @@ export default function DashboardPage() {
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
/>
<div style={grid}>
{CARDS.map((card) => (
{cards.map((card) => (
<a key={card.label} href={card.to} style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{card.label}</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13 }}>{card.desc}</div>

View File

@@ -0,0 +1,505 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
import { VendorFilter } from "../components/VendorFilter";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, Btn, inputStyle } from "../components/FormField";
// ─── Types ──────────────────────────────────────────────────────────────────
interface Vendor { id: string; name: string; }
interface EventTaxOverride {
id: string;
taxId: string;
rate: number;
tax: { name: string; rate: number };
}
interface EventProductItem {
id: string;
productId: string;
product: { id: string; name: string; price: number; sku?: string };
}
interface Event {
id: string;
name: string;
description?: string;
startsAt: string;
endsAt: string;
isActive: boolean;
vendorId: string;
vendor?: Vendor;
taxOverrides?: EventTaxOverride[];
products?: EventProductItem[];
_count?: { products: number; taxOverrides: number; transactions: number };
}
interface Tax { id: string; name: string; rate: number; }
interface Product { id: string; name: string; price: number; sku?: string; }
interface ApiList<T> { data: T[]; pagination: { total: number } }
interface EventSummary {
event: { id: string; name: string; startsAt: string; endsAt: string };
totals: { revenue: number; tax: number; discounts: number; transactionCount: number; averageTransaction: number };
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function EventsPage() {
const { user } = useAuth();
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Event | null>(null);
const [detail, setDetail] = useState<Event | null>(null);
const [detailView, setDetailView] = useState<"config" | "report">("config");
const load = useCallback(async () => {
setLoading(true);
try {
const q = vendorId && user?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}&limit=50` : "?limit=50";
const res = await api.get<ApiList<Event>>(`/events${q}`);
setEvents(res.data);
setTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [vendorId, user?.role]);
useEffect(() => { load(); }, [load]);
const openDetail = async (ev: Event) => {
try {
const full = await api.get<Event>(`/events/${ev.id}`);
setDetail(full);
setDetailView("config");
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this event? This cannot be undone.")) return;
await api.delete(`/events/${id}`);
load();
};
const columns = [
{ key: "name", header: "Name", render: (ev: Event) => ev.name },
{
key: "dates", header: "Dates", render: (ev: Event) =>
`${fmtDate(ev.startsAt)}${fmtDate(ev.endsAt)}`
},
{ key: "vendor", header: "Vendor", render: (ev: Event) => ev.vendor?.name ?? ev.vendorId },
{
key: "status", header: "Status", render: (ev: Event) => (
<span style={ev.isActive ? activeBadge : inactiveBadge}>
{ev.isActive ? "Active" : "Inactive"}
</span>
)
},
{
key: "counts", header: "Products / Taxes / Txns", render: (ev: Event) =>
`${ev._count?.products ?? 0} / ${ev._count?.taxOverrides ?? 0} / ${ev._count?.transactions ?? 0}`
},
{
key: "actions", header: "", render: (ev: Event) => (
<div style={{ display: "flex", gap: 6 }}>
<Btn size="sm" onClick={() => openDetail(ev)}>View</Btn>
<Btn size="sm" onClick={() => { setEditing(ev); setShowForm(true); }}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(ev.id)}>Delete</Btn>
</div>
)
},
];
return (
<div style={{ padding: "32px 28px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader
title="Events"
subtitle={`${total} event${total !== 1 ? "s" : ""}`}
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>}
/>
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />
{showForm && (
<EventFormModal
event={editing}
defaultVendorId={vendorId}
onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); load(); }}
/>
)}
{detail && (
<EventDetailModal
event={detail}
view={detailView}
onViewChange={setDetailView}
onClose={() => setDetail(null)}
onRefresh={() => openDetail(detail)}
/>
)}
</div>
);
}
// ─── Event Form Modal ────────────────────────────────────────────────────────
function EventFormModal({ event, defaultVendorId, onClose, onSaved }: {
event: Event | null;
defaultVendorId?: string;
onClose: () => void;
onSaved: () => void;
}) {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const [name, setName] = useState(event?.name ?? "");
const [description, setDescription] = useState(event?.description ?? "");
const [startsAt, setStartsAt] = useState(event ? toDatetimeLocal(event.startsAt) : "");
const [endsAt, setEndsAt] = useState(event ? toDatetimeLocal(event.endsAt) : "");
const [isActive, setIsActive] = useState(event?.isActive ?? true);
const [selectedVendorId, setSelectedVendorId] = useState(
event?.vendorId ?? defaultVendorId ?? ""
);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isAdmin && !event) {
api.get<{ data: Vendor[] }>("/vendors?limit=200")
.then((r) => setVendors(r.data))
.catch(console.error);
}
}, [isAdmin, event]);
const save = async () => {
if (isAdmin && !event && !selectedVendorId) {
setError("Please select a vendor for this event.");
return;
}
setSaving(true);
setError("");
try {
const body = {
name, description: description || undefined,
startsAt: new Date(startsAt).toISOString(),
endsAt: new Date(endsAt).toISOString(),
isActive,
};
if (event) {
await api.put(`/events/${event.id}`, body);
} else {
const q = isAdmin && selectedVendorId ? `?vendorId=${encodeURIComponent(selectedVendorId)}` : "";
await api.post(`/events${q}`, body);
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
return (
<Modal title={event ? "Edit Event" : "New Event"} onClose={onClose}>
{isAdmin && !event && (
<FormField label="Vendor" required>
<select style={inputStyle} value={selectedVendorId}
onChange={(e) => setSelectedVendorId(e.target.value)} required>
<option value="">Select vendor</option>
{vendors.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</FormField>
)}
<FormField label="Name">
<input style={input} value={name} onChange={(e) => setName(e.target.value)} />
</FormField>
<FormField label="Description">
<textarea style={{ ...input, height: 64, resize: "vertical" }} value={description}
onChange={(e) => setDescription(e.target.value)} />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="Starts At">
<input type="datetime-local" style={input} value={startsAt}
onChange={(e) => setStartsAt(e.target.value)} />
</FormField>
<FormField label="Ends At">
<input type="datetime-local" style={input} value={endsAt}
onChange={(e) => setEndsAt(e.target.value)} />
</FormField>
</div>
<FormField label="">
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
<span style={{ fontSize: 14 }}>Active</span>
</label>
</FormField>
{error && <div style={errStyle}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
</div>
</Modal>
);
}
// ─── Event Detail Modal ──────────────────────────────────────────────────────
function EventDetailModal({ event, view, onViewChange, onClose, onRefresh }: {
event: Event;
view: "config" | "report";
onViewChange: (v: "config" | "report") => void;
onClose: () => void;
onRefresh: () => void;
}) {
return (
<Modal title={event.name} onClose={onClose} width={760}>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 16 }}>
{fmtDate(event.startsAt)} {fmtDate(event.endsAt)}
{event.description && <span style={{ marginLeft: 12 }}>· {event.description}</span>}
</div>
<div style={tabs}>
{(["config", "report"] as const).map((t) => (
<button key={t} type="button"
style={{ ...tabBtn, ...(view === t ? tabBtnActive : {}) }}
onClick={() => onViewChange(t)}>
{t === "config" ? "Configuration" : "Reports"}
</button>
))}
</div>
{view === "config" && (
<EventConfigPanel event={event} onRefresh={onRefresh} />
)}
{view === "report" && (
<EventReportPanel eventId={event.id} />
)}
</Modal>
);
}
// ─── Event Config Panel ──────────────────────────────────────────────────────
function EventConfigPanel({ event, onRefresh }: { event: Event; onRefresh: () => void }) {
const [taxes, setTaxes] = useState<Tax[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [taxId, setTaxId] = useState("");
const [taxRate, setTaxRate] = useState("");
const [productId, setProductId] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
const q = `?vendorId=${encodeURIComponent(event.vendorId)}&limit=200`;
Promise.all([
api.get<ApiList<Tax>>(`/taxes${q}`).then((r) => setTaxes(r.data)),
api.get<ApiList<Product>>(`/products${q}`).then((r) => setProducts(r.data)),
]).catch(console.error);
}, [event.vendorId]);
const addTax = async () => {
setErr("");
try {
await api.put(`/events/${event.id}/taxes`, { taxId, rate: parseFloat(taxRate) });
setTaxId(""); setTaxRate("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeTax = async (tId: string) => {
await api.delete(`/events/${event.id}/taxes/${tId}`);
onRefresh();
};
const addProduct = async () => {
setErr("");
try {
await api.post(`/events/${event.id}/products`, { productId });
setProductId("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeProduct = async (pId: string) => {
await api.delete(`/events/${event.id}/products/${pId}`);
onRefresh();
};
return (
<div>
{err && <div style={errStyle}>{err}</div>}
{/* Tax overrides */}
<div style={sectionTitle}>Tax Rate Overrides</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Override the default tax rate for this event. Empty = use vendor defaults.
</div>
{(event.taxOverrides ?? []).map((o) => (
<div key={o.id} style={listRow}>
<span style={{ flex: 1 }}>{o.tax.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>
{o.tax.rate}% <strong>{o.rate}%</strong>
</span>
<Btn size="sm" variant="danger" onClick={() => removeTax(o.taxId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8, alignItems: "center" }}>
<select style={input} value={taxId} onChange={(e) => setTaxId(e.target.value)}>
<option value="">Select tax</option>
{taxes.map((t) => <option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>)}
</select>
<input style={{ ...input, width: 90 }} placeholder="Rate %" type="number" min="0" max="100"
value={taxRate} onChange={(e) => setTaxRate(e.target.value)} />
<Btn onClick={addTax} disabled={!taxId || !taxRate}>Add</Btn>
</div>
{/* Product allowlist */}
<div style={{ ...sectionTitle, marginTop: 24 }}>Product Allowlist</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Restrict which products are available at this event. Empty = all vendor products available.
</div>
{(event.products ?? []).map((ep) => (
<div key={ep.id} style={listRow}>
<span style={{ flex: 1 }}>{ep.product.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>${ep.product.price.toFixed(2)}</span>
<Btn size="sm" variant="danger" onClick={() => removeProduct(ep.productId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<select style={input} value={productId} onChange={(e) => setProductId(e.target.value)}>
<option value="">Add product</option>
{products
.filter((p) => !(event.products ?? []).find((ep) => ep.productId === p.id))
.map((p) => <option key={p.id} value={p.id}>{p.name} ${p.price.toFixed(2)}</option>)}
</select>
<Btn onClick={addProduct} disabled={!productId}>Add</Btn>
</div>
</div>
);
}
// ─── Event Report Panel ──────────────────────────────────────────────────────
function EventReportPanel({ eventId }: { eventId: string }) {
const [summary, setSummary] = useState<EventSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<EventSummary>(`/events/${eventId}/reports/summary`)
.then(setSummary)
.catch(console.error)
.finally(() => setLoading(false));
}, [eventId]);
if (loading) return <div style={{ color: "var(--color-text-muted)" }}>Loading</div>;
if (!summary) return <div style={{ color: "var(--color-text-muted)" }}>No data</div>;
const { totals, byPaymentMethod, topProducts } = summary;
return (
<div>
<div style={statGrid}>
<StatCard label="Revenue" value={`$${totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(totals.transactionCount)} />
<StatCard label="Avg Transaction" value={`$${totals.averageTransaction.toFixed(2)}`} />
<StatCard label="Tax Collected" value={`$${totals.tax.toFixed(2)}`} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: byPaymentMethod.map((m) => (
<div key={m.method} style={listRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>({m.count})</span>
</div>
))
}
</div>
<div style={card}>
<div style={cardTitle}>Top Products</div>
{topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: topProducts.map((p, i) => (
<div key={p.productId} style={listRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 8 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function toDatetimeLocal(iso: string) {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const input: React.CSSProperties = {
width: "100%", border: "1px solid var(--color-border)", borderRadius: "var(--radius)",
padding: "7px 10px", fontSize: 14, boxSizing: "border-box",
};
const errStyle: React.CSSProperties = { color: "#dc2626", fontSize: 13, marginBottom: 8 };
const activeBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f0fdf4", color: "#166534" };
const inactiveBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f1f5f9", color: "#64748b" };
const sectionTitle: React.CSSProperties = { fontWeight: 600, fontSize: 14, marginBottom: 6 };
const listRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)" };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 16, marginBottom: 20 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
import { VendorFilter } from "../components/VendorFilter";
interface Role { id: string; name: string; }
interface User {
@@ -11,14 +13,17 @@ interface User {
name: string;
email: string;
role: Role;
vendor?: { id: string; name: string };
createdAt: string;
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; limit: number; totalPages: number }; }
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "" };
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "", vendorId: "" };
export default function UsersPage() {
const { user: me } = useAuth();
const [vendorId, setVendorId] = useState(me?.vendorId ?? "");
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
@@ -31,8 +36,9 @@ export default function UsersPage() {
const load = useCallback(async () => {
setLoading(true);
try {
const q = vendorId && me?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
const [usersRes, rolesRes] = await Promise.all([
api.get<ApiList<User>>("/users"),
api.get<ApiList<User>>(`/users${q}`),
api.get<Role[]>("/users/roles/list"),
]);
setUsers(usersRes.data);
@@ -42,13 +48,13 @@ export default function UsersPage() {
} finally {
setLoading(false);
}
}, []);
}, [vendorId, me?.role]);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm(EMPTY_FORM);
setForm({ ...EMPTY_FORM, vendorId });
setError("");
setModal("create");
};
@@ -66,7 +72,7 @@ export default function UsersPage() {
setError("");
try {
if (modal === "create") {
await api.post("/users", form);
await api.post("/users", { ...form, vendorId: form.vendorId || undefined });
} else if (selected) {
const patch: Record<string, string> = { name: form.name, roleId: form.roleId };
if (form.password) patch.password = form.password;
@@ -91,9 +97,12 @@ export default function UsersPage() {
}
};
const isAdmin = me?.role === "admin";
const columns = [
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
...(isAdmin ? [{ key: "vendor", header: "Vendor", render: (u: User) => u.vendor?.name ?? "—" }] : []),
{
key: "role",
header: "Role",
@@ -120,11 +129,14 @@ export default function UsersPage() {
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader
title="Users"
subtitle="Manage staff accounts and roles"
action={<Btn onClick={openCreate}>+ Add User</Btn>}
/>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 16 }}>
<PageHeader
title="Users"
subtitle="Manage staff accounts and roles"
action={<Btn onClick={openCreate}>+ Add User</Btn>}
/>
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<Table columns={columns} data={users} keyField="id" loading={loading} />
{modal && (
@@ -166,9 +178,9 @@ export default function UsersPage() {
function roleBadge(role: string): React.CSSProperties {
const colors: Record<string, { bg: string; color: string }> = {
owner: { bg: "#fef3c7", color: "#92400e" },
manager: { bg: "#dbeafe", color: "#1e3a8a" },
cashier: { bg: "#f0fdf4", color: "#14532d" },
admin: { bg: "#fef3c7", color: "#92400e" },
vendor: { bg: "#dbeafe", color: "#1e3a8a" },
user: { bg: "#f0fdf4", color: "#14532d" },
};
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
return {

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Vendor {
@@ -12,17 +15,131 @@ interface Vendor {
updatedAt: string;
}
export default function VendorPage() {
interface ApiList<T> { data: T[]; pagination: { total: number } }
const EMPTY_FORM = { name: "", businessNum: "" };
// ─── Admin view: list all vendors, create/edit/delete ────────────────────────
function AdminVendorPage() {
const [vendors, setVendors] = useState<Vendor[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<Vendor | null>(null);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ApiList<Vendor>>("/vendors?limit=100");
setVendors(res.data);
setTotal(res.pagination.total);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm(EMPTY_FORM);
setError("");
setModal("create");
};
const openEdit = (v: Vendor) => {
setSelected(v);
setForm({ name: v.name, businessNum: v.businessNum ?? "" });
setError("");
setModal("edit");
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this vendor? All associated data will be removed.")) return;
try {
await api.delete(`/vendors/${id}`);
load();
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed");
}
};
const handleSave = async () => {
setSaving(true);
setError("");
try {
if (modal === "edit" && selected) {
await api.put(`/vendors/${selected.id}`, form);
} else {
await api.post("/vendors", form);
}
setModal(null);
load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally { setSaving(false); }
};
const columns = [
{ key: "name", header: "Name", render: (v: Vendor) => v.name },
{ key: "businessNum", header: "Business No.", render: (v: Vendor) => v.businessNum ?? "—" },
{ key: "createdAt", header: "Created", render: (v: Vendor) => new Date(v.createdAt).toLocaleDateString() },
{
key: "actions", header: "", render: (v: Vendor) => (
<div style={{ display: "flex", gap: 6 }}>
<Btn size="sm" onClick={() => openEdit(v)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(v.id)}>Delete</Btn>
</div>
)
},
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader
title="Vendors"
subtitle={`${total} vendor${total !== 1 ? "s" : ""}`}
action={<Btn onClick={openCreate}>+ New Vendor</Btn>}
/>
<Table columns={columns} data={vendors} keyField="id" loading={loading} emptyText="No vendors found." />
{modal && (
<Modal title={modal === "create" ? "New Vendor" : "Edit Vendor"} onClose={() => setModal(null)}>
<FormField label="Business Name" required>
<input style={inputStyle} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
</FormField>
<FormField label="Business Number / ABN">
<input style={inputStyle} value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
</FormField>
{error && <div style={errStyle}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
<Btn onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
</div>
</Modal>
)}
</div>
);
}
// ─── Vendor/user view: own settings only ────────────────────────────────────
function OwnVendorPage() {
const [vendor, setVendor] = useState<Vendor | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [form, setForm] = useState({ name: "", businessNum: "" });
const [form, setForm] = useState(EMPTY_FORM);
useEffect(() => {
api
.get<{ data: Vendor[] }>("/vendors")
api.get<ApiList<Vendor>>("/vendors")
.then((res) => {
const v = res.data[0] ?? null;
setVendor(v);
@@ -43,9 +160,7 @@ export default function VendorPage() {
setEditing(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
} finally { setSaving(false); }
};
if (loading) return <div style={{ padding: 32 }}>Loading</div>;
@@ -56,38 +171,23 @@ export default function VendorPage() {
<PageHeader
title="Vendor Settings"
subtitle="Business details and configuration"
action={
!editing && (
<Btn onClick={() => setEditing(true)}>Edit</Btn>
)
}
action={!editing && <Btn onClick={() => setEditing(true)}>Edit</Btn>}
/>
{editing ? (
<form onSubmit={handleSave} style={card}>
{error && <div style={errStyle}>{error}</div>}
<FormField label="Business Name" required>
<input
style={inputStyle}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
<input style={inputStyle} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
</FormField>
<FormField label="Business Number / ABN">
<input
style={inputStyle}
value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))}
/>
<input style={inputStyle} value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
</FormField>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Btn type="submit" disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</Btn>
<Btn variant="ghost" onClick={() => setEditing(false)}>
Cancel
</Btn>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save changes"}</Btn>
<Btn variant="ghost" onClick={() => setEditing(false)}>Cancel</Btn>
</div>
</form>
) : (
@@ -102,6 +202,15 @@ export default function VendorPage() {
);
}
// ─── Root export — branches on role ─────────────────────────────────────────
export default function VendorPage() {
const { user } = useAuth();
return user?.role === "admin" ? <AdminVendorPage /> : <OwnVendorPage />;
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
function Row({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}>

View File

@@ -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

View File

@@ -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])
}

View File

@@ -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,
},
});

View File

@@ -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") {

View File

@@ -0,0 +1,35 @@
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;
}
/**
* Returns a Prisma `where` fragment for vendor filtering on list queries.
* - Admin with ?vendorId= → filter to that vendor
* - Admin without ?vendorId= → no filter (sees all)
* - Other roles → always locked to own vendorId
*/
export function vendorWhereClause(
authReq: AuthenticatedRequest,
query: Record<string, unknown> = {}
): Record<string, string> {
if (authReq.auth.roleName === "admin") {
if (typeof query.vendorId === "string" && query.vendorId) {
return { vendorId: query.vendorId };
}
return {};
}
return { vendorId: authReq.auth.vendorId };
}

View File

@@ -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) {

View File

@@ -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 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),
});
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, 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 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, 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 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, 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";
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;

371
server/src/routes/events.ts Normal file
View File

@@ -0,0 +1,371 @@
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";
import { resolveVendorId, vendorWhereClause } 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 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 = vendorWhereClause(authReq, req.query as Record<string, unknown>);
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");
}
const targetVendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
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;

View File

@@ -5,10 +5,11 @@ 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 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),
@@ -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, 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 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, 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 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, managerUp, async (req: Request, res: Response, next: Ne
include: { category: true, tax: true },
});
res.json(product);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
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";
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;

View File

@@ -5,10 +5,11 @@ 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 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),
@@ -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, 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 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, 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 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, 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";
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;

View File

@@ -6,10 +6,11 @@ import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { logger } from "../lib/logger.js";
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 ──────────────────────────────────────────────────────────────
@@ -33,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(),
});
@@ -95,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) => ({
@@ -131,9 +134,101 @@ router.post("/batch", auth, async (req: Request, res: Response, next: NextFuncti
}
});
// ─── POST /api/v1/transactions (single, real-time) ────────────────────────
// Used by the Android POS for live transactions. Runs through the payment
// abstraction before persisting; returns immediately with success/failure.
const SingleTransactionSchema = z.object({
idempotencyKey: z.string().min(1).max(200),
paymentMethod: z.enum(["cash", "card"]),
subtotal: z.number().min(0),
taxTotal: z.number().min(0),
discountTotal: z.number().min(0),
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) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId, userId } = authReq.auth;
const body = SingleTransactionSchema.parse(req.body);
// Idempotency guard
const existing = await prisma.transaction.findUnique({
where: { idempotencyKey: body.idempotencyKey },
});
if (existing) {
res.json(existing);
return;
}
// Run payment through provider abstraction
const paymentResult = await processPayment({
method: body.paymentMethod,
amount: body.total,
currency: process.env.CURRENCY ?? "AUD",
reference: body.idempotencyKey,
});
const status = paymentResult.status === "completed" ? "completed"
: paymentResult.status === "pending" ? "pending"
: "failed";
const tx = await prisma.transaction.create({
data: {
idempotencyKey: body.idempotencyKey,
vendorId,
userId,
status,
paymentMethod: body.paymentMethod,
subtotal: body.subtotal,
taxTotal: body.taxTotal,
discountTotal: body.discountTotal,
total: body.total,
notes: body.notes,
...(body.eventId ? { eventId: body.eventId } : {}),
items: {
create: body.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
},
},
include: { items: true },
});
logger.info("transaction.created", {
id: tx.id,
status,
total: tx.total,
providerRef: paymentResult.providerRef,
});
res.status(status === "completed" ? 201 : 202).json({
...tx,
payment: {
status: paymentResult.status,
providerRef: paymentResult.providerRef,
errorCode: paymentResult.errorCode,
errorMessage: paymentResult.errorMessage,
},
});
} catch (err) {
next(err);
}
});
// ─── 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;
@@ -173,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({
@@ -193,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;
@@ -259,10 +354,79 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon
}
});
// ─── GET /api/v1/transactions/reports/shift ───────────────────────────────
// Totals for a single shift window (e.g. today). Same shape as summary but
// 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, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { from, to } = req.query as { from?: string; to?: string };
const dateFilter = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
const where = {
vendorId,
status: "completed",
...(from || to ? { createdAt: dateFilter } : {}),
};
const [totals, byPayment, firstTx, lastTx] = 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.transaction.findFirst({
where,
orderBy: { createdAt: "asc" },
select: { createdAt: true },
}),
prisma.transaction.findFirst({
where,
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
res.json({
period: { from: from ?? null, to: to ?? null },
shiftOpen: firstTx?.createdAt ?? null,
shiftClose: lastTx?.createdAt ?? null,
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,
})),
});
} catch (err) {
next(err);
}
});
// ─── 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({

View File

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

View File

@@ -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,50 @@ 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) => {
// 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 {
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 },