Vendor-assign events and scope event catalog to vendor
Some checks failed
CI / Server — typecheck & build (push) Has been cancelled
CI / Client — typecheck & build (push) Has been cancelled
CI / Docker build (smoke test) (push) Has been cancelled

- Add vendorWhereClause() helper: admin + ?vendorId= filters to that
  vendor; admin with no filter sees all; other roles locked to own
- Fix events GET / to use vendorWhereClause so vendor filter works
- EventFormModal: admin sees a Vendor picker when creating a new event,
  pre-populated from the active VendorFilter; POST includes ?vendorId=
- EventConfigPanel: scope /taxes and /products fetches to event.vendorId
  so only the event's vendor's catalog items are selectable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 17:48:44 -05:00
parent e1b1a82e07
commit 31e539102b
3 changed files with 58 additions and 8 deletions

View File

@@ -5,7 +5,7 @@ import { PageHeader } from "../components/PageHeader";
import { VendorFilter } from "../components/VendorFilter"; 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, inputStyle } from "../components/FormField";
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -139,6 +139,7 @@ export default function EventsPage() {
{showForm && ( {showForm && (
<EventFormModal <EventFormModal
event={editing} event={editing}
defaultVendorId={vendorId}
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); load(); }} onSaved={() => { setShowForm(false); load(); }}
/> />
@@ -159,20 +160,39 @@ export default function EventsPage() {
// ─── Event Form Modal ──────────────────────────────────────────────────────── // ─── Event Form Modal ────────────────────────────────────────────────────────
function EventFormModal({ event, onClose, onSaved }: { function EventFormModal({ event, defaultVendorId, onClose, onSaved }: {
event: Event | null; event: Event | null;
defaultVendorId?: string;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) { }) {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const [name, setName] = useState(event?.name ?? ""); const [name, setName] = useState(event?.name ?? "");
const [description, setDescription] = useState(event?.description ?? ""); const [description, setDescription] = useState(event?.description ?? "");
const [startsAt, setStartsAt] = useState(event ? toDatetimeLocal(event.startsAt) : ""); const [startsAt, setStartsAt] = useState(event ? toDatetimeLocal(event.startsAt) : "");
const [endsAt, setEndsAt] = useState(event ? toDatetimeLocal(event.endsAt) : ""); const [endsAt, setEndsAt] = useState(event ? toDatetimeLocal(event.endsAt) : "");
const [isActive, setIsActive] = useState(event?.isActive ?? true); const [isActive, setIsActive] = useState(event?.isActive ?? true);
const [selectedVendorId, setSelectedVendorId] = useState(
event?.vendorId ?? defaultVendorId ?? ""
);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => {
if (isAdmin && !event) {
api.get<{ data: Vendor[] }>("/vendors?limit=200")
.then((r) => setVendors(r.data))
.catch(console.error);
}
}, [isAdmin, event]);
const save = async () => { const save = async () => {
if (isAdmin && !event && !selectedVendorId) {
setError("Please select a vendor for this event.");
return;
}
setSaving(true); setSaving(true);
setError(""); setError("");
try { try {
@@ -185,7 +205,8 @@ function EventFormModal({ event, onClose, onSaved }: {
if (event) { if (event) {
await api.put(`/events/${event.id}`, body); await api.put(`/events/${event.id}`, body);
} else { } else {
await api.post("/events", body); const q = isAdmin && selectedVendorId ? `?vendorId=${encodeURIComponent(selectedVendorId)}` : "";
await api.post(`/events${q}`, body);
} }
onSaved(); onSaved();
} catch (err) { } catch (err) {
@@ -197,6 +218,15 @@ function EventFormModal({ event, onClose, onSaved }: {
return ( return (
<Modal title={event ? "Edit Event" : "New Event"} onClose={onClose}> <Modal title={event ? "Edit Event" : "New Event"} onClose={onClose}>
{isAdmin && !event && (
<FormField label="Vendor" required>
<select style={inputStyle} value={selectedVendorId}
onChange={(e) => setSelectedVendorId(e.target.value)} required>
<option value="">Select vendor</option>
{vendors.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</FormField>
)}
<FormField label="Name"> <FormField label="Name">
<input style={input} value={name} onChange={(e) => setName(e.target.value)} /> <input style={input} value={name} onChange={(e) => setName(e.target.value)} />
</FormField> </FormField>
@@ -276,11 +306,12 @@ function EventConfigPanel({ event, onRefresh }: { event: Event; onRefresh: () =>
const [err, setErr] = useState(""); const [err, setErr] = useState("");
useEffect(() => { useEffect(() => {
const q = `?vendorId=${encodeURIComponent(event.vendorId)}&limit=200`;
Promise.all([ Promise.all([
api.get<ApiList<Tax>>("/taxes?limit=100").then((r) => setTaxes(r.data)), api.get<ApiList<Tax>>(`/taxes${q}`).then((r) => setTaxes(r.data)),
api.get<ApiList<Product>>("/products?limit=200").then((r) => setProducts(r.data)), api.get<ApiList<Product>>(`/products${q}`).then((r) => setProducts(r.data)),
]).catch(console.error); ]).catch(console.error);
}, []); }, [event.vendorId]);
const addTax = async () => { const addTax = async () => {
setErr(""); setErr("");

View File

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

View File

@@ -5,7 +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"; import { resolveVendorId, vendorWhereClause } 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;
@@ -39,7 +39,7 @@ router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFu
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 = vendorScope(authReq); const where = vendorWhereClause(authReq, req.query as Record<string, unknown>);
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.event.findMany({ prisma.event.findMany({