Rename roles, add multi-vendor support, and Events system
Roles: owner→admin, manager→vendor, cashier→user across all routes, seed, and client UI. Role badge colours updated in UsersPage. Multi-vendor: - GET /vendors and GET /users now return all records for admin role; vendor/user roles remain scoped to their vendorId - POST /users: admin can specify vendorId to assign user to any vendor - vendors/users now include vendor name in responses for admin context Events (new): - Prisma schema: Event, EventTax, EventProduct models; Transaction.eventId - POST/GET/PUT/DELETE /api/v1/events — full CRUD, vendor-scoped - PUT /events/:id/taxes + DELETE — upsert/remove per-event tax rate overrides - POST/GET/DELETE /events/:id/products — product allowlist (empty=all) - GET /events/:id/transactions — paginated list scoped to event - GET /events/:id/reports/summary — revenue, avg tx, top products for event - Transactions: eventId accepted in both single POST and batch POST - Catalog sync: active/upcoming events included in /catalog/sync response Client: - Layout nav filtered by role (user role sees Catalog only) - Dashboard cards filtered by role - Events page: list, create/edit modal, detail modal with Configuration (tax overrides + product allowlist) and Reports tabs DB: DATABASE_URL updated to file:./prisma/dev.db in .env.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import UsersPage from "./pages/UsersPage";
|
|||||||
import CatalogPage from "./pages/CatalogPage";
|
import CatalogPage from "./pages/CatalogPage";
|
||||||
import VendorPage from "./pages/VendorPage";
|
import VendorPage from "./pages/VendorPage";
|
||||||
import ReportsPage from "./pages/ReportsPage";
|
import ReportsPage from "./pages/ReportsPage";
|
||||||
|
import EventsPage from "./pages/EventsPage";
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -43,6 +44,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="catalog" element={<CatalogPage />} />
|
<Route path="catalog" element={<CatalogPage />} />
|
||||||
|
<Route path="events" element={<EventsPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="vendor" element={<VendorPage />} />
|
<Route path="vendor" element={<VendorPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: "/", label: "Dashboard", exact: true },
|
{ to: "/", label: "Dashboard", exact: true, roles: ["admin", "vendor", "user"] },
|
||||||
{ to: "/catalog", label: "Catalog" },
|
{ to: "/catalog", label: "Catalog", roles: ["admin", "vendor", "user"] },
|
||||||
{ to: "/users", label: "Users" },
|
{ to: "/events", label: "Events", roles: ["admin", "vendor"] },
|
||||||
{ to: "/vendor", label: "Vendor" },
|
{ to: "/users", label: "Users", roles: ["admin", "vendor"] },
|
||||||
{ to: "/reports", label: "Reports" },
|
{ to: "/vendor", label: "Vendor", roles: ["admin", "vendor"] },
|
||||||
|
{ to: "/reports", label: "Reports", roles: ["admin", "vendor"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -19,12 +20,14 @@ export default function Layout() {
|
|||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleNav = NAV.filter((item) => item.roles.includes(user?.role ?? ""));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.shell}>
|
<div style={s.shell}>
|
||||||
<aside style={s.sidebar}>
|
<aside style={s.sidebar}>
|
||||||
<div style={s.brand}>POS Admin</div>
|
<div style={s.brand}>POS Admin</div>
|
||||||
<nav style={s.nav}>
|
<nav style={s.nav}>
|
||||||
{NAV.map((item) => (
|
{visibleNav.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
const CARDS = [
|
const ALL_CARDS = [
|
||||||
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog" },
|
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog", roles: ["admin", "vendor", "user"] },
|
||||||
{ label: "Users", desc: "Manage roles and access", to: "/users" },
|
{ label: "Events", desc: "Events, tax overrides, reports", to: "/events", roles: ["admin", "vendor"] },
|
||||||
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor" },
|
{ label: "Users", desc: "Manage roles and access", to: "/users", roles: ["admin", "vendor"] },
|
||||||
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports" },
|
{ 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() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const cards = ALL_CARDS.filter((c) => c.roles.includes(user?.role ?? ""));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
|
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
|
||||||
@@ -18,7 +21,7 @@ export default function DashboardPage() {
|
|||||||
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
|
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
|
||||||
/>
|
/>
|
||||||
<div style={grid}>
|
<div style={grid}>
|
||||||
{CARDS.map((card) => (
|
{cards.map((card) => (
|
||||||
<a key={card.label} href={card.to} style={cardStyle}>
|
<a key={card.label} href={card.to} style={cardStyle}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{card.label}</div>
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{card.label}</div>
|
||||||
<div style={{ color: "var(--color-text-muted)", fontSize: 13 }}>{card.desc}</div>
|
<div style={{ color: "var(--color-text-muted)", fontSize: 13 }}>{card.desc}</div>
|
||||||
|
|||||||
466
client/src/pages/EventsPage.tsx
Normal file
466
client/src/pages/EventsPage.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Table } from "../components/Table";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { FormField, Btn } 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 [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 res = await api.get<ApiList<Event>>("/events?limit=50");
|
||||||
|
setEvents(res.data);
|
||||||
|
setTotal(res.pagination.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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" }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Events"
|
||||||
|
subtitle={`${total} event${total !== 1 ? "s" : ""}`}
|
||||||
|
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<EventFormModal
|
||||||
|
event={editing}
|
||||||
|
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, onClose, onSaved }: {
|
||||||
|
event: Event | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
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 [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
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 {
|
||||||
|
await api.post("/events", 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}>
|
||||||
|
<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(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<ApiList<Tax>>("/taxes?limit=100").then((r) => setTaxes(r.data)),
|
||||||
|
api.get<ApiList<Product>>("/products?limit=200").then((r) => setProducts(r.data)),
|
||||||
|
]).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -166,9 +166,9 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
function roleBadge(role: string): React.CSSProperties {
|
function roleBadge(role: string): React.CSSProperties {
|
||||||
const colors: Record<string, { bg: string; color: string }> = {
|
const colors: Record<string, { bg: string; color: string }> = {
|
||||||
owner: { bg: "#fef3c7", color: "#92400e" },
|
admin: { bg: "#fef3c7", color: "#92400e" },
|
||||||
manager: { bg: "#dbeafe", color: "#1e3a8a" },
|
vendor: { bg: "#dbeafe", color: "#1e3a8a" },
|
||||||
cashier: { bg: "#f0fdf4", color: "#14532d" },
|
user: { bg: "#f0fdf4", color: "#14532d" },
|
||||||
};
|
};
|
||||||
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
|
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
DATABASE_URL=file:./dev.db
|
DATABASE_URL=file:./prisma/dev.db
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ model Vendor {
|
|||||||
products Product[]
|
products Product[]
|
||||||
taxes Tax[]
|
taxes Tax[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
events Event[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique // cashier | manager | owner
|
name String @unique // admin | vendor | user
|
||||||
|
|
||||||
users User[]
|
users User[]
|
||||||
}
|
}
|
||||||
@@ -74,8 +75,9 @@ model Tax {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
products Product[]
|
products Product[]
|
||||||
|
eventOverrides EventTax[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
@@ -96,6 +98,7 @@ model Product {
|
|||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
tax Tax? @relation(fields: [taxId], references: [id])
|
tax Tax? @relation(fields: [taxId], references: [id])
|
||||||
transactionItems TransactionItem[]
|
transactionItems TransactionItem[]
|
||||||
|
eventProducts EventProduct[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Transaction {
|
model Transaction {
|
||||||
@@ -103,6 +106,7 @@ model Transaction {
|
|||||||
idempotencyKey String @unique
|
idempotencyKey String @unique
|
||||||
vendorId String
|
vendorId String
|
||||||
userId String
|
userId String
|
||||||
|
eventId String?
|
||||||
status String // pending | completed | failed | refunded
|
status String // pending | completed | failed | refunded
|
||||||
paymentMethod String // cash | card
|
paymentMethod String // cash | card
|
||||||
subtotal Float
|
subtotal Float
|
||||||
@@ -115,6 +119,7 @@ model Transaction {
|
|||||||
|
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
event Event? @relation(fields: [eventId], references: [id])
|
||||||
items TransactionItem[]
|
items TransactionItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,3 +137,48 @@ model TransactionItem {
|
|||||||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
vendorId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
startsAt DateTime
|
||||||
|
endsAt DateTime
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
|
taxOverrides EventTax[]
|
||||||
|
products EventProduct[]
|
||||||
|
transactions Transaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax rate overrides for a specific event. Shadows the vendor-level Tax for the
|
||||||
|
// event duration. Empty = use vendor defaults.
|
||||||
|
model EventTax {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventId String
|
||||||
|
taxId String
|
||||||
|
rate Float // override rate in percent
|
||||||
|
|
||||||
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
tax Tax @relation(fields: [taxId], references: [id])
|
||||||
|
|
||||||
|
@@unique([eventId, taxId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist of products available at an event. Empty = all vendor products available.
|
||||||
|
model EventProduct {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventId String
|
||||||
|
productId String
|
||||||
|
|
||||||
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
|
||||||
|
@@unique([eventId, productId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Seed roles
|
// Seed roles
|
||||||
const ownerRole = await prisma.role.upsert({
|
const adminRole = await prisma.role.upsert({
|
||||||
where: { name: "owner" },
|
where: { name: "admin" },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name: "owner" },
|
create: { name: "admin" },
|
||||||
});
|
});
|
||||||
await prisma.role.upsert({
|
await prisma.role.upsert({
|
||||||
where: { name: "manager" },
|
where: { name: "vendor" },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name: "manager" },
|
create: { name: "vendor" },
|
||||||
});
|
});
|
||||||
await prisma.role.upsert({
|
await prisma.role.upsert({
|
||||||
where: { name: "cashier" },
|
where: { name: "user" },
|
||||||
update: {},
|
update: {},
|
||||||
create: { name: "cashier" },
|
create: { name: "user" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed demo vendor
|
// Seed demo vendor
|
||||||
@@ -32,7 +32,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed demo owner user
|
// Seed demo admin user
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: "admin@demo.com" },
|
where: { email: "admin@demo.com" },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -41,7 +41,7 @@ async function main() {
|
|||||||
passwordHash: await bcrypt.hash("password123", 10),
|
passwordHash: await bcrypt.hash("password123", 10),
|
||||||
name: "Demo Admin",
|
name: "Demo Admin",
|
||||||
vendorId: vendor.id,
|
vendorId: vendor.id,
|
||||||
roleId: ownerRole.id,
|
roleId: adminRole.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import taxesRouter from "./routes/taxes.js";
|
|||||||
import productsRouter from "./routes/products.js";
|
import productsRouter from "./routes/products.js";
|
||||||
import catalogRouter from "./routes/catalog.js";
|
import catalogRouter from "./routes/catalog.js";
|
||||||
import transactionsRouter from "./routes/transactions.js";
|
import transactionsRouter from "./routes/transactions.js";
|
||||||
|
import eventsRouter from "./routes/events.js";
|
||||||
import { errorHandler } from "./middleware/errorHandler.js";
|
import { errorHandler } from "./middleware/errorHandler.js";
|
||||||
import { requestLogger } from "./middleware/requestLogger.js";
|
import { requestLogger } from "./middleware/requestLogger.js";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export function createApp() {
|
|||||||
app.use("/api/v1/products", productsRouter);
|
app.use("/api/v1/products", productsRouter);
|
||||||
app.use("/api/v1/catalog", catalogRouter);
|
app.use("/api/v1/catalog", catalogRouter);
|
||||||
app.use("/api/v1/transactions", transactionsRouter);
|
app.use("/api/v1/transactions", transactionsRouter);
|
||||||
|
app.use("/api/v1/events", eventsRouter);
|
||||||
|
|
||||||
// Serve React admin UI static assets in production
|
// Serve React admin UI static assets in production
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedAfter = since ? { updatedAt: { gt: since } } : {};
|
const 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({
|
prisma.product.findMany({
|
||||||
where: { vendorId, ...updatedAfter },
|
where: { vendorId, ...updatedAfter },
|
||||||
include: { category: true, tax: true },
|
include: { category: true, tax: true },
|
||||||
@@ -44,6 +45,20 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
|||||||
where: { vendorId, ...updatedAfter },
|
where: { vendorId, ...updatedAfter },
|
||||||
orderBy: { updatedAt: "asc" },
|
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({
|
res.json({
|
||||||
@@ -52,10 +67,12 @@ router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction
|
|||||||
products,
|
products,
|
||||||
categories,
|
categories,
|
||||||
taxes,
|
taxes,
|
||||||
|
events,
|
||||||
counts: {
|
counts: {
|
||||||
products: products.length,
|
products: products.length,
|
||||||
categories: categories.length,
|
categories: categories.length,
|
||||||
taxes: taxes.length,
|
taxes: taxes.length,
|
||||||
|
events: events.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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({
|
const CategorySchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -44,7 +44,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const body = CategorySchema.parse(req.body);
|
const body = CategorySchema.parse(req.body);
|
||||||
@@ -57,7 +57,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.category.findFirst({
|
const existing = await prisma.category.findFirst({
|
||||||
@@ -73,7 +73,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.category.findFirst({
|
const existing = await prisma.category.findFirst({
|
||||||
|
|||||||
374
server/src/routes/events.ts
Normal file
374
server/src/routes/events.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
import { AppError } from "../middleware/errorHandler.js";
|
||||||
|
import { parsePage, paginatedResponse } from "../lib/pagination.js";
|
||||||
|
import { AuthenticatedRequest } from "../types/index.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||||
|
const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
||||||
|
|
||||||
|
const EventSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
startsAt: z.string().datetime(),
|
||||||
|
endsAt: z.string().datetime(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EventTaxSchema = z.object({
|
||||||
|
taxId: z.string().min(1),
|
||||||
|
rate: z.number().min(0).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EventProductSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: resolve vendorId scope (admin sees all, vendor sees own)
|
||||||
|
function vendorScope(authReq: AuthenticatedRequest) {
|
||||||
|
return authReq.auth.roleName === "admin" ? {} : { vendorId: authReq.auth.vendorId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/v1/events ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
||||||
|
const where = vendorScope(authReq);
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { startsAt: "asc" },
|
||||||
|
include: {
|
||||||
|
vendor: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { products: true, taxOverrides: true, transactions: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.event.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/v1/events ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const body = EventSchema.parse(req.body);
|
||||||
|
|
||||||
|
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
|
||||||
|
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin can specify vendorId; vendor always uses their own
|
||||||
|
const targetVendorId =
|
||||||
|
authReq.auth.roleName === "admin" && (req.body as { vendorId?: string }).vendorId
|
||||||
|
? (req.body as { vendorId: string }).vendorId
|
||||||
|
: authReq.auth.vendorId;
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
...body,
|
||||||
|
startsAt: new Date(body.startsAt),
|
||||||
|
endsAt: new Date(body.endsAt),
|
||||||
|
vendorId: targetVendorId,
|
||||||
|
},
|
||||||
|
include: { vendor: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(event);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/v1/events/:id ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
include: {
|
||||||
|
vendor: { select: { id: true, name: true } },
|
||||||
|
taxOverrides: { include: { tax: true } },
|
||||||
|
products: { include: { product: { select: { id: true, name: true, price: true, sku: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
res.json(event);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PUT /api/v1/events/:id ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const existing = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const body = EventSchema.parse(req.body);
|
||||||
|
if (new Date(body.endsAt) <= new Date(body.startsAt)) {
|
||||||
|
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { ...body, startsAt: new Date(body.startsAt), endsAt: new Date(body.endsAt) },
|
||||||
|
include: { vendor: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
res.json(event);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── DELETE /api/v1/events/:id ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const existing = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
await prisma.event.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tax overrides ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// PUT /api/v1/events/:id/taxes — upsert a tax override (idempotent)
|
||||||
|
router.put("/:id/taxes", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const { taxId, rate } = EventTaxSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Verify tax belongs to same vendor
|
||||||
|
const tax = await prisma.tax.findFirst({ where: { id: taxId, vendorId: event.vendorId } });
|
||||||
|
if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
|
||||||
|
|
||||||
|
const override = await prisma.eventTax.upsert({
|
||||||
|
where: { eventId_taxId: { eventId: event.id, taxId } },
|
||||||
|
create: { eventId: event.id, taxId, rate },
|
||||||
|
update: { rate },
|
||||||
|
include: { tax: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(override);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/events/:id/taxes/:taxId
|
||||||
|
router.delete("/:id/taxes/:taxId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const existing = await prisma.eventTax.findUnique({
|
||||||
|
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError(404, "NOT_FOUND", "Tax override not found");
|
||||||
|
|
||||||
|
await prisma.eventTax.delete({
|
||||||
|
where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } },
|
||||||
|
});
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Product allowlist ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/v1/events/:id/products
|
||||||
|
router.get("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const items = await prisma.eventProduct.findMany({
|
||||||
|
where: { eventId: event.id },
|
||||||
|
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
|
||||||
|
});
|
||||||
|
res.json(items);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/events/:id/products — add product to allowlist
|
||||||
|
router.post("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const { productId } = EventProductSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Verify product belongs to same vendor
|
||||||
|
const product = await prisma.product.findFirst({ where: { id: productId, vendorId: event.vendorId } });
|
||||||
|
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
|
||||||
|
|
||||||
|
const item = await prisma.eventProduct.upsert({
|
||||||
|
where: { eventId_productId: { eventId: event.id, productId } },
|
||||||
|
create: { eventId: event.id, productId },
|
||||||
|
update: {},
|
||||||
|
include: { product: { select: { id: true, name: true, price: true, sku: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(item);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/events/:id/products/:productId
|
||||||
|
router.delete("/:id/products/:productId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const existing = await prisma.eventProduct.findUnique({
|
||||||
|
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError(404, "NOT_FOUND", "Product not in event allowlist");
|
||||||
|
|
||||||
|
await prisma.eventProduct.delete({
|
||||||
|
where: { eventId_productId: { eventId: event.id, productId: req.params.productId } },
|
||||||
|
});
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/v1/events/:id/transactions ──────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/:id/transactions", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
||||||
|
const where = { eventId: event.id };
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.transaction.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } }, items: true },
|
||||||
|
}),
|
||||||
|
prisma.transaction.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/v1/events/:id/reports/summary ───────────────────────────────
|
||||||
|
|
||||||
|
router.get("/:id/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: { id: req.params.id, ...vendorScope(authReq) },
|
||||||
|
});
|
||||||
|
if (!event) throw new AppError(404, "NOT_FOUND", "Event not found");
|
||||||
|
|
||||||
|
const where = { eventId: event.id, status: "completed" };
|
||||||
|
|
||||||
|
const [totals, byPayment, topProducts] = await Promise.all([
|
||||||
|
prisma.transaction.aggregate({
|
||||||
|
where,
|
||||||
|
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
|
||||||
|
_count: { id: true },
|
||||||
|
_avg: { total: true },
|
||||||
|
}),
|
||||||
|
prisma.transaction.groupBy({
|
||||||
|
by: ["paymentMethod"],
|
||||||
|
where,
|
||||||
|
_sum: { total: true },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.transactionItem.groupBy({
|
||||||
|
by: ["productId", "productName"],
|
||||||
|
where: { transaction: where },
|
||||||
|
_sum: { total: true, quantity: true },
|
||||||
|
orderBy: { _sum: { total: "desc" } },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
event: { id: event.id, name: event.name, startsAt: event.startsAt, endsAt: event.endsAt },
|
||||||
|
totals: {
|
||||||
|
revenue: totals._sum.total ?? 0,
|
||||||
|
subtotal: totals._sum.subtotal ?? 0,
|
||||||
|
tax: totals._sum.taxTotal ?? 0,
|
||||||
|
discounts: totals._sum.discountTotal ?? 0,
|
||||||
|
transactionCount: totals._count.id,
|
||||||
|
averageTransaction: totals._avg.total ?? 0,
|
||||||
|
},
|
||||||
|
byPaymentMethod: byPayment.map((r) => ({
|
||||||
|
method: r.paymentMethod,
|
||||||
|
revenue: r._sum.total ?? 0,
|
||||||
|
count: r._count.id,
|
||||||
|
})),
|
||||||
|
topProducts: topProducts.map((r) => ({
|
||||||
|
productId: r.productId,
|
||||||
|
productName: r.productName,
|
||||||
|
revenue: r._sum.total ?? 0,
|
||||||
|
unitsSold: r._sum.quantity ?? 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -8,7 +8,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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({
|
const ProductSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -65,7 +65,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const body = ProductSchema.parse(req.body);
|
const body = ProductSchema.parse(req.body);
|
||||||
@@ -79,7 +79,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.product.findFirst({
|
const existing = await prisma.product.findFirst({
|
||||||
@@ -99,7 +99,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.product.findFirst({
|
const existing = await prisma.product.findFirst({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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({
|
const TaxSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -45,7 +45,7 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const body = TaxSchema.parse(req.body);
|
const body = TaxSchema.parse(req.body);
|
||||||
@@ -58,7 +58,7 @@ router.post("/", auth, managerUp, async (req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.tax.findFirst({
|
const existing = await prisma.tax.findFirst({
|
||||||
@@ -74,7 +74,7 @@ router.put("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const existing = await prisma.tax.findFirst({
|
const existing = await prisma.tax.findFirst({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { processPayment } from "../lib/payments.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ const TransactionSchema = z.object({
|
|||||||
total: z.number().min(0),
|
total: z.number().min(0),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
items: z.array(TransactionItemSchema).min(1),
|
items: z.array(TransactionItemSchema).min(1),
|
||||||
|
eventId: z.string().optional(),
|
||||||
// Android includes a local timestamp for ordering
|
// Android includes a local timestamp for ordering
|
||||||
createdAt: z.string().datetime().optional(),
|
createdAt: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
@@ -96,6 +97,7 @@ router.post("/batch", auth, async (req: Request, res: Response, next: NextFuncti
|
|||||||
discountTotal: tx.discountTotal,
|
discountTotal: tx.discountTotal,
|
||||||
total: tx.total,
|
total: tx.total,
|
||||||
notes: tx.notes,
|
notes: tx.notes,
|
||||||
|
...(tx.eventId ? { eventId: tx.eventId } : {}),
|
||||||
...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}),
|
...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}),
|
||||||
items: {
|
items: {
|
||||||
create: tx.items.map((item) => ({
|
create: tx.items.map((item) => ({
|
||||||
@@ -145,6 +147,7 @@ const SingleTransactionSchema = z.object({
|
|||||||
total: z.number().min(0),
|
total: z.number().min(0),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
items: z.array(TransactionItemSchema).min(1),
|
items: z.array(TransactionItemSchema).min(1),
|
||||||
|
eventId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -186,6 +189,7 @@ router.post("/", auth, async (req: Request, res: Response, next: NextFunction) =
|
|||||||
discountTotal: body.discountTotal,
|
discountTotal: body.discountTotal,
|
||||||
total: body.total,
|
total: body.total,
|
||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
|
...(body.eventId ? { eventId: body.eventId } : {}),
|
||||||
items: {
|
items: {
|
||||||
create: body.items.map((item) => ({
|
create: body.items.map((item) => ({
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
@@ -224,7 +228,7 @@ router.post("/", auth, async (req: Request, res: Response, next: NextFunction) =
|
|||||||
|
|
||||||
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
|
// ─── 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const { vendorId } = authReq.auth;
|
const { vendorId } = authReq.auth;
|
||||||
@@ -264,7 +268,7 @@ router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextF
|
|||||||
|
|
||||||
// ─── GET /api/v1/transactions/:id ─────────────────────────────────────────
|
// ─── 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const tx = await prisma.transaction.findFirst({
|
const tx = await prisma.transaction.findFirst({
|
||||||
@@ -284,7 +288,7 @@ router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
|||||||
// ─── GET /api/v1/transactions/reports/summary ─────────────────────────────
|
// ─── GET /api/v1/transactions/reports/summary ─────────────────────────────
|
||||||
// Daily totals, payment method breakdown, top products.
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const { vendorId } = authReq.auth;
|
const { vendorId } = authReq.auth;
|
||||||
@@ -355,7 +359,7 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon
|
|||||||
// also returns an average transaction value and opening/closing time of the
|
// also returns an average transaction value and opening/closing time of the
|
||||||
// first and last completed transaction in the period.
|
// first and last completed transaction in the period.
|
||||||
|
|
||||||
router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.get("/reports/shift", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const { vendorId } = authReq.auth;
|
const { vendorId } = authReq.auth;
|
||||||
@@ -422,7 +426,7 @@ router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response
|
|||||||
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
|
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
|
||||||
// Server-authoritative: only managers/owners can issue refunds.
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const tx = await prisma.transaction.findFirst({
|
const tx = await prisma.transaction.findFirst({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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
|
// Strip passwordHash from any user object before sending
|
||||||
function safe<T extends { passwordHash?: string }>(u: T): Omit<T, "passwordHash"> {
|
function safe<T extends { passwordHash?: string }>(u: T): Omit<T, "passwordHash"> {
|
||||||
@@ -22,6 +22,7 @@ const CreateUserSchema = z.object({
|
|||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
roleId: z.string().min(1),
|
roleId: z.string().min(1),
|
||||||
|
vendorId: z.string().min(1).optional(), // admin can assign to any vendor
|
||||||
});
|
});
|
||||||
|
|
||||||
const UpdateUserSchema = z.object({
|
const UpdateUserSchema = z.object({
|
||||||
@@ -30,20 +31,21 @@ const UpdateUserSchema = z.object({
|
|||||||
password: z.string().min(8).optional(),
|
password: z.string().min(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/v1/users
|
// GET /api/v1/users — admin sees all users; vendor sees their own vendor
|
||||||
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
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([
|
const [users, total] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { role: true },
|
include: { role: true, vendor: { select: { id: true, name: true } } },
|
||||||
}),
|
}),
|
||||||
prisma.user.count({ where }),
|
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
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const isAdmin = authReq.auth.roleName === "admin";
|
||||||
const user = await prisma.user.findFirst({
|
const user = 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 },
|
include: { role: true, vendor: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
|
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
|
||||||
res.json(safe(user));
|
res.json(safe(user));
|
||||||
@@ -80,9 +83,10 @@ router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: Ne
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/v1/users
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const isAdmin = authReq.auth.roleName === "admin";
|
||||||
const body = CreateUserSchema.parse(req.body);
|
const body = CreateUserSchema.parse(req.body);
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email: body.email } });
|
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 } });
|
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
|
||||||
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
|
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
|
||||||
|
|
||||||
// Managers cannot create owners
|
// Vendors cannot create admin accounts
|
||||||
if (authReq.auth.roleName === "manager" && role.name === "owner") {
|
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
|
||||||
throw new AppError(403, "FORBIDDEN", "Managers cannot create owner accounts");
|
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({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
passwordHash: await bcrypt.hash(body.password, 10),
|
passwordHash: await bcrypt.hash(body.password, 10),
|
||||||
name: body.name,
|
name: body.name,
|
||||||
vendorId: authReq.auth.vendorId,
|
vendorId: targetVendorId,
|
||||||
roleId: body.roleId,
|
roleId: body.roleId,
|
||||||
},
|
},
|
||||||
include: { role: true },
|
include: { role: true, vendor: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(safe(user));
|
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
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const isAdmin = authReq.auth.roleName === "admin";
|
||||||
const body = UpdateUserSchema.parse(req.body);
|
const body = UpdateUserSchema.parse(req.body);
|
||||||
|
|
||||||
const existing = await prisma.user.findFirst({
|
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 },
|
include: { role: true },
|
||||||
});
|
});
|
||||||
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
|
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) {
|
if (body.roleId) {
|
||||||
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
|
const role = await prisma.role.findUnique({ where: { id: body.roleId } });
|
||||||
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
|
if (!role) throw new AppError(400, "BAD_REQUEST", "Invalid role");
|
||||||
if (authReq.auth.roleName === "manager" && role.name === "owner") {
|
if (authReq.auth.roleName === "vendor" && role.name === "admin") {
|
||||||
throw new AppError(403, "FORBIDDEN", "Managers cannot assign owner role");
|
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({
|
const user = await prisma.user.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: { role: true },
|
include: { role: true, vendor: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(safe(user));
|
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
|
// 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 {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const isAdmin = authReq.auth.roleName === "admin";
|
||||||
if (req.params.id === authReq.auth.userId) {
|
if (req.params.id === authReq.auth.userId) {
|
||||||
throw new AppError(400, "BAD_REQUEST", "Cannot delete your own account");
|
throw new AppError(400, "BAD_REQUEST", "Cannot delete your own account");
|
||||||
}
|
}
|
||||||
const existing = await prisma.user.findFirst({
|
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");
|
if (!existing) throw new AppError(404, "NOT_FOUND", "User not found");
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AuthenticatedRequest } from "../types/index.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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({
|
const VendorSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -16,20 +16,22 @@ const VendorSchema = z.object({
|
|||||||
taxSettings: z.record(z.unknown()).optional(),
|
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) => {
|
router.get("/", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
|
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([
|
const [data, total] = await Promise.all([
|
||||||
prisma.vendor.findMany({
|
prisma.vendor.findMany({
|
||||||
where: { id: authReq.auth.vendorId },
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
}),
|
}),
|
||||||
prisma.vendor.count({ where: { id: authReq.auth.vendorId } }),
|
prisma.vendor.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json(paginatedResponse(data, total, { page, limit, skip }));
|
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) => {
|
router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const isAdmin = authReq.auth.roleName === "admin";
|
||||||
const vendor = await prisma.vendor.findFirst({
|
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");
|
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
|
||||||
res.json(vendor);
|
res.json(vendor);
|
||||||
@@ -52,8 +55,8 @@ router.get("/:id", auth, async (req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/v1/vendors
|
// POST /api/v1/vendors — admin only
|
||||||
router.post("/", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const body = VendorSchema.parse(req.body);
|
const body = VendorSchema.parse(req.body);
|
||||||
const vendor = await prisma.vendor.create({
|
const vendor = await prisma.vendor.create({
|
||||||
@@ -68,13 +71,17 @@ router.post("/", auth, ownerOnly, async (req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/v1/vendors/:id
|
// PUT /api/v1/vendors/:id — admin or vendor (own only)
|
||||||
router.put("/:id", auth, ownerOnly, async (req: Request, res: Response, next: NextFunction) => {
|
router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authReq = req as AuthenticatedRequest;
|
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");
|
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 body = VendorSchema.parse(req.body);
|
||||||
const vendor = await prisma.vendor.update({
|
const vendor = await prisma.vendor.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
|
|||||||
Reference in New Issue
Block a user