Add multi-vendor capability with admin vendor management

- Add resolveVendorId() helper — admin can pass ?vendorId= to scope
  catalog operations to any vendor; other roles locked to JWT vendorId
- Thread ?vendorId= through products, categories, taxes, events routes
- Add DELETE /vendors/:id (admin only) with cascade-safe guard:
  blocks if vendor has users or transactions; otherwise cascade-deletes
  EventProduct → EventTax → Event → Product → Tax → Category → Vendor
- Rewrite VendorPage: admin gets full CRUD list, vendor gets own settings
- Add VendorFilter shared component (admin-only dropdown)
- Integrate VendorFilter into Catalog, Users, and Events pages so admin
  can switch vendor context for all create/read operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 07:59:58 -05:00
parent 65eb405cf1
commit e1b1a82e07
11 changed files with 379 additions and 195 deletions

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

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

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import { VendorFilter } from "../components/VendorFilter";
import { Table } from "../components/Table"; import { Table } from "../components/Table";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { FormField, Btn } from "../components/FormField"; import { FormField, Btn } from "../components/FormField";
@@ -50,6 +52,8 @@ interface EventSummary {
// ─── Main Page ────────────────────────────────────────────────────────────── // ─── Main Page ──────────────────────────────────────────────────────────────
export default function EventsPage() { export default function EventsPage() {
const { user } = useAuth();
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -61,7 +65,8 @@ export default function EventsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.get<ApiList<Event>>("/events?limit=50"); 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); setEvents(res.data);
setTotal(res.pagination.total); setTotal(res.pagination.total);
} catch (err) { } catch (err) {
@@ -69,7 +74,7 @@ export default function EventsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [vendorId, user?.role]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -120,11 +125,14 @@ export default function EventsPage() {
return ( return (
<div style={{ padding: "32px 28px" }}> <div style={{ padding: "32px 28px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader <PageHeader
title="Events" title="Events"
subtitle={`${total} event${total !== 1 ? "s" : ""}`} subtitle={`${total} event${total !== 1 ? "s" : ""}`}
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>} 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." /> <Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />

View File

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

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 { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, inputStyle, Btn } from "../components/FormField"; import { FormField, inputStyle, Btn } from "../components/FormField";
interface Vendor { interface Vendor {
@@ -12,17 +15,131 @@ interface Vendor {
updatedAt: string; 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 [vendor, setVendor] = useState<Vendor | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [form, setForm] = useState({ name: "", businessNum: "" }); const [form, setForm] = useState(EMPTY_FORM);
useEffect(() => { useEffect(() => {
api api.get<ApiList<Vendor>>("/vendors")
.get<{ data: Vendor[] }>("/vendors")
.then((res) => { .then((res) => {
const v = res.data[0] ?? null; const v = res.data[0] ?? null;
setVendor(v); setVendor(v);
@@ -43,9 +160,7 @@ export default function VendorPage() {
setEditing(false); setEditing(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Save failed"); setError(err instanceof Error ? err.message : "Save failed");
} finally { } finally { setSaving(false); }
setSaving(false);
}
}; };
if (loading) return <div style={{ padding: 32 }}>Loading</div>; if (loading) return <div style={{ padding: 32 }}>Loading</div>;
@@ -56,38 +171,23 @@ export default function VendorPage() {
<PageHeader <PageHeader
title="Vendor Settings" title="Vendor Settings"
subtitle="Business details and configuration" subtitle="Business details and configuration"
action={ action={!editing && <Btn onClick={() => setEditing(true)}>Edit</Btn>}
!editing && (
<Btn onClick={() => setEditing(true)}>Edit</Btn>
)
}
/> />
{editing ? ( {editing ? (
<form onSubmit={handleSave} style={card}> <form onSubmit={handleSave} style={card}>
{error && <div style={errStyle}>{error}</div>} {error && <div style={errStyle}>{error}</div>}
<FormField label="Business Name" required> <FormField label="Business Name" required>
<input <input style={inputStyle} value={form.name}
style={inputStyle} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</FormField> </FormField>
<FormField label="Business Number / ABN"> <FormField label="Business Number / ABN">
<input <input style={inputStyle} value={form.businessNum}
style={inputStyle} onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
value={form.businessNum}
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))}
/>
</FormField> </FormField>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}> <div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Btn type="submit" disabled={saving}> <Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save changes"}</Btn>
{saving ? "Saving…" : "Save changes"} <Btn variant="ghost" onClick={() => setEditing(false)}>Cancel</Btn>
</Btn>
<Btn variant="ghost" onClick={() => setEditing(false)}>
Cancel
</Btn>
</div> </div>
</form> </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 }) { function Row({ label, value }: { label: string; value: string }) {
return ( return (
<div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}> <div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}>

View File

@@ -0,0 +1,16 @@
import { AuthenticatedRequest } from "../types/index.js";
/**
* Resolves the effective vendorId for a request.
* Admin users may pass ?vendorId= to operate on any vendor's data.
* All other roles are locked to their own vendorId.
*/
export function resolveVendorId(
authReq: AuthenticatedRequest,
query: Record<string, unknown> = {}
): string {
if (authReq.auth.roleName === "admin" && typeof query.vendorId === "string" && query.vendorId) {
return query.vendorId;
}
return authReq.auth.vendorId;
}

View File

@@ -5,86 +5,76 @@ import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js"; import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js"; import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.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 vendorUp = requireRole("admin", "vendor") 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),
});
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 where = { vendorId: authReq.auth.vendorId }; const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = { vendorId };
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.category.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }), prisma.category.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
prisma.category.count({ where }), prisma.category.count({ where }),
]); ]);
res.json(paginatedResponse(data, total, { page, limit, skip })); res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
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 cat = await prisma.category.findFirst({ 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"); if (!cat) throw new AppError(404, "NOT_FOUND", "Category not found");
res.json(cat); res.json(cat);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.post("/", auth, vendorUp, 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 vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = CategorySchema.parse(req.body); const body = CategorySchema.parse(req.body);
const cat = await prisma.category.create({ const cat = await prisma.category.create({ data: { ...body, vendorId } });
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(cat); res.status(201).json(cat);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.put("/:id", auth, vendorUp, 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 existing = await prisma.category.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Category not found");
const body = CategorySchema.parse(req.body); const body = CategorySchema.parse(req.body);
const cat = await prisma.category.update({ where: { id: req.params.id }, data: body }); const cat = await prisma.category.update({ where: { id: req.params.id }, data: body });
res.json(cat); res.json(cat);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.delete("/:id", auth, vendorUp, 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";
const existing = await prisma.category.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Category not found");
await prisma.category.delete({ where: { id: req.params.id } }); await prisma.category.delete({ where: { id: req.params.id } });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
export default router; export default router;

View File

@@ -5,6 +5,7 @@ import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js"; import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js"; import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.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;
@@ -71,11 +72,7 @@ router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextF
throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt"); throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt");
} }
// Admin can specify vendorId; vendor always uses their own const targetVendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
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({ const event = await prisma.event.create({
data: { data: {

View File

@@ -5,6 +5,7 @@ import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js"; import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js"; import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.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;
@@ -25,65 +26,55 @@ router.get("/", auth, async (req: Request, res: Response, next: NextFunction) =>
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 { categoryId, search } = req.query as { categoryId?: string; search?: string }; const { categoryId, search } = req.query as { categoryId?: string; search?: string };
const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = { const where = {
vendorId: authReq.auth.vendorId, vendorId,
...(categoryId ? { categoryId } : {}), ...(categoryId ? { categoryId } : {}),
...(search ...(search ? { name: { contains: search } } : {}),
? { name: { contains: search } }
: {}),
}; };
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.product.findMany({ prisma.product.findMany({ where, skip, take: limit, orderBy: { name: "asc" }, include: { category: true, tax: true } }),
where,
skip,
take: limit,
orderBy: { name: "asc" },
include: { category: true, tax: true },
}),
prisma.product.count({ where }), prisma.product.count({ where }),
]); ]);
res.json(paginatedResponse(data, total, { page, limit, skip })); res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
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 product = await prisma.product.findFirst({ 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 }, include: { category: true, tax: true },
}); });
if (!product) throw new AppError(404, "NOT_FOUND", "Product not found"); if (!product) throw new AppError(404, "NOT_FOUND", "Product not found");
res.json(product); res.json(product);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.post("/", auth, vendorUp, 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 vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = ProductSchema.parse(req.body); const body = ProductSchema.parse(req.body);
const product = await prisma.product.create({ const product = await prisma.product.create({
data: { ...body, vendorId: authReq.auth.vendorId }, data: { ...body, vendorId },
include: { category: true, tax: true }, include: { category: true, tax: true },
}); });
res.status(201).json(product); res.status(201).json(product);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.put("/:id", auth, vendorUp, 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 existing = await prisma.product.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
@@ -94,23 +85,20 @@ router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: Nex
include: { category: true, tax: true }, include: { category: true, tax: true },
}); });
res.json(product); res.json(product);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.delete("/:id", auth, vendorUp, 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";
const existing = await prisma.product.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Product not found");
await prisma.product.delete({ where: { id: req.params.id } }); await prisma.product.delete({ where: { id: req.params.id } });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
export default router; export default router;

View File

@@ -5,6 +5,7 @@ import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js"; import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js"; import { AuthenticatedRequest } from "../types/index.js";
import { resolveVendorId } from "../lib/vendorScope.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;
@@ -19,73 +20,64 @@ 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 where = { vendorId: authReq.auth.vendorId }; const vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const where = { vendorId };
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.tax.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }), prisma.tax.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
prisma.tax.count({ where }), prisma.tax.count({ where }),
]); ]);
res.json(paginatedResponse(data, total, { page, limit, skip })); res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
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 tax = await prisma.tax.findFirst({ 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"); if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found");
res.json(tax); res.json(tax);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.post("/", auth, vendorUp, 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 vendorId = resolveVendorId(authReq, req.query as Record<string, unknown>);
const body = TaxSchema.parse(req.body); const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.create({ const tax = await prisma.tax.create({ data: { ...body, vendorId } });
data: { ...body, vendorId: authReq.auth.vendorId },
});
res.status(201).json(tax); res.status(201).json(tax);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.put("/:id", auth, vendorUp, 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 existing = await prisma.tax.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Tax not found");
const body = TaxSchema.parse(req.body); const body = TaxSchema.parse(req.body);
const tax = await prisma.tax.update({ where: { id: req.params.id }, data: body }); const tax = await prisma.tax.update({ where: { id: req.params.id }, data: body });
res.json(tax); res.json(tax);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
router.delete("/:id", auth, vendorUp, 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";
const existing = await prisma.tax.findFirst({ 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"); if (!existing) throw new AppError(404, "NOT_FOUND", "Tax not found");
await prisma.tax.delete({ where: { id: req.params.id } }); await prisma.tax.delete({ where: { id: req.params.id } });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
export default router; export default router;

View File

@@ -71,6 +71,39 @@ router.post("/", auth, adminOnly, async (req: Request, res: Response, next: Next
} }
}); });
// DELETE /api/v1/vendors/:id — admin only
router.delete("/:id", auth, adminOnly, async (req: Request, res: Response, next: NextFunction) => {
try {
const vendor = await prisma.vendor.findUnique({ where: { id: req.params.id } });
if (!vendor) throw new AppError(404, "NOT_FOUND", "Vendor not found");
// Check for dependent data before deleting
const [users, transactions] = await Promise.all([
prisma.user.count({ where: { vendorId: req.params.id } }),
prisma.transaction.count({ where: { vendorId: req.params.id } }),
]);
if (users > 0 || transactions > 0) {
throw new AppError(
409,
"CONFLICT",
`Cannot delete vendor with existing data (${users} user(s), ${transactions} transaction(s)). Remove all associated data first.`
);
}
// Safe to delete — cascade via Prisma in order
await prisma.eventProduct.deleteMany({ where: { event: { vendorId: req.params.id } } });
await prisma.eventTax.deleteMany({ where: { event: { vendorId: req.params.id } } });
await prisma.event.deleteMany({ where: { vendorId: req.params.id } });
await prisma.product.deleteMany({ where: { vendorId: req.params.id } });
await prisma.tax.deleteMany({ where: { vendorId: req.params.id } });
await prisma.category.deleteMany({ where: { vendorId: req.params.id } });
await prisma.vendor.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
// PUT /api/v1/vendors/:id — admin or vendor (own only) // PUT /api/v1/vendors/:id — admin or vendor (own only)
router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => { router.put("/:id", auth, async (req: Request, res: Response, next: NextFunction) => {
try { try {