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:
2026-03-21 07:27:30 -05:00
parent c426b19b7c
commit 65eb405cf1
17 changed files with 1014 additions and 78 deletions

View File

@@ -20,11 +20,12 @@ model Vendor {
products Product[]
taxes Tax[]
transactions Transaction[]
events Event[]
}
model Role {
id String @id @default(cuid())
name String @unique // cashier | manager | owner
name String @unique // admin | vendor | user
users User[]
}
@@ -74,8 +75,9 @@ model Tax {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
products Product[]
vendor Vendor @relation(fields: [vendorId], references: [id])
products Product[]
eventOverrides EventTax[]
}
model Product {
@@ -96,6 +98,7 @@ model Product {
category Category? @relation(fields: [categoryId], references: [id])
tax Tax? @relation(fields: [taxId], references: [id])
transactionItems TransactionItem[]
eventProducts EventProduct[]
}
model Transaction {
@@ -103,6 +106,7 @@ model Transaction {
idempotencyKey String @unique
vendorId String
userId String
eventId String?
status String // pending | completed | failed | refunded
paymentMethod String // cash | card
subtotal Float
@@ -115,6 +119,7 @@ model Transaction {
vendor Vendor @relation(fields: [vendorId], references: [id])
user User @relation(fields: [userId], references: [id])
event Event? @relation(fields: [eventId], references: [id])
items TransactionItem[]
}
@@ -132,3 +137,48 @@ model TransactionItem {
transaction Transaction @relation(fields: [transactionId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
// ─── Events ───────────────────────────────────────────────────────────────────
model Event {
id String @id @default(cuid())
vendorId String
name String
description String?
startsAt DateTime
endsAt DateTime
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id])
taxOverrides EventTax[]
products EventProduct[]
transactions Transaction[]
}
// Tax rate overrides for a specific event. Shadows the vendor-level Tax for the
// event duration. Empty = use vendor defaults.
model EventTax {
id String @id @default(cuid())
eventId String
taxId String
rate Float // override rate in percent
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
tax Tax @relation(fields: [taxId], references: [id])
@@unique([eventId, taxId])
}
// Allowlist of products available at an event. Empty = all vendor products available.
model EventProduct {
id String @id @default(cuid())
eventId String
productId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
@@unique([eventId, productId])
}

View File

@@ -5,20 +5,20 @@ const prisma = new PrismaClient();
async function main() {
// Seed roles
const ownerRole = await prisma.role.upsert({
where: { name: "owner" },
const adminRole = await prisma.role.upsert({
where: { name: "admin" },
update: {},
create: { name: "owner" },
create: { name: "admin" },
});
await prisma.role.upsert({
where: { name: "manager" },
where: { name: "vendor" },
update: {},
create: { name: "manager" },
create: { name: "vendor" },
});
await prisma.role.upsert({
where: { name: "cashier" },
where: { name: "user" },
update: {},
create: { name: "cashier" },
create: { name: "user" },
});
// Seed demo vendor
@@ -32,7 +32,7 @@ async function main() {
},
});
// Seed demo owner user
// Seed demo admin user
await prisma.user.upsert({
where: { email: "admin@demo.com" },
update: {},
@@ -41,7 +41,7 @@ async function main() {
passwordHash: await bcrypt.hash("password123", 10),
name: "Demo Admin",
vendorId: vendor.id,
roleId: ownerRole.id,
roleId: adminRole.id,
},
});