From 61fbc4cd728842bdb305a303960184abbd05dfe6 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:53:38 -0700 Subject: [PATCH 01/14] WIP: inventory system implementation Amp-Thread-ID: https://ampcode.com/threads/T-019d1e30-7dd6-7489-91ad-50ea8c189b8d Co-authored-by: Amp --- SPEC.md | 437 ++++++++++++ app/admin/layout.tsx | 3 + app/api/inventory/access/route.ts | 14 + app/api/inventory/admin/items/[id]/route.ts | 71 ++ app/api/inventory/admin/items/import/route.ts | 43 ++ app/api/inventory/admin/items/route.ts | 43 ++ app/api/inventory/admin/orders/[id]/route.ts | 58 ++ app/api/inventory/admin/orders/route.ts | 26 + .../admin/rentals/[id]/return/route.ts | 73 ++ app/api/inventory/admin/rentals/route.ts | 20 + app/api/inventory/admin/settings/route.ts | 51 ++ .../inventory/admin/teams/[id]/lock/route.ts | 44 ++ app/api/inventory/admin/teams/route.ts | 17 + app/api/inventory/digikey/search/route.ts | 29 + app/api/inventory/items/[id]/route.ts | 19 + app/api/inventory/items/route.ts | 58 ++ .../inventory/lookup/[slackUserId]/route.ts | 89 +++ app/api/inventory/orders/route.ts | 141 ++++ app/api/inventory/rentals/route.ts | 123 ++++ app/api/inventory/sse/route.ts | 48 ++ app/api/inventory/teams/[id]/join/route.ts | 52 ++ app/api/inventory/teams/[id]/leave/route.ts | 40 ++ .../teams/[id]/members/[userId]/route.ts | 49 ++ app/api/inventory/teams/[id]/members/route.ts | 62 ++ app/api/inventory/teams/[id]/route.ts | 130 ++++ app/api/inventory/teams/route.ts | 69 ++ app/api/inventory/tools/route.ts | 12 + app/components/inventory/CartPanel.tsx | 79 +++ app/components/inventory/CheckoutModal.tsx | 91 +++ app/components/inventory/ItemCard.tsx | 95 +++ app/components/inventory/NFCScanner.tsx | 129 ++++ app/components/inventory/OrderStatusBar.tsx | 58 ++ app/components/inventory/RentalTimer.tsx | 44 ++ app/components/inventory/ToolCard.tsx | 60 ++ app/dashboard/layout.tsx | 3 + app/inventory/admin/items/page.tsx | 671 ++++++++++++++++++ app/inventory/admin/layout.tsx | 121 ++++ app/inventory/admin/page.tsx | 354 +++++++++ app/inventory/admin/rentals/page.tsx | 166 +++++ app/inventory/admin/settings/page.tsx | 98 +++ app/inventory/admin/teams/page.tsx | 173 +++++ app/inventory/dashboard/page.tsx | 312 ++++++++ app/inventory/layout.tsx | 158 +++++ app/inventory/page.tsx | 270 +++++++ app/inventory/team/page.tsx | 502 +++++++++++++ app/inventory/tools/page.tsx | 232 ++++++ instrumentation-client.ts.bak | 47 ++ lib/hooks/useInventorySSE.ts | 49 ++ lib/inventory/access.ts | 91 +++ lib/inventory/config.ts | 11 + lib/inventory/digikey.ts | 76 ++ lib/inventory/notifications.ts | 19 + lib/inventory/sse.ts | 42 ++ .../migration.sql | 164 +++++ prisma/schema.prisma | 373 ++++++---- 55 files changed, 6187 insertions(+), 122 deletions(-) create mode 100644 SPEC.md create mode 100644 app/api/inventory/access/route.ts create mode 100644 app/api/inventory/admin/items/[id]/route.ts create mode 100644 app/api/inventory/admin/items/import/route.ts create mode 100644 app/api/inventory/admin/items/route.ts create mode 100644 app/api/inventory/admin/orders/[id]/route.ts create mode 100644 app/api/inventory/admin/orders/route.ts create mode 100644 app/api/inventory/admin/rentals/[id]/return/route.ts create mode 100644 app/api/inventory/admin/rentals/route.ts create mode 100644 app/api/inventory/admin/settings/route.ts create mode 100644 app/api/inventory/admin/teams/[id]/lock/route.ts create mode 100644 app/api/inventory/admin/teams/route.ts create mode 100644 app/api/inventory/digikey/search/route.ts create mode 100644 app/api/inventory/items/[id]/route.ts create mode 100644 app/api/inventory/items/route.ts create mode 100644 app/api/inventory/lookup/[slackUserId]/route.ts create mode 100644 app/api/inventory/orders/route.ts create mode 100644 app/api/inventory/rentals/route.ts create mode 100644 app/api/inventory/sse/route.ts create mode 100644 app/api/inventory/teams/[id]/join/route.ts create mode 100644 app/api/inventory/teams/[id]/leave/route.ts create mode 100644 app/api/inventory/teams/[id]/members/[userId]/route.ts create mode 100644 app/api/inventory/teams/[id]/members/route.ts create mode 100644 app/api/inventory/teams/[id]/route.ts create mode 100644 app/api/inventory/teams/route.ts create mode 100644 app/api/inventory/tools/route.ts create mode 100644 app/components/inventory/CartPanel.tsx create mode 100644 app/components/inventory/CheckoutModal.tsx create mode 100644 app/components/inventory/ItemCard.tsx create mode 100644 app/components/inventory/NFCScanner.tsx create mode 100644 app/components/inventory/OrderStatusBar.tsx create mode 100644 app/components/inventory/RentalTimer.tsx create mode 100644 app/components/inventory/ToolCard.tsx create mode 100644 app/inventory/admin/items/page.tsx create mode 100644 app/inventory/admin/layout.tsx create mode 100644 app/inventory/admin/page.tsx create mode 100644 app/inventory/admin/rentals/page.tsx create mode 100644 app/inventory/admin/settings/page.tsx create mode 100644 app/inventory/admin/teams/page.tsx create mode 100644 app/inventory/dashboard/page.tsx create mode 100644 app/inventory/layout.tsx create mode 100644 app/inventory/page.tsx create mode 100644 app/inventory/team/page.tsx create mode 100644 app/inventory/tools/page.tsx create mode 100644 instrumentation-client.ts.bak create mode 100644 lib/hooks/useInventorySSE.ts create mode 100644 lib/inventory/access.ts create mode 100644 lib/inventory/config.ts create mode 100644 lib/inventory/digikey.ts create mode 100644 lib/inventory/notifications.ts create mode 100644 lib/inventory/sse.ts create mode 100644 prisma/migrations/20260324032823_add_inventory_models/migration.sql diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..8e0e9ab2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,437 @@ +# Stasis Inventory Management System + +## Overview + +In-person inventory management system for Stasis's hardware event. Lives in the existing Stasis repo, same database. Teams browse and order parts, rent tools, and interact via NFC badge scanning. Admins fulfill orders and track tool usage. Group Amazon account meets DoorDash. + +--- + +## Tech Stack + +Uses the existing Stasis stack: + +- **Runtime:** Bun +- **Framework:** Next.js 14+ (App Router) -- existing repo +- **UI:** shadcn/ui + Tailwind CSS -- existing repo +- **ORM:** Prisma -- existing repo, extend the schema +- **Database:** Postgres -- existing DB +- **Auth:** Better Auth -- existing, cookie-based sessions (`better-auth.session_token`) +- **Roles/Permissions:** Existing `UserRole` / permissions system +- **Currency:** Existing `CurrencyTransaction` ledger -- read bits balance to gate inventory access (items are free, no transactions created) +- **Audit:** Existing `AuditLog` +- **Real-time:** Server-Sent Events (SSE) for live order/rental status updates +- **Slack DMs:** `@slack/web-api` `chat.postMessage` from API routes (user's `slackId` already on the User model) +- **NFC:** Web NFC API +- **Part images:** DigiKey Product Information API v4 (free tier, OAuth2 client credentials) + +--- + +## New Prisma Models + +Added to the existing schema alongside User, Session, CurrencyTransaction, etc. All relations reference the existing `User` model. + +```prisma +model Team { + id String @id @default(cuid()) + name String @unique + locked Boolean @default(false) + members User[] @relation("TeamMembers") + orders Order[] + toolRentals ToolRental[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Item { + id String @id @default(cuid()) + name String + description String? + imageUrl String? + stock Int + category String + maxPerTeam Int + orderItems OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Tool { + // Each row is a unique PHYSICAL tool, not a type. + // 5 soldering irons = 5 rows. No stock field. + id String @id @default(cuid()) + name String + description String? + imageUrl String? + available Boolean @default(true) + rentals ToolRental[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum OrderStatus { + PLACED + IN_PROGRESS + READY + COMPLETED +} + +model Order { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + placedById String + placedBy User @relation("OrdersPlaced", fields: [placedById], references: [id]) + status OrderStatus @default(PLACED) + floor Int + location String + items OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id]) + itemId String + item Item @relation(fields: [itemId], references: [id]) + quantity Int +} + +enum RentalStatus { + CHECKED_OUT + RETURNED +} + +model ToolRental { + id String @id @default(cuid()) + toolId String + tool Tool @relation(fields: [toolId], references: [id]) + teamId String + team Team @relation(fields: [teamId], references: [id]) + rentedById String + rentedBy User @relation("ToolsRented", fields: [rentedById], references: [id]) + status RentalStatus @default(CHECKED_OUT) + floor Int + location String + dueAt DateTime? + returnedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model InventorySettings { + id String @id @default("singleton") + enabled Boolean @default(false) +} +``` + +The existing `User` model needs these relation fields added: + +```prisma +// Add to existing User model: +teamId String? +team Team? @relation("TeamMembers", fields: [teamId], references: [id]) +ordersPlaced Order[] @relation("OrdersPlaced") +toolsRented ToolRental[] @relation("ToolsRented") +``` + +--- + +## Page Routes + +All under the existing Next.js app. Auth is already handled by Better Auth middleware -- users must be logged in. + +| Route | Description | Auth | +|---|---|---| +| `/inventory` | Browse parts, add to cart, checkout | Logged in, must have team. **Only visible when inventory is enabled (admin toggle) AND user is eligible (bits balance >= `MIN_BITS_FOR_INVENTORY`).** Hidden server-side otherwise. | +| `/inventory/tools` | Browse and rent tools | Logged in, must have team. Same visibility gating as `/inventory`. | +| `/inventory/team` | Create/join/edit/leave team | Logged in | +| `/inventory/dashboard` | Attendee home: active order, active rentals, team, history | Logged in | +| `/inventory/admin` | Admin dashboard: orders, rentals, NFC lookup | ADMIN role | +| `/inventory/admin/items` | Manage inventory: CSV import, add/edit items, DigiKey image search | ADMIN role | +| `/inventory/admin/teams` | View all teams, lock/unlock | ADMIN role | +| `/inventory/admin/settings` | Toggle inventory visibility | ADMIN role | + +--- + +## API Routes + +All under `/api/inventory/`. Auth via existing Better Auth session. Admin routes use existing `requireAdmin` / `requirePermission` helpers. + +### Items + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/items` | List all items with stock and team's remaining limits | +| `GET` | `/api/inventory/items/:id` | Get single item details | + +### Orders + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/orders` | Get current user's team's orders (active + history) | +| `POST` | `/api/inventory/orders` | Place order. Validates: team exists, no active order, stock available, within per-team limits. Decrements stock immediately. Sends Slack DM to all team members. | + +### Tool Rentals + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/rentals` | Get current user's team's rentals (active + history) | +| `POST` | `/api/inventory/rentals` | Rent a tool. Validates: team exists, tool available, team has < max concurrent rentals. Sets `dueAt` if time limit configured. Sends Slack DM to all team members. | + +### Teams + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/teams` | List all teams (for joining) | +| `POST` | `/api/inventory/teams` | Create team. Validates: unique name, user not already on a team. | +| `GET` | `/api/inventory/teams/:id` | Get team details + members | +| `PATCH` | `/api/inventory/teams/:id` | Edit team name. Validates: not locked. | +| `DELETE` | `/api/inventory/teams/:id` | Delete team. Validates: only one member (the requester). | +| `POST` | `/api/inventory/teams/:id/join` | Join a team. Validates: not locked, not full, user not on another team. | +| `POST` | `/api/inventory/teams/:id/leave` | Leave team. Auto-deletes team if now empty. | +| `POST` | `/api/inventory/teams/:id/members` | Add member by Slack user ID. Validates: not locked, not full. | +| `DELETE` | `/api/inventory/teams/:id/members/:userId` | Remove a member. Validates: not locked. | + +### Admin + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/admin/orders` | All orders, filterable by status | +| `PATCH` | `/api/inventory/admin/orders/:id` | Update order status (IN_PROGRESS, READY, COMPLETED). Sends Slack DM on READY and COMPLETED. Logs to AuditLog. | +| `GET` | `/api/inventory/admin/rentals` | All active rentals | +| `PATCH` | `/api/inventory/admin/rentals/:id/return` | Mark tool as returned. Sets `returnedAt`, marks tool available. Sends Slack DM. Logs to AuditLog. | +| `POST` | `/api/inventory/admin/items/import` | CSV upload (bulk import items). Logs to AuditLog. | +| `POST` | `/api/inventory/admin/items` | Add single item. Logs to AuditLog. | +| `PATCH` | `/api/inventory/admin/items/:id` | Edit item. Logs to AuditLog. | +| `DELETE` | `/api/inventory/admin/items/:id` | Delete item. Logs to AuditLog. | +| `GET` | `/api/inventory/admin/teams` | List all teams | +| `PATCH` | `/api/inventory/admin/teams/:id/lock` | Toggle team lock. Logs to AuditLog. | +| `GET` | `/api/inventory/admin/settings` | Get inventory settings (enabled/disabled) | +| `PATCH` | `/api/inventory/admin/settings` | Toggle inventory visibility. Logs to AuditLog. | + +### NFC Lookup + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/lookup/:slackUserId` | Given a Slack user ID (from NFC scan), return their team's active order and active rentals | + +### DigiKey + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/digikey/search?q=` | Search DigiKey by part name, return list of product images for admin to choose from | + +### Real-time + +| Method | Route | Description | +|---|---|---| +| `GET` | `/api/inventory/sse?teamId=` | SSE endpoint. Clients subscribe with their team ID. Server pushes order/rental/team status changes. Admin subscribes without team filter for global feed. | + +--- + +## Core Flows + +### 1. Ordering + +**No pricing. Items are free. No approval step.** + +Orders belong to the team. Any team member can place an order and any team member can pick it up. One active order per team at a time. + +**Inventory visibility:** Admin can toggle the inventory on/off from `/inventory/admin/settings`. When off, all `/inventory/*` routes are hidden server-side. When on, only eligible users (bits balance >= `MIN_BITS_FOR_INVENTORY`) can see it. Double protection. + +**Attendee flow:** +1. Browse items on `/inventory` +2. Select item(s) and quantity (per-team max enforced, shown on part cards) +3. Provide current location: floor dropdown + room/table text field +4. Submit order -- stock decremented immediately +5. All team members get Slack DM + +**Order lifecycle:** +1. **PLACED** -- stock updated, team gets Slack DM, SSE event pushed +2. **IN_PROGRESS** -- admin begins preparing, SSE event pushed +3. **READY** -- admin marks ready, team gets Slack DM, SSE event pushed +4. **COMPLETED** -- team member scans badge at hardware station, admin confirms, team gets Slack DM, SSE event pushed + +**Admin flow:** +1. View unfulfilled orders on `/inventory/admin`, filterable by status +2. Mark orders as IN_PROGRESS or READY (READY triggers Slack DM) +3. Scan NFC badge or enter Slack user ID to look up team's order at pickup +4. Mark order COMPLETED + +### 2. Tool Rental + +Tool rentals are per-team. Max concurrent rentals per team is configurable (default: 2). + +**Attendee flow:** +1. Browse tools on `/inventory/tools` +2. Select tool, provide location (floor + room/table) +3. Submit -- tool marked unavailable, all team members get Slack DM + +**Rental lifecycle:** +1. **CHECKED_OUT** -- tool unavailable, team gets Slack DM, SSE event pushed +2. **Due/overdue** -- if time limits configured, cron job sends Slack reminder to team +3. **RETURNED** -- admin marks returned, tool available again, team gets Slack DM, SSE event pushed + +### 3. Teams + +- Must be on a team to order or rent +- Create team on `/inventory/team` with a unique name +- Add members by Slack user ID (already on User model) +- Max team size configurable (default: 4) +- Per-team limits do not scale with team size +- All members have equal control (no owner) +- Any member can edit, add/remove members, rename (unless locked) +- Can switch teams at will +- **When a member leaves a team:** active orders and rentals stay with the team. The departing member stops receiving notifications and can no longer pick up orders for that team. +- Solo member can delete team; empty teams auto-delete +- Admins can lock teams to freeze all changes +- All mutations use database transactions to prevent race conditions + +### 4. NFC Badge + +**Web NFC API for both reading and writing.** + +**Badge registration (admin):** +1. Admin enters Slack user ID on `/inventory/admin` +2. Taps NFC tag to device +3. App writes Slack user ID as NDEF text record + +**Pickup (admin):** +1. Admin taps "Scan Badge" on `/inventory/admin` +2. Attendee taps badge +3. App reads Slack user ID, calls `GET /api/inventory/lookup/:slackUserId` +4. Displays team's active order and rentals + +**Fallback:** Manual Slack user ID text input on admin dashboard. + +**Implementation:** Web NFC API. If iOS support is needed, a native app may be required (Web NFC is not supported in Safari). + +### 5. Inventory Management + +**CSV import:** +- Upload on `/inventory/admin/items` +- Schema defined below; organizers must follow it +- Bulk upserts items + +**Single item add/edit:** +- Form on `/inventory/admin/items` +- DigiKey image search: type part name, pick best image from results +- Manual image URL as fallback + +**Item fields:** name, description, image URL, stock, category, max per team + +### 6. Attendee Dashboard (`/inventory/dashboard`) + +- Active order with live status (PLACED / IN_PROGRESS / READY) via SSE +- Active tool rentals with time remaining (if limits set) +- Team info: name, members, edit/leave/switch +- Order history +- Past tool rentals + +### 7. Browse / Order Page (`/inventory`) + +- Part cards show per-team remaining limit (e.g., "3 of 5 remaining") +- Ordering disabled for items where team has hit max +- One active order per team -- checkout disabled if active order exists +- Cart persisted in client state (not DB) +- **Hidden server-side when inventory is disabled or user is not eligible** + +### 8. Admin Dashboard (`/inventory/admin`) + +- Unfulfilled orders (chronological), filterable by status +- Active tool rentals (which team, where, since when) +- NFC scan / manual Slack ID lookup +- Team management on `/inventory/admin/teams` (view all, lock/unlock) +- Inventory toggle on `/inventory/admin/settings` + +--- + +## Integration with Existing Systems + +### Auth + +Uses existing Better Auth. No new auth code. Session cookie `better-auth.session_token` is already set by the main app. All inventory routes just read the session. + +### Bits / Currency + +Uses existing `CurrencyTransaction` ledger: +- Check user's bits balance against `MIN_BITS_FOR_INVENTORY` to determine eligibility +- Items are free -- no currency transactions created by the inventory system + +### Roles + +Uses existing `UserRole` system: +- ADMIN role = inventory admin access +- Use existing `requireAdmin` / `requirePermission` helpers from `lib/admin-auth.ts` + +### Audit Log + +All admin mutations logged to existing `AuditLog` table. Add new action types for inventory operations (e.g., `INVENTORY_ORDER_STATUS_UPDATE`, `INVENTORY_IMPORT`, `INVENTORY_TEAM_LOCK`, etc.). + +### Slack + +User's `slackId` is already on the `User` model (populated on signup via HCA hook). DMs sent via `@slack/web-api` `chat.postMessage` using the existing bot token. + +--- + +## Slack DMs + +No separate service. Push-only via `chat.postMessage` from API routes. + +**DM triggers (sent to each team member's `slackId`):** +- Order placed +- Order ready +- Order completed +- Tool checked out +- Tool due/overdue reminder +- Tool returned + +**Overdue reminders:** `node-cron` job in the Next.js server process, checks for overdue rentals periodically. + +--- + +## DigiKey Integration + +DigiKey Product Information API v4 (free tier). OAuth2 client credentials flow. + +**Flow:** +1. Admin types part name into image search on `/inventory/admin/items` +2. Frontend calls `GET /api/inventory/digikey/search?q=` +3. Backend authenticates via client credentials, calls DigiKey keyword search +4. Returns product list with image URLs +5. Admin picks best image +6. URL saved to item record + +--- + +## CSV Import Format + +```csv +name,description,category,stock,max_per_team,image_url +"10K Resistor","10K Ohm 1/4W","resistors",500,20,"" +"ESP32","ESP32-WROOM-32D","microcontrollers",50,2,"" +"Soldering Iron Tip","Fine point replacement tip","accessories",30,5,"" +``` + +- `image_url` is optional (fill later via DigiKey search or manual upload) +- `max_per_team` is required + +--- + +## New Environment Variables + +Added to existing `.env`: + +| Variable | Description | Default | +|---|---|---| +| `DIGIKEY_CLIENT_ID` | DigiKey API client ID | -- | +| `DIGIKEY_CLIENT_SECRET` | DigiKey API client secret | -- | +| `MIN_BITS_FOR_INVENTORY` | Minimum bits balance to access inventory | -- | +| `VENUE_FLOORS` | Number of floors in venue | `3` | +| `TOOL_RENTAL_TIME_LIMIT_MINUTES` | Tool rental time limit (0 = no limit) | `0` | +| `MAX_TEAM_SIZE` | Max members per team | `4` | +| `MAX_CONCURRENT_RENTALS` | Max active tool rentals per team | `2` | + +Slack bot token and DB connection already exist in the Stasis env. diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index dd543978..d657bc6e 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -150,6 +150,9 @@ export default function AdminLayout({ Events )} + + Inventory + diff --git a/app/api/inventory/access/route.ts b/app/api/inventory/access/route.ts new file mode 100644 index 00000000..a85a0695 --- /dev/null +++ b/app/api/inventory/access/route.ts @@ -0,0 +1,14 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import { checkInventoryAccess } from "@/lib/inventory/access" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const access = await checkInventoryAccess(session.user.id) + return NextResponse.json(access) +} diff --git a/app/api/inventory/admin/items/[id]/route.ts b/app/api/inventory/admin/items/[id]/route.ts new file mode 100644 index 00000000..a459a95c --- /dev/null +++ b/app/api/inventory/admin/items/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + const { id } = await params + + const existing = await prisma.item.findUnique({ where: { id } }) + if (!existing) return NextResponse.json({ error: "Item not found" }, { status: 404 }) + + const body = await request.json() + const { name, description, imageUrl, stock, category, maxPerTeam } = body + + const item = await prisma.item.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(imageUrl !== undefined && { imageUrl }), + ...(stock !== undefined && { stock }), + ...(category !== undefined && { category }), + ...(maxPerTeam !== undefined && { maxPerTeam }), + }, + }) + + await logAdminAction( + AuditAction.INVENTORY_ITEM_UPDATE, + session.user.id, + session.user.email, + "Item", + item.id, + body + ) + + return NextResponse.json(item) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + const { id } = await params + + const existing = await prisma.item.findUnique({ where: { id } }) + if (!existing) return NextResponse.json({ error: "Item not found" }, { status: 404 }) + + await prisma.item.delete({ where: { id } }) + + await logAdminAction( + AuditAction.INVENTORY_ITEM_DELETE, + session.user.id, + session.user.email, + "Item", + id, + { name: existing.name } + ) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts new file mode 100644 index 00000000..8ee27d2b --- /dev/null +++ b/app/api/inventory/admin/items/import/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function POST(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + + const body = await request.json() + const { items } = body + + if (!Array.isArray(items) || items.length === 0) { + return NextResponse.json( + { error: "Request body must contain a non-empty items array" }, + { status: 400 } + ) + } + + const data = items.map((item: Record) => ({ + name: item.name as string, + description: (item.description as string) ?? null, + imageUrl: (item.imageUrl as string) ?? null, + stock: item.stock as number, + category: item.category as string, + maxPerTeam: item.maxPerTeam as number, + })) + + const result2 = await prisma.item.createMany({ data }) + + await logAdminAction( + AuditAction.INVENTORY_IMPORT, + session.user.id, + session.user.email, + "Item", + undefined, + { count: result2.count } + ) + + return NextResponse.json({ imported: result2.count }, { status: 201 }) +} diff --git a/app/api/inventory/admin/items/route.ts b/app/api/inventory/admin/items/route.ts new file mode 100644 index 00000000..ca5628c4 --- /dev/null +++ b/app/api/inventory/admin/items/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function POST(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + + const body = await request.json() + const { name, description, imageUrl, stock, category, maxPerTeam } = body + + if (!name || stock == null || !category || maxPerTeam == null) { + return NextResponse.json( + { error: "Missing required fields: name, stock, category, maxPerTeam" }, + { status: 400 } + ) + } + + const item = await prisma.item.create({ + data: { + name, + description: description ?? null, + imageUrl: imageUrl ?? null, + stock, + category, + maxPerTeam, + }, + }) + + await logAdminAction( + AuditAction.INVENTORY_ITEM_CREATE, + session.user.id, + session.user.email, + "Item", + item.id, + { name, stock, category, maxPerTeam } + ) + + return NextResponse.json(item, { status: 201 }) +} diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts new file mode 100644 index 00000000..41140bc3 --- /dev/null +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" +import { pushSSE } from "@/lib/inventory/sse" +import { notifyTeam } from "@/lib/inventory/notifications" +import { OrderStatus } from "@/app/generated/prisma/client" + +const VALID_STATUSES: OrderStatus[] = ["IN_PROGRESS", "READY", "COMPLETED"] + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const adminResult = await requireAdmin() + if ("error" in adminResult) return adminResult.error + const { session } = adminResult + + const { id } = await params + const body = await request.json() + const { status } = body as { status: OrderStatus } + + if (!status || !VALID_STATUSES.includes(status)) { + return NextResponse.json( + { error: `Status must be one of: ${VALID_STATUSES.join(", ")}` }, + { status: 400 } + ) + } + + const order = await prisma.order.update({ + where: { id }, + data: { status }, + include: { + team: { select: { id: true, name: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + items: { include: { item: true } }, + }, + }) + + if (status === "READY") { + notifyTeam(order.teamId, "Your order is ready for pickup!") + } else if (status === "COMPLETED") { + notifyTeam(order.teamId, "Your order has been completed!") + } + + await logAdminAction( + AuditAction.INVENTORY_ORDER_STATUS_UPDATE, + session.user.id, + session.user.email, + "Order", + id, + { orderId: id, status } + ) + + pushSSE(order.teamId, { type: "order_status_updated", data: order }) + + return NextResponse.json(order) +} diff --git a/app/api/inventory/admin/orders/route.ts b/app/api/inventory/admin/orders/route.ts new file mode 100644 index 00000000..9014aaf7 --- /dev/null +++ b/app/api/inventory/admin/orders/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { OrderStatus } from "@/app/generated/prisma/client" + +export async function GET(request: Request) { + const adminResult = await requireAdmin() + if ("error" in adminResult) return adminResult.error + + const { searchParams } = new URL(request.url) + const status = searchParams.get("status") as OrderStatus | null + + const where = status ? { status } : {} + + const orders = await prisma.order.findMany({ + where, + include: { + team: { select: { id: true, name: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + items: { include: { item: true } }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json(orders) +} diff --git a/app/api/inventory/admin/rentals/[id]/return/route.ts b/app/api/inventory/admin/rentals/[id]/return/route.ts new file mode 100644 index 00000000..316d85ba --- /dev/null +++ b/app/api/inventory/admin/rentals/[id]/return/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" +import { pushSSE } from "@/lib/inventory/sse" +import { notifyTeam } from "@/lib/inventory/notifications" + +export async function PATCH( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const adminResult = await requireAdmin() + if ("error" in adminResult) return adminResult.error + const { session } = adminResult + + const { id } = await params + + const rental = await prisma.toolRental.findUnique({ + where: { id }, + include: { tool: true }, + }) + + if (!rental) { + return NextResponse.json({ error: "Rental not found" }, { status: 404 }) + } + + if (rental.status === "RETURNED") { + return NextResponse.json( + { error: "Rental has already been returned" }, + { status: 400 } + ) + } + + const updated = await prisma.$transaction(async (tx) => { + const result = await tx.toolRental.update({ + where: { id }, + data: { + status: "RETURNED", + returnedAt: new Date(), + }, + include: { + tool: true, + team: { select: { id: true, name: true } }, + rentedBy: { select: { id: true, name: true, email: true } }, + }, + }) + + await tx.tool.update({ + where: { id: rental.toolId }, + data: { available: true }, + }) + + return result + }) + + notifyTeam( + updated.teamId, + `Tool ${updated.tool.name} has been returned` + ) + + await logAdminAction( + AuditAction.INVENTORY_RENTAL_RETURN, + session.user.id, + session.user.email, + "ToolRental", + id, + { rentalId: id, toolId: rental.toolId } + ) + + pushSSE(updated.teamId, { type: "rental_returned", data: updated }) + + return NextResponse.json(updated) +} diff --git a/app/api/inventory/admin/rentals/route.ts b/app/api/inventory/admin/rentals/route.ts new file mode 100644 index 00000000..10f1ec5d --- /dev/null +++ b/app/api/inventory/admin/rentals/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" + +export async function GET() { + const adminResult = await requireAdmin() + if ("error" in adminResult) return adminResult.error + + const rentals = await prisma.toolRental.findMany({ + where: { status: "CHECKED_OUT" }, + include: { + tool: true, + team: { select: { id: true, name: true } }, + rentedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json(rentals) +} diff --git a/app/api/inventory/admin/settings/route.ts b/app/api/inventory/admin/settings/route.ts new file mode 100644 index 00000000..793cfb48 --- /dev/null +++ b/app/api/inventory/admin/settings/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function GET() { + const result = await requireAdmin() + if ("error" in result) return result.error + + const settings = await prisma.inventorySettings.upsert({ + where: { id: "singleton" }, + update: {}, + create: { id: "singleton", enabled: false }, + }) + + return NextResponse.json({ enabled: settings.enabled }) +} + +export async function PATCH(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + + const body = await request.json() + const { enabled } = body + + if (typeof enabled !== "boolean") { + return NextResponse.json( + { error: "enabled must be a boolean" }, + { status: 400 } + ) + } + + const settings = await prisma.inventorySettings.upsert({ + where: { id: "singleton" }, + update: { enabled }, + create: { id: "singleton", enabled }, + }) + + await logAdminAction( + AuditAction.INVENTORY_SETTINGS_UPDATE, + session.user.id, + session.user.email, + "InventorySettings", + "singleton", + { enabled } + ) + + return NextResponse.json({ enabled: settings.enabled }) +} diff --git a/app/api/inventory/admin/teams/[id]/lock/route.ts b/app/api/inventory/admin/teams/[id]/lock/route.ts new file mode 100644 index 00000000..7e2bc562 --- /dev/null +++ b/app/api/inventory/admin/teams/[id]/lock/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + const { id } = await params + + const existing = await prisma.team.findUnique({ where: { id } }) + if (!existing) return NextResponse.json({ error: "Team not found" }, { status: 404 }) + + const body = await request.json() + const { locked } = body + + if (typeof locked !== "boolean") { + return NextResponse.json( + { error: "locked must be a boolean" }, + { status: 400 } + ) + } + + const team = await prisma.team.update({ + where: { id }, + data: { locked }, + }) + + await logAdminAction( + AuditAction.INVENTORY_TEAM_LOCK, + session.user.id, + session.user.email, + "Team", + id, + { locked, teamName: existing.name } + ) + + return NextResponse.json({ id: team.id, locked: team.locked }) +} diff --git a/app/api/inventory/admin/teams/route.ts b/app/api/inventory/admin/teams/route.ts new file mode 100644 index 00000000..b3b1ec68 --- /dev/null +++ b/app/api/inventory/admin/teams/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" + +export async function GET() { + const result = await requireAdmin() + if ("error" in result) return result.error + + const teams = await prisma.team.findMany({ + include: { + members: { select: { id: true, name: true } }, + }, + orderBy: { name: "asc" }, + }) + + return NextResponse.json(teams) +} diff --git a/app/api/inventory/digikey/search/route.ts b/app/api/inventory/digikey/search/route.ts new file mode 100644 index 00000000..e1efb481 --- /dev/null +++ b/app/api/inventory/digikey/search/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { requireAdmin } from "@/lib/admin-auth" +import { searchDigiKey } from "@/lib/inventory/digikey" + +export async function GET(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { searchParams } = new URL(request.url) + const q = searchParams.get("q") + + if (!q || q.trim().length === 0) { + return NextResponse.json( + { error: "Query parameter 'q' is required" }, + { status: 400 } + ) + } + + try { + const results = await searchDigiKey(q.trim()) + return NextResponse.json(results) + } catch (error) { + console.error("[DigiKey] Search failed:", error) + return NextResponse.json( + { error: "DigiKey search failed. The service may not be configured." }, + { status: 502 } + ) + } +} diff --git a/app/api/inventory/items/[id]/route.ts b/app/api/inventory/items/[id]/route.ts new file mode 100644 index 00000000..44f5c000 --- /dev/null +++ b/app/api/inventory/items/[id]/route.ts @@ -0,0 +1,19 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const item = await prisma.item.findUnique({ where: { id } }) + if (!item) return NextResponse.json({ error: "Item not found" }, { status: 404 }) + + return NextResponse.json(item) +} diff --git a/app/api/inventory/items/route.ts b/app/api/inventory/items/route.ts new file mode 100644 index 00000000..8230f7cb --- /dev/null +++ b/app/api/inventory/items/route.ts @@ -0,0 +1,58 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const items = await prisma.item.findMany({ + orderBy: { name: "asc" }, + }) + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (user?.teamId) { + const usageRows = await prisma.orderItem.groupBy({ + by: ["itemId"], + where: { + order: { teamId: user.teamId }, + }, + _sum: { quantity: true }, + }) + + const usageMap = new Map( + usageRows.map((r) => [r.itemId, r._sum.quantity ?? 0]) + ) + + return NextResponse.json( + items.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + imageUrl: item.imageUrl, + stock: item.stock, + category: item.category, + maxPerTeam: item.maxPerTeam, + teamUsed: usageMap.get(item.id) ?? 0, + })) + ) + } + + return NextResponse.json( + items.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + imageUrl: item.imageUrl, + stock: item.stock, + category: item.category, + maxPerTeam: item.maxPerTeam, + teamUsed: 0, + })) + ) +} diff --git a/app/api/inventory/lookup/[slackUserId]/route.ts b/app/api/inventory/lookup/[slackUserId]/route.ts new file mode 100644 index 00000000..38fb6715 --- /dev/null +++ b/app/api/inventory/lookup/[slackUserId]/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server" +import { requireAdmin } from "@/lib/admin-auth" +import prisma from "@/lib/prisma" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slackUserId: string }> } +) { + const adminCheck = await requireAdmin() + if ("error" in adminCheck) return adminCheck.error + + const { slackUserId } = await params + + const user = await prisma.user.findUnique({ + where: { slackId: slackUserId }, + select: { + id: true, + name: true, + slackDisplayName: true, + image: true, + teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, + }, + }) + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ) + } + + if (!user.teamId) { + return NextResponse.json({ + user: { + id: user.id, + name: user.slackDisplayName ?? user.name, + image: user.image, + }, + team: null, + activeOrder: null, + activeRentals: [], + }) + } + + const [activeOrder, activeRentals] = await Promise.all([ + prisma.order.findFirst({ + where: { + teamId: user.teamId, + status: { not: "COMPLETED" }, + }, + include: { + items: { include: { item: true } }, + placedBy: { + select: { name: true, slackDisplayName: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }), + prisma.toolRental.findMany({ + where: { + teamId: user.teamId, + status: "CHECKED_OUT", + }, + include: { + tool: true, + rentedBy: { + select: { name: true, slackDisplayName: true }, + }, + }, + }), + ]) + + return NextResponse.json({ + user: { + id: user.id, + name: user.slackDisplayName ?? user.name, + image: user.image, + }, + team: user.team, + activeOrder, + activeRentals, + }) +} diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts new file mode 100644 index 00000000..c682b909 --- /dev/null +++ b/app/api/inventory/orders/route.ts @@ -0,0 +1,141 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { pushSSE } from "@/lib/inventory/sse" +import { notifyTeam } from "@/lib/inventory/notifications" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (!user?.teamId) { + return NextResponse.json( + { error: "You must be on a team to view orders" }, + { status: 403 } + ) + } + + const orders = await prisma.order.findMany({ + where: { teamId: user.teamId }, + include: { + items: { include: { item: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json(orders) +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { items, floor, location } = body as { + items: Array<{ itemId: string; quantity: number }> + floor: number + location: string + } + + if (!items || !Array.isArray(items) || items.length === 0) { + return NextResponse.json({ error: "Items are required" }, { status: 400 }) + } + + if (typeof floor !== "number" || !location) { + return NextResponse.json( + { error: "Floor and location are required" }, + { status: 400 } + ) + } + + const order = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + include: { team: true }, + }) + + if (!user?.teamId || !user.team) { + throw new Error("You must be on a team to place an order") + } + + if (user.team.locked) { + throw new Error("Your team is locked and cannot place orders") + } + + const activeOrder = await tx.order.findFirst({ + where: { + teamId: user.teamId, + status: { not: "COMPLETED" }, + }, + }) + + if (activeOrder) { + throw new Error("Your team already has an active order") + } + + for (const { itemId, quantity } of items) { + const item = await tx.item.findUnique({ where: { id: itemId } }) + if (!item) { + throw new Error(`Item ${itemId} not found`) + } + if (item.stock < quantity) { + throw new Error(`Insufficient stock for ${item.name}`) + } + + const usageResult = await tx.orderItem.aggregate({ + _sum: { quantity: true }, + where: { + itemId, + order: { teamId: user.teamId }, + }, + }) + const totalUsage = usageResult._sum.quantity ?? 0 + + if (totalUsage + quantity > item.maxPerTeam) { + throw new Error( + `Exceeds max per team for ${item.name} (limit: ${item.maxPerTeam}, used: ${totalUsage})` + ) + } + + await tx.item.update({ + where: { id: itemId }, + data: { stock: { decrement: quantity } }, + }) + } + + return tx.order.create({ + data: { + teamId: user.teamId, + placedById: session.user.id, + floor, + location, + items: { + create: items.map(({ itemId, quantity }) => ({ + itemId, + quantity, + })), + }, + }, + include: { + items: { include: { item: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + }, + }) + }) + + notifyTeam(order.teamId, "Your team's order has been placed!") + pushSSE(order.teamId, { type: "order_placed", data: order }) + + return NextResponse.json(order, { status: 201 }) +} diff --git a/app/api/inventory/rentals/route.ts b/app/api/inventory/rentals/route.ts new file mode 100644 index 00000000..e96d964e --- /dev/null +++ b/app/api/inventory/rentals/route.ts @@ -0,0 +1,123 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { pushSSE } from "@/lib/inventory/sse" +import { notifyTeam } from "@/lib/inventory/notifications" +import { + MAX_CONCURRENT_RENTALS, + TOOL_RENTAL_TIME_LIMIT_MINUTES, +} from "@/lib/inventory/config" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (!user?.teamId) { + return NextResponse.json( + { error: "You must be on a team to view rentals" }, + { status: 403 } + ) + } + + const rentals = await prisma.toolRental.findMany({ + where: { teamId: user.teamId }, + include: { + tool: true, + rentedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json(rentals) +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { toolId, floor, location } = body as { + toolId: string + floor: number + location: string + } + + if (!toolId || typeof floor !== "number" || !location) { + return NextResponse.json( + { error: "toolId, floor, and location are required" }, + { status: 400 } + ) + } + + const rental = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + include: { team: true }, + }) + + if (!user?.teamId || !user.team) { + throw new Error("You must be on a team to rent a tool") + } + + const tool = await tx.tool.findUnique({ where: { id: toolId } }) + if (!tool) { + throw new Error("Tool not found") + } + if (!tool.available) { + throw new Error("Tool is not available") + } + + const activeRentals = await tx.toolRental.count({ + where: { + teamId: user.teamId, + status: "CHECKED_OUT", + }, + }) + + if (activeRentals >= MAX_CONCURRENT_RENTALS) { + throw new Error( + `Your team already has ${MAX_CONCURRENT_RENTALS} active rental(s)` + ) + } + + await tx.tool.update({ + where: { id: toolId }, + data: { available: false }, + }) + + const dueAt = + TOOL_RENTAL_TIME_LIMIT_MINUTES > 0 + ? new Date(Date.now() + TOOL_RENTAL_TIME_LIMIT_MINUTES * 60 * 1000) + : null + + return tx.toolRental.create({ + data: { + toolId, + teamId: user.teamId, + rentedById: session.user.id, + floor, + location, + dueAt, + }, + include: { + tool: true, + rentedBy: { select: { id: true, name: true, email: true } }, + }, + }) + }) + + notifyTeam(rental.teamId, `Your team has rented: ${rental.tool.name}`) + pushSSE(rental.teamId, { type: "rental_created", data: rental }) + + return NextResponse.json(rental, { status: 201 }) +} diff --git a/app/api/inventory/sse/route.ts b/app/api/inventory/sse/route.ts new file mode 100644 index 00000000..dfb355f6 --- /dev/null +++ b/app/api/inventory/sse/route.ts @@ -0,0 +1,48 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextRequest } from "next/server" +import { registerConnection, removeConnection } from "@/lib/inventory/sse" + +export async function GET(request: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return new Response("Unauthorized", { status: 401 }) + } + + const teamId = request.nextUrl.searchParams.get("teamId") || "admin" + + const stream = new ReadableStream({ + start(controller) { + registerConnection(teamId, controller) + + // Send initial keepalive + controller.enqueue(new TextEncoder().encode(": keepalive\n\n")) + + // Keepalive interval + const interval = setInterval(() => { + try { + controller.enqueue(new TextEncoder().encode(": keepalive\n\n")) + } catch { + clearInterval(interval) + } + }, 30_000) + + // Clean up on abort + request.signal.addEventListener("abort", () => { + clearInterval(interval) + removeConnection(teamId, controller) + try { + controller.close() + } catch {} + }) + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }) +} diff --git a/app/api/inventory/teams/[id]/join/route.ts b/app/api/inventory/teams/[id]/join/route.ts new file mode 100644 index 00000000..d4bd041a --- /dev/null +++ b/app/api/inventory/teams/[id]/join/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { MAX_TEAM_SIZE } from "@/lib/inventory/config" + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + + const team = await prisma.team.findUnique({ + where: { id }, + include: { _count: { select: { members: true } } }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + if (team.locked) { + return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + } + + if (team._count.members >= MAX_TEAM_SIZE) { + return NextResponse.json({ error: "Team is full" }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (user?.teamId) { + return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: id }, + }) + }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/teams/[id]/leave/route.ts b/app/api/inventory/teams/[id]/leave/route.ts new file mode 100644 index 00000000..29b49e6a --- /dev/null +++ b/app/api/inventory/teams/[id]/leave/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (user?.teamId !== id) { + return NextResponse.json({ error: "You are not a member of this team" }, { status: 400 }) + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: null }, + }) + + const remaining = await tx.user.count({ where: { teamId: id } }) + + if (remaining === 0) { + await tx.team.delete({ where: { id } }) + } + }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/teams/[id]/members/[userId]/route.ts b/app/api/inventory/teams/[id]/members/[userId]/route.ts new file mode 100644 index 00000000..aef4f88a --- /dev/null +++ b/app/api/inventory/teams/[id]/members/[userId]/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; userId: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id, userId } = await params + + const team = await prisma.team.findUnique({ + where: { id }, + include: { members: { select: { id: true } } }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + if (team.locked) { + return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + } + + const isMember = team.members.some((m) => m.id === userId) + if (!isMember) { + return NextResponse.json({ error: "User is not a member of this team" }, { status: 400 }) + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { teamId: null }, + }) + + const remaining = await tx.user.count({ where: { teamId: id } }) + + if (remaining === 0) { + await tx.team.delete({ where: { id } }) + } + }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/teams/[id]/members/route.ts b/app/api/inventory/teams/[id]/members/route.ts new file mode 100644 index 00000000..5f43ed6d --- /dev/null +++ b/app/api/inventory/teams/[id]/members/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { MAX_TEAM_SIZE } from "@/lib/inventory/config" + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const body = await request.json() + const { slackId } = body + + if (!slackId || typeof slackId !== "string") { + return NextResponse.json({ error: "slackId is required" }, { status: 400 }) + } + + const team = await prisma.team.findUnique({ + where: { id }, + include: { _count: { select: { members: true } } }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + if (team.locked) { + return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + } + + if (team._count.members >= MAX_TEAM_SIZE) { + return NextResponse.json({ error: "Team is full" }, { status: 400 }) + } + + const targetUser = await prisma.user.findUnique({ + where: { slackId }, + select: { id: true, teamId: true }, + }) + + if (!targetUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + if (targetUser.teamId) { + return NextResponse.json({ error: "User is already on a team" }, { status: 400 }) + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: targetUser.id }, + data: { teamId: id }, + }) + }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/teams/[id]/route.ts b/app/api/inventory/teams/[id]/route.ts new file mode 100644 index 00000000..a09908eb --- /dev/null +++ b/app/api/inventory/teams/[id]/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + + const team = await prisma.team.findUnique({ + where: { id }, + include: { + members: { + select: { + id: true, + name: true, + slackDisplayName: true, + image: true, + }, + }, + }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + return NextResponse.json(team) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const body = await request.json() + const { name } = body + + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json({ error: "Team name is required" }, { status: 400 }) + } + + const trimmedName = name.trim() + + const team = await prisma.team.findUnique({ + where: { id }, + include: { members: { select: { id: true } } }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + if (team.locked) { + return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + } + + const isMember = team.members.some((m) => m.id === session.user.id) + if (!isMember) { + return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) + } + + const existing = await prisma.team.findUnique({ where: { name: trimmedName } }) + if (existing && existing.id !== id) { + return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) + } + + const updated = await prisma.team.update({ + where: { id }, + data: { name: trimmedName }, + }) + + return NextResponse.json(updated) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + + const team = await prisma.team.findUnique({ + where: { id }, + include: { members: { select: { id: true } } }, + }) + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }) + } + + const isMember = team.members.some((m) => m.id === session.user.id) + if (!isMember) { + return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) + } + + if (team.members.length > 1) { + return NextResponse.json( + { error: "Cannot delete a team with other members. All other members must leave first." }, + { status: 400 } + ) + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: null }, + }) + + await tx.team.delete({ where: { id } }) + }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/teams/route.ts b/app/api/inventory/teams/route.ts new file mode 100644 index 00000000..eb947281 --- /dev/null +++ b/app/api/inventory/teams/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const teams = await prisma.team.findMany({ + select: { + id: true, + name: true, + locked: true, + createdAt: true, + _count: { select: { members: true } }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json(teams) +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { name } = body + + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json({ error: "Team name is required" }, { status: 400 }) + } + + const trimmedName = name.trim() + + const existing = await prisma.team.findUnique({ where: { name: trimmedName } }) + if (existing) { + return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (user?.teamId) { + return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) + } + + const team = await prisma.$transaction(async (tx) => { + const created = await tx.team.create({ + data: { name: trimmedName }, + }) + + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: created.id }, + }) + + return created + }) + + return NextResponse.json(team, { status: 201 }) +} diff --git a/app/api/inventory/tools/route.ts b/app/api/inventory/tools/route.ts new file mode 100644 index 00000000..14a99ddb --- /dev/null +++ b/app/api/inventory/tools/route.ts @@ -0,0 +1,12 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const tools = await prisma.tool.findMany({ orderBy: { name: "asc" } }) + return NextResponse.json(tools) +} diff --git a/app/components/inventory/CartPanel.tsx b/app/components/inventory/CartPanel.tsx new file mode 100644 index 00000000..40322171 --- /dev/null +++ b/app/components/inventory/CartPanel.tsx @@ -0,0 +1,79 @@ +'use client'; + +interface CartItem { + itemId: string; + name: string; + quantity: number; +} + +interface CartPanelProps { + items: CartItem[]; + onUpdateQuantity: (itemId: string, qty: number) => void; + onRemove: (itemId: string) => void; + onCheckout: () => void; + disabled?: boolean; +} + +export function CartPanel({ items, onUpdateQuantity, onRemove, onCheckout, disabled }: CartPanelProps) { + const totalItems = items.reduce((sum, item) => sum + item.quantity, 0); + const isEmpty = items.length === 0; + + return ( +
+

Cart

+ + {isEmpty ? ( +

No items in cart.

+ ) : ( +
    + {items.map(item => ( +
  • + {item.name} +
    +
    + + + {item.quantity} + + +
    + +
    +
  • + ))} +
+ )} + +
+
+ Total items + {totalItems} +
+ +
+
+ ); +} diff --git a/app/components/inventory/CheckoutModal.tsx b/app/components/inventory/CheckoutModal.tsx new file mode 100644 index 00000000..81ac622d --- /dev/null +++ b/app/components/inventory/CheckoutModal.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState } from 'react'; + +interface CheckoutItem { + itemId: string; + name: string; + quantity: number; +} + +interface CheckoutModalProps { + isOpen: boolean; + onClose: () => void; + items: CheckoutItem[]; + onConfirm: (floor: number, location: string) => void; + isSubmitting?: boolean; +} + +export function CheckoutModal({ isOpen, onClose, items, onConfirm, isSubmitting }: CheckoutModalProps) { + const [floor, setFloor] = useState(1); + const [location, setLocation] = useState(''); + + if (!isOpen) return null; + + const canConfirm = location.trim().length > 0 && !isSubmitting; + + return ( +
+
+
+

Confirm Order

+ + {/* Order summary */} +
+

Items

+
    + {items.map(item => ( +
  • + {item.name} + x{item.quantity} +
  • + ))} +
+
+ + {/* Floor dropdown */} +
+ + +
+ + {/* Location input */} +
+ + setLocation(e.target.value)} + placeholder="Room number or table" + className="w-full border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + /> +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/app/components/inventory/ItemCard.tsx b/app/components/inventory/ItemCard.tsx new file mode 100644 index 00000000..8dca316e --- /dev/null +++ b/app/components/inventory/ItemCard.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; + +interface ItemCardProps { + item: { + id: string; + name: string; + description?: string; + imageUrl?: string; + stock: number; + category: string; + maxPerTeam: number; + teamUsed?: number; + }; + onAdd: (itemId: string, quantity: number) => void; +} + +export function ItemCard({ item, onAdd }: ItemCardProps) { + const remaining = item.maxPerTeam - (item.teamUsed ?? 0); + const canAdd = remaining > 0 && item.stock > 0; + const maxQuantity = Math.min(remaining, item.stock); + const [quantity, setQuantity] = useState(1); + + const decrement = () => setQuantity(q => Math.max(1, q - 1)); + const increment = () => setQuantity(q => Math.min(maxQuantity, q + 1)); + + return ( +
+ {/* Image */} + {item.imageUrl ? ( + {item.name} + ) : ( +
+ No image +
+ )} + + {/* Category badge */} + + {item.category} + + + {/* Name */} +

{item.name}

+ + {/* Description */} + {item.description && ( +

{item.description}

+ )} + +
+ {/* Stock info */} +
+ {item.stock} in stock + {remaining} of {item.maxPerTeam} remaining +
+ + {/* Quantity selector + Add button */} +
+
+ + + {quantity} + + +
+ +
+
+
+ ); +} diff --git a/app/components/inventory/NFCScanner.tsx b/app/components/inventory/NFCScanner.tsx new file mode 100644 index 00000000..ac0f50da --- /dev/null +++ b/app/components/inventory/NFCScanner.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +interface NFCScannerProps { + mode: 'read' | 'write'; + writeData?: string; + onRead: (slackUserId: string) => void; + onWriteComplete?: () => void; +} + +export function NFCScanner({ mode, writeData, onRead, onWriteComplete }: NFCScannerProps) { + const [scanning, setScanning] = useState(false); + const [status, setStatus] = useState(''); + const [manualId, setManualId] = useState(''); + + const supportsNFC = typeof window !== 'undefined' && 'NDEFReader' in window; + + const startScan = useCallback(async () => { + if (!supportsNFC) return; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const NDEFReader = (window as any).NDEFReader; + const reader = new NDEFReader(); + + if (mode === 'read') { + setScanning(true); + setStatus('Scanning... Hold NFC tag near device.'); + await reader.scan(); + + reader.addEventListener('reading', ({ message }: { message: { records: Array<{ recordType: string; data: ArrayBuffer }> } }) => { + for (const record of message.records) { + if (record.recordType === 'text') { + const decoder = new TextDecoder(); + const value = decoder.decode(record.data); + setScanning(false); + setStatus('Tag read successfully.'); + onRead(value); + return; + } + } + setStatus('No text record found on tag.'); + }); + + reader.addEventListener('readingerror', () => { + setStatus('Error reading tag. Try again.'); + }); + } else if (mode === 'write' && writeData) { + setScanning(true); + setStatus('Hold NFC tag near device to write...'); + await reader.write({ + records: [{ recordType: 'text', data: writeData }], + }); + setScanning(false); + setStatus('Tag written successfully.'); + onWriteComplete?.(); + } + } catch (err) { + setScanning(false); + setStatus(`NFC error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + }, [mode, writeData, onRead, onWriteComplete, supportsNFC]); + + const stopScan = () => { + setScanning(false); + setStatus(''); + }; + + // Fallback: manual entry + if (!supportsNFC) { + return ( +
+

+ NFC not supported on this device +

+
+ setManualId(e.target.value)} + placeholder="Enter Slack User ID" + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + /> + +
+
+ ); + } + + return ( +
+
+ + NFC {mode === 'read' ? 'Reader' : 'Writer'} + + {scanning && ( + + )} +
+ + {status && ( +

{status}

+ )} + + +
+ ); +} diff --git a/app/components/inventory/OrderStatusBar.tsx b/app/components/inventory/OrderStatusBar.tsx new file mode 100644 index 00000000..fb2e5119 --- /dev/null +++ b/app/components/inventory/OrderStatusBar.tsx @@ -0,0 +1,58 @@ +'use client'; + +type OrderStatus = 'PLACED' | 'IN_PROGRESS' | 'READY' | 'COMPLETED'; + +interface OrderStatusBarProps { + status: OrderStatus; +} + +const STEPS: { key: OrderStatus; label: string }[] = [ + { key: 'PLACED', label: 'Placed' }, + { key: 'IN_PROGRESS', label: 'In Progress' }, + { key: 'READY', label: 'Ready' }, + { key: 'COMPLETED', label: 'Completed' }, +]; + +export function OrderStatusBar({ status }: OrderStatusBarProps) { + const currentIndex = STEPS.findIndex(s => s.key === status); + + return ( +
+ {STEPS.map((step, i) => { + const filled = i <= currentIndex; + return ( +
+ {/* Step dot + label */} +
+
+ {i + 1} +
+ + {step.label} + +
+ + {/* Connector line */} + {i < STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/app/components/inventory/RentalTimer.tsx b/app/components/inventory/RentalTimer.tsx new file mode 100644 index 00000000..b5f89521 --- /dev/null +++ b/app/components/inventory/RentalTimer.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface RentalTimerProps { + dueAt: string | null; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || hours > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(' '); +} + +export function RentalTimer({ dueAt }: RentalTimerProps) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!dueAt) return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [dueAt]); + + if (!dueAt) { + return No time limit; + } + + const due = new Date(dueAt).getTime(); + const diff = due - now; + const overdue = diff <= 0; + + return ( + + {overdue ? 'Overdue' : formatDuration(diff)} + + ); +} diff --git a/app/components/inventory/ToolCard.tsx b/app/components/inventory/ToolCard.tsx new file mode 100644 index 00000000..a0484814 --- /dev/null +++ b/app/components/inventory/ToolCard.tsx @@ -0,0 +1,60 @@ +'use client'; + +interface ToolCardProps { + tool: { + id: string; + name: string; + description?: string; + imageUrl?: string; + available: boolean; + }; + onRent: (toolId: string) => void; +} + +export function ToolCard({ tool, onRent }: ToolCardProps) { + return ( +
+ {/* Image */} + {tool.imageUrl ? ( + {tool.name} + ) : ( +
+ No image +
+ )} + + {/* Availability badge */} + + {tool.available ? 'Available' : 'Unavailable'} + + + {/* Name */} +

{tool.name}

+ + {/* Description */} + {tool.description && ( +

{tool.description}

+ )} + +
+ +
+
+ ); +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 1690ff8c..3fe986cf 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -147,6 +147,9 @@ export default function DashboardLayout({ Guidelines & FAQ + + Inventory +
diff --git a/app/inventory/admin/items/page.tsx b/app/inventory/admin/items/page.tsx new file mode 100644 index 00000000..4cbfcb28 --- /dev/null +++ b/app/inventory/admin/items/page.tsx @@ -0,0 +1,671 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +interface Item { + id: string; + name: string; + description?: string; + category: string; + stock: number; + maxPerTeam: number; + imageUrl?: string; +} + +interface DigiKeyImage { + url: string; + description?: string; +} + +interface CSVRow { + name: string; + description: string; + category: string; + stock: number; + max_per_team: number; + image_url: string; +} + +function parseCSV(text: string): CSVRow[] { + const lines = text.split('\n').filter((l) => l.trim()); + if (lines.length < 2) return []; + + const parseRow = (line: string): string[] => { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch === ',' && !inQuotes) { + fields.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + fields.push(current.trim()); + return fields; + }; + + const headers = parseRow(lines[0]).map((h) => h.toLowerCase().replace(/\s+/g, '_')); + const rows: CSVRow[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseRow(lines[i]); + if (values.length < headers.length) continue; + + const nameIdx = headers.indexOf('name'); + const descIdx = headers.indexOf('description'); + const catIdx = headers.indexOf('category'); + const stockIdx = headers.indexOf('stock'); + const maxIdx = headers.indexOf('max_per_team'); + const imgIdx = headers.indexOf('image_url'); + + if (nameIdx === -1 || catIdx === -1 || stockIdx === -1 || maxIdx === -1) continue; + + rows.push({ + name: values[nameIdx] || '', + description: descIdx >= 0 ? values[descIdx] || '' : '', + category: values[catIdx] || '', + stock: parseInt(values[stockIdx], 10) || 0, + max_per_team: parseInt(values[maxIdx], 10) || 0, + image_url: imgIdx >= 0 ? values[imgIdx] || '' : '', + }); + } + + return rows; +} + +export default function AdminItemsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + // Add form + const [formName, setFormName] = useState(''); + const [formDescription, setFormDescription] = useState(''); + const [formCategory, setFormCategory] = useState(''); + const [formStock, setFormStock] = useState(''); + const [formMaxPerTeam, setFormMaxPerTeam] = useState(''); + const [formImageUrl, setFormImageUrl] = useState(''); + const [formSubmitting, setFormSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + // Edit + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [editCategory, setEditCategory] = useState(''); + const [editStock, setEditStock] = useState(''); + const [editMaxPerTeam, setEditMaxPerTeam] = useState(''); + const [editImageUrl, setEditImageUrl] = useState(''); + const [editSubmitting, setEditSubmitting] = useState(false); + + // DigiKey search + const [digiKeyQuery, setDigiKeyQuery] = useState(''); + const [digiKeyResults, setDigiKeyResults] = useState([]); + const [digiKeyLoading, setDigiKeyLoading] = useState(false); + const [digiKeyTarget, setDigiKeyTarget] = useState<'add' | 'edit'>('add'); + + // CSV import + const [csvData, setCsvData] = useState(null); + const [csvImporting, setCsvImporting] = useState(false); + const [csvResult, setCsvResult] = useState(null); + + const fetchItems = useCallback(async () => { + try { + const res = await fetch('/api/inventory/items'); + if (res.ok) { + const data = await res.json(); + setItems(data); + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + const handleAddItem = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + if (!formName || !formCategory || !formStock || !formMaxPerTeam) { + setFormError('Name, category, stock, and max per team are required.'); + return; + } + setFormSubmitting(true); + try { + const res = await fetch('/api/inventory/admin/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formName, + description: formDescription || undefined, + category: formCategory, + stock: parseInt(formStock, 10), + maxPerTeam: parseInt(formMaxPerTeam, 10), + imageUrl: formImageUrl || undefined, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + setFormError(err?.error || 'Failed to add item.'); + return; + } + setFormName(''); + setFormDescription(''); + setFormCategory(''); + setFormStock(''); + setFormMaxPerTeam(''); + setFormImageUrl(''); + await fetchItems(); + } catch { + setFormError('Failed to add item.'); + } finally { + setFormSubmitting(false); + } + }; + + const handleDeleteItem = async (id: string) => { + if (!confirm('Are you sure you want to delete this item?')) return; + try { + const res = await fetch(`/api/inventory/admin/items/${id}`, { + method: 'DELETE', + }); + if (res.ok) { + await fetchItems(); + } + } catch { + // silently fail + } + }; + + const startEdit = (item: Item) => { + setEditingId(item.id); + setEditName(item.name); + setEditDescription(item.description || ''); + setEditCategory(item.category); + setEditStock(String(item.stock)); + setEditMaxPerTeam(String(item.maxPerTeam)); + setEditImageUrl(item.imageUrl || ''); + }; + + const cancelEdit = () => { + setEditingId(null); + }; + + const handleEditSubmit = async (id: string) => { + setEditSubmitting(true); + try { + const res = await fetch(`/api/inventory/admin/items/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: editName, + description: editDescription || undefined, + category: editCategory, + stock: parseInt(editStock, 10), + maxPerTeam: parseInt(editMaxPerTeam, 10), + imageUrl: editImageUrl || undefined, + }), + }); + if (res.ok) { + setEditingId(null); + await fetchItems(); + } + } catch { + // silently fail + } finally { + setEditSubmitting(false); + } + }; + + const handleDigiKeySearch = async () => { + if (!digiKeyQuery.trim()) return; + setDigiKeyLoading(true); + setDigiKeyResults([]); + try { + const res = await fetch( + `/api/inventory/digikey/search?q=${encodeURIComponent(digiKeyQuery)}` + ); + if (res.ok) { + const data = await res.json(); + setDigiKeyResults(data); + } + } catch { + // silently fail + } finally { + setDigiKeyLoading(false); + } + }; + + const selectDigiKeyImage = (url: string) => { + if (digiKeyTarget === 'add') { + setFormImageUrl(url); + } else { + setEditImageUrl(url); + } + setDigiKeyResults([]); + setDigiKeyQuery(''); + }; + + const handleCSVFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setCsvResult(null); + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + const parsed = parseCSV(text); + setCsvData(parsed); + }; + reader.readAsText(file); + }; + + const handleCSVImport = async () => { + if (!csvData || csvData.length === 0) return; + setCsvImporting(true); + setCsvResult(null); + try { + const res = await fetch('/api/inventory/admin/items/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items: csvData }), + }); + if (res.ok) { + const data = await res.json(); + setCsvResult(`Imported ${data.count ?? csvData.length} items successfully.`); + setCsvData(null); + await fetchItems(); + } else { + const err = await res.json().catch(() => null); + setCsvResult(`Import failed: ${err?.error || 'Unknown error'}`); + } + } catch { + setCsvResult('Import failed.'); + } finally { + setCsvImporting(false); + } + }; + + return ( +
+ {/* Add Item Form */} +
+

+ Add Item +

+
+
+
+ + setFormName(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + required + /> +
+
+ + setFormCategory(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + required + /> +
+
+ + setFormStock(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + min="0" + required + /> +
+
+ + setFormMaxPerTeam(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + min="0" + required + /> +
+
+
+ + setFormDescription(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + /> +
+
+ + setFormImageUrl(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + placeholder="https://..." + /> +
+ {formError &&

{formError}

} + +
+
+ + {/* DigiKey Image Search */} +
+

+ DigiKey Image Search +

+
+ + +
+
+ setDigiKeyQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleDigiKeySearch()} + placeholder="Search for component images..." + className="flex-1 border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + /> + +
+ {digiKeyResults.length > 0 && ( +
+ {digiKeyResults.map((img, i) => ( + + ))} +
+ )} +
+ + {/* Items Table */} +
+

+ Items ({items.length}) +

+ {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +

No items in inventory.

+ ) : ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + {editingId === item.id ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} + + ))} + +
+ Name + + Category + + Stock + + Max/Team + + Image + + Actions +
+ setEditName(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" + /> + + setEditCategory(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" + /> + + setEditStock(e.target.value)} + className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" + min="0" + /> + + setEditMaxPerTeam(e.target.value)} + className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" + min="0" + /> + + setEditImageUrl(e.target.value)} + className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" + placeholder="URL" + /> + +
+ + +
+
{item.name}{item.category}{item.stock}{item.maxPerTeam} + {item.imageUrl ? ( + {item.name} + ) : ( + -- + )} + +
+ + +
+
+
+ )} +
+ + {/* CSV Import */} +
+

+ CSV Import +

+

+ Expected columns: name, description, category, stock, max_per_team, image_url +

+ + + {csvData && csvData.length > 0 && ( +
+

+ Preview ({csvData.length} rows): +

+
+ + + + + + + + + + + + + {csvData.map((row, i) => ( + + + + + + + + + ))} + +
NameDescriptionCategoryStockMax/TeamImage URL
{row.name} + {row.description} + {row.category}{row.stock}{row.max_per_team} + {row.image_url} +
+
+ +
+ )} + + {csvResult && ( +

+ {csvResult} +

+ )} +
+
+ ); +} diff --git a/app/inventory/admin/layout.tsx b/app/inventory/admin/layout.tsx new file mode 100644 index 00000000..8b30c811 --- /dev/null +++ b/app/inventory/admin/layout.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useSession } from "@/lib/auth-client"; +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +interface Role { + role: string; +} + +export default function AdminInventoryLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { data: session, isPending } = useSession(); + const pathname = usePathname(); + const [isAdmin, setIsAdmin] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!session) { + setLoading(false); + return; + } + fetch('/api/user/roles') + .then(res => res.json()) + .then((data: { roles: string[] }) => { + const admin = data.roles.some( + (r) => r === 'ADMIN' || r === 'REVIEWER' + ); + setIsAdmin(admin); + setLoading(false); + }) + .catch(() => { + setIsAdmin(false); + setLoading(false); + }); + }, [session]); + + const tabs = [ + { label: 'Orders', href: '/inventory/admin' }, + { label: 'Rentals', href: '/inventory/admin/rentals' }, + { label: 'Items', href: '/inventory/admin/items' }, + { label: 'Teams', href: '/inventory/admin/teams' }, + { label: 'Settings', href: '/inventory/admin/settings' }, + ]; + + const getTabClass = (href: string) => { + const isActive = + href === '/inventory/admin' + ? pathname === '/inventory/admin' + : pathname.startsWith(href); + + return `px-4 py-2 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ + isActive + ? 'text-orange-500 border-orange-500' + : 'text-brown-800 border-transparent hover:text-orange-500' + }`; + }; + + if (isPending || loading) { + return ( +
+
+
+ ); + } + + if (!session) { + return ( +
+
+

+ Not Authenticated +

+

Please sign in to access admin.

+
+
+ ); + } + + if (!isAdmin) { + return ( +
+
+

+ Access Denied +

+

+ You do not have permission to access the admin panel. +

+ + Back to Inventory + +
+
+ ); + } + + return ( +
+ {/* Admin sub-tabs */} +
+
+ {tabs.map((tab) => ( + + {tab.label} + + ))} +
+
+ + {children} +
+ ); +} diff --git a/app/inventory/admin/page.tsx b/app/inventory/admin/page.tsx new file mode 100644 index 00000000..c3338944 --- /dev/null +++ b/app/inventory/admin/page.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useInventorySSE } from '@/lib/hooks/useInventorySSE'; + +interface OrderItem { + id: string; + item: { id: string; name: string }; + quantity: number; +} + +interface Order { + id: string; + team: { id: string; name: string }; + placedBy: { id: string; name: string; email?: string }; + floor: number; + location: string; + status: 'PLACED' | 'IN_PROGRESS' | 'READY' | 'COMPLETED'; + items: OrderItem[]; + createdAt: string; +} + +interface LookupResult { + user: { + name: string; + email?: string; + slackId: string; + }; + team?: { + name: string; + }; + activeOrder?: Order; + activeRentals?: { id: string; toolName: string; checkedOutAt: string }[]; +} + +const STATUS_COLORS: Record = { + PLACED: 'bg-cream-200 text-brown-800 border-brown-800', + IN_PROGRESS: 'bg-orange-400 text-cream-50 border-orange-500', + READY: 'bg-orange-500 text-cream-50 border-orange-600', + COMPLETED: 'bg-brown-800 text-cream-50 border-brown-900', +}; + +const STATUS_TABS = ['All', 'PLACED', 'IN_PROGRESS', 'READY', 'COMPLETED'] as const; + +export default function AdminOrdersPage() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState('All'); + const [updating, setUpdating] = useState(null); + + // NFC / Lookup + const [lookupInput, setLookupInput] = useState(''); + const [lookupResult, setLookupResult] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + const [lookupError, setLookupError] = useState(null); + + const sseEvent = useInventorySSE('admin'); + + const fetchOrders = useCallback(async () => { + try { + const params = statusFilter !== 'All' ? `?status=${statusFilter}` : ''; + const res = await fetch(`/api/inventory/admin/orders${params}`); + if (res.ok) { + const data = await res.json(); + setOrders(data); + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + setLoading(true); + fetchOrders(); + }, [fetchOrders]); + + // Refetch on SSE event + useEffect(() => { + if (sseEvent) { + fetchOrders(); + } + }, [sseEvent, fetchOrders]); + + const updateStatus = async (orderId: string, newStatus: string) => { + setUpdating(orderId); + try { + const res = await fetch(`/api/inventory/admin/orders/${orderId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + if (res.ok) { + await fetchOrders(); + } + } catch { + // silently fail + } finally { + setUpdating(null); + } + }; + + const handleLookup = async (slackId?: string) => { + const id = slackId || lookupInput.trim(); + if (!id) return; + setLookupLoading(true); + setLookupError(null); + setLookupResult(null); + try { + const res = await fetch(`/api/inventory/lookup/${encodeURIComponent(id)}`); + if (!res.ok) { + setLookupError('User not found.'); + return; + } + const data = await res.json(); + setLookupResult(data); + } catch { + setLookupError('Lookup failed.'); + } finally { + setLookupLoading(false); + } + }; + + const handleNFCScan = async () => { + try { + if (!('NDEFReader' in window)) { + setLookupError('NFC not supported on this device.'); + return; + } + // @ts-expect-error NDEFReader is not in all TS libs + const ndef = new NDEFReader(); + await ndef.scan(); + ndef.addEventListener('reading', ({ serialNumber }: { serialNumber: string }) => { + handleLookup(serialNumber); + }); + } catch { + setLookupError('NFC scan failed or was cancelled.'); + } + }; + + const getNextStatus = (status: string): string | null => { + switch (status) { + case 'PLACED': return 'IN_PROGRESS'; + case 'IN_PROGRESS': return 'READY'; + case 'READY': return 'COMPLETED'; + default: return null; + } + }; + + const getActionLabel = (status: string): string | null => { + switch (status) { + case 'PLACED': return 'Start'; + case 'IN_PROGRESS': return 'Mark Ready'; + case 'READY': return 'Mark Completed'; + default: return null; + } + }; + + return ( +
+ {/* NFC Lookup Section */} +
+

+ Badge Lookup +

+
+
+ + setLookupInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleLookup()} + placeholder="Enter Slack user ID..." + className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" + /> +
+ + +
+ + {lookupLoading && ( +

Looking up...

+ )} + {lookupError && ( +

{lookupError}

+ )} + + {lookupResult && ( +
+
+
+

{lookupResult.user.name}

+ {lookupResult.user.email && ( +

{lookupResult.user.email}

+ )} +

+ Slack: {lookupResult.user.slackId} +

+ {lookupResult.team && ( +

+ Team: {lookupResult.team.name} +

+ )} +
+ +
+ + {lookupResult.activeOrder && ( +
+

+ Active Order +

+ + {lookupResult.activeOrder.status.replace('_', ' ')} + +
    + {lookupResult.activeOrder.items.map((item) => ( +
  • + {item.item.name} x{item.quantity} +
  • + ))} +
+
+ )} + + {lookupResult.activeRentals && lookupResult.activeRentals.length > 0 && ( +
+

+ Active Rentals +

+
    + {lookupResult.activeRentals.map((r) => ( +
  • {r.toolName}
  • + ))} +
+
+ )} +
+ )} +
+ + {/* Status Filter Tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Orders List */} + {loading ? ( +
+
+
+ ) : orders.length === 0 ? ( +
+

No orders found

+
+ ) : ( +
+ {orders.map((order) => { + const nextStatus = getNextStatus(order.status); + const actionLabel = getActionLabel(order.status); + + return ( +
+
+
+

+ {order.team.name} +

+

+ Placed by {order.placedBy.name} + {order.floor && ` -- Floor ${order.floor}`} + {order.location && ` -- ${order.location}`} +

+

+ {new Date(order.createdAt).toLocaleString()} +

+
+ + {order.status.replace('_', ' ')} + +
+ + {/* Items */} +
+
    + {order.items.map((item) => ( +
  • + {item.item.name} x{item.quantity} +
  • + ))} +
+
+ + {/* Actions */} + {nextStatus && actionLabel && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/inventory/admin/rentals/page.tsx b/app/inventory/admin/rentals/page.tsx new file mode 100644 index 00000000..8151c3be --- /dev/null +++ b/app/inventory/admin/rentals/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useInventorySSE } from '@/lib/hooks/useInventorySSE'; + +interface Rental { + id: string; + tool: { id: string; name: string }; + team: { id: string; name: string }; + rentedBy: { id: string; name: string; email?: string }; + floor: number; + location: string; + createdAt: string; + dueAt?: string; +} + +export default function AdminRentalsPage() { + const [rentals, setRentals] = useState([]); + const [loading, setLoading] = useState(true); + const [returning, setReturning] = useState(null); + + const sseEvent = useInventorySSE('admin'); + + const fetchRentals = useCallback(async () => { + try { + const res = await fetch('/api/inventory/admin/rentals'); + if (res.ok) { + const data = await res.json(); + setRentals(data); + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRentals(); + }, [fetchRentals]); + + // Refetch on SSE event + useEffect(() => { + if (sseEvent) { + fetchRentals(); + } + }, [sseEvent, fetchRentals]); + + const markReturned = async (rentalId: string) => { + setReturning(rentalId); + try { + const res = await fetch(`/api/inventory/admin/rentals/${rentalId}/return`, { + method: 'PATCH', + }); + if (res.ok) { + await fetchRentals(); + } + } catch { + // silently fail + } finally { + setReturning(null); + } + }; + + const isOverdue = (dueAt?: string) => { + if (!dueAt) return false; + return new Date(dueAt) < new Date(); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+

+ Active Rentals ({rentals.length}) +

+ + {rentals.length === 0 ? ( +

No active rentals.

+ ) : ( +
+ + + + + + + + + + + + + + {rentals.map((rental) => { + const overdue = isOverdue(rental.dueAt); + return ( + + + + + + + + + + ); + })} + +
+ Tool + + Team + + Rented By + + Location + + Checked Out + + Due At + + Actions +
+ {rental.tool.name} + {overdue && ( + + Overdue + + )} + {rental.team.name}{rental.rentedBy.name} + {rental.floor && `Floor ${rental.floor}`} + {rental.floor && rental.location && ' - '} + {rental.location} + {!rental.floor && !rental.location && '--'} + + {new Date(rental.createdAt).toLocaleString()} + + {rental.dueAt + ? new Date(rental.dueAt).toLocaleString() + : '--'} + + +
+
+ )} +
+ ); +} diff --git a/app/inventory/admin/settings/page.tsx b/app/inventory/admin/settings/page.tsx new file mode 100644 index 00000000..91d4c1d9 --- /dev/null +++ b/app/inventory/admin/settings/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface Settings { + enabled: boolean; +} + +export default function AdminSettingsPage() { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/inventory/admin/settings') + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => { + setError('Failed to load settings.'); + setLoading(false); + }); + }, []); + + const toggleEnabled = async () => { + if (!settings) return; + setToggling(true); + setError(null); + try { + const res = await fetch('/api/inventory/admin/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !settings.enabled }), + }); + if (res.ok) { + const data = await res.json(); + setSettings(data); + } else { + setError('Failed to update settings.'); + } + } catch { + setError('Failed to update settings.'); + } finally { + setToggling(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+

+ Settings +

+ + {error &&

{error}

} + +
+ +
+
+ ); +} diff --git a/app/inventory/admin/teams/page.tsx b/app/inventory/admin/teams/page.tsx new file mode 100644 index 00000000..801b2bc0 --- /dev/null +++ b/app/inventory/admin/teams/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +interface TeamMember { + id: string; + name: string; +} + +interface Team { + id: string; + name: string; + members: TeamMember[]; + locked: boolean; +} + +export default function AdminTeamsPage() { + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(null); + const [expandedTeam, setExpandedTeam] = useState(null); + + const fetchTeams = useCallback(async () => { + try { + const res = await fetch('/api/inventory/admin/teams'); + if (res.ok) { + const data = await res.json(); + setTeams(data); + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTeams(); + }, [fetchTeams]); + + const toggleLock = async (team: Team) => { + setToggling(team.id); + try { + const res = await fetch(`/api/inventory/admin/teams/${team.id}/lock`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locked: !team.locked }), + }); + if (res.ok) { + await fetchTeams(); + } + } catch { + // silently fail + } finally { + setToggling(null); + } + }; + + const toggleExpand = (teamId: string) => { + setExpandedTeam(expandedTeam === teamId ? null : teamId); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+

+ Teams ({teams.length}) +

+ + {teams.length === 0 ? ( +

No teams found.

+ ) : ( +
+ + + + + + + + + + + {teams.map((team) => ( + <> + + + + + + + {expandedTeam === team.id && ( + + + + )} + + ))} + +
+ Team Name + + Members + + Status + + Actions +
+ + + {team.members.length} + + + {team.locked ? 'Locked' : 'Active'} + + + +
+ {team.members.length === 0 ? ( +

No members

+ ) : ( +
    + {team.members.map((member) => ( +
  • + {member.name} +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/app/inventory/dashboard/page.tsx b/app/inventory/dashboard/page.tsx new file mode 100644 index 00000000..ce652546 --- /dev/null +++ b/app/inventory/dashboard/page.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useSession } from '@/lib/auth-client'; +import { useInventorySSE } from '@/lib/hooks/useInventorySSE'; +import { OrderStatusBar } from '@/app/components/inventory/OrderStatusBar'; +import { RentalTimer } from '@/app/components/inventory/RentalTimer'; +import Link from 'next/link'; + +interface AccessInfo { + allowed: boolean; + reason?: string; + isAdmin: boolean; + teamId?: string; + teamName?: string; +} + +interface OrderItem { + id: string; + quantity: number; + item: { id: string; name: string; imageUrl?: string }; +} + +interface Order { + id: string; + status: 'PLACED' | 'IN_PROGRESS' | 'READY' | 'COMPLETED'; + floor: number; + location: string; + items: OrderItem[]; + placedBy: { id: string; name: string; email: string }; + createdAt: string; +} + +interface Rental { + id: string; + status: 'CHECKED_OUT' | 'RETURNED'; + floor: number; + location: string; + dueAt: string | null; + createdAt: string; + returnedAt: string | null; + tool: { id: string; name: string }; + rentedBy: { id: string; name: string; email: string }; +} + +interface TeamDetail { + id: string; + name: string; + members: Array<{ id: string; name: string; image?: string }>; +} + +export default function DashboardPage() { + const { data: session } = useSession(); + const [access, setAccess] = useState(null); + const [orders, setOrders] = useState([]); + const [rentals, setRentals] = useState([]); + const [team, setTeam] = useState(null); + const [loading, setLoading] = useState(true); + + const lastEvent = useInventorySSE(access?.teamId ?? null); + + const fetchAccess = useCallback(async () => { + try { + const res = await fetch('/api/inventory/access'); + if (!res.ok) return null; + const data = await res.json(); + setAccess(data); + return data as AccessInfo; + } catch { + return null; + } + }, []); + + const fetchOrders = useCallback(async () => { + try { + const res = await fetch('/api/inventory/orders'); + if (!res.ok) return; + const data = await res.json(); + setOrders(data); + } catch { + // Ignore + } + }, []); + + const fetchRentals = useCallback(async () => { + try { + const res = await fetch('/api/inventory/rentals'); + if (!res.ok) return; + const data = await res.json(); + setRentals(data); + } catch { + // Ignore + } + }, []); + + const fetchTeam = useCallback(async (teamId: string) => { + try { + const res = await fetch(`/api/inventory/teams/${teamId}`); + if (!res.ok) return; + const data = await res.json(); + setTeam(data); + } catch { + // Ignore + } + }, []); + + useEffect(() => { + if (!session) return; + (async () => { + const accessData = await fetchAccess(); + if (accessData?.teamId) { + await Promise.all([ + fetchOrders(), + fetchRentals(), + fetchTeam(accessData.teamId), + ]); + } + setLoading(false); + })(); + }, [session, fetchAccess, fetchOrders, fetchRentals, fetchTeam]); + + // Re-fetch on SSE events + useEffect(() => { + if (!lastEvent) return; + const type = lastEvent.type; + if (type === 'order_placed' || type === 'order_updated') { + fetchOrders(); + } + if (type === 'rental_created' || type === 'rental_returned') { + fetchRentals(); + } + }, [lastEvent, fetchOrders, fetchRentals]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!access?.teamId) { + return ( +
+

No Team

+

+ You need to join or create a team before using inventory. +

+ + Go to Team Page + +
+ ); + } + + const activeOrder = orders.find(o => o.status !== 'COMPLETED'); + const pastOrders = orders.filter(o => o.status === 'COMPLETED'); + const activeRentals = rentals.filter(r => r.status === 'CHECKED_OUT'); + const pastRentals = rentals.filter(r => r.status === 'RETURNED'); + + return ( +
+ {/* Active Order */} +
+

Active Order

+ {activeOrder ? ( +
+
+ +
+
+ Floor {activeOrder.floor} + {activeOrder.location} + Placed by {activeOrder.placedBy.name} + {new Date(activeOrder.createdAt).toLocaleString()} +
+
    + {activeOrder.items.map(oi => ( +
  • + {oi.item.name} + x{oi.quantity} +
  • + ))} +
+
+ ) : ( +

No active order.

+ )} +
+ + {/* Active Rentals */} +
+

Active Rentals

+ {activeRentals.length > 0 ? ( +
+ {activeRentals.map(rental => ( +
+
+ {rental.tool.name} +
+ Floor {rental.floor} + {rental.location} + by {rental.rentedBy.name} +
+
+ +
+ ))} +
+ ) : ( +

No active rentals.

+ )} +
+ + {/* Team Info */} + {team && ( +
+

Team

+
+

{team.name}

+
+ {team.members.map(member => ( +
+ {member.image ? ( + + ) : ( +
+ )} + {member.name} +
+ ))} +
+
+
+ )} + + {/* Order History */} +
+

Order History

+ {pastOrders.length > 0 ? ( +
+ + + + + + + + + + {pastOrders.map(order => ( + + + + + + ))} + +
DateItemsPlaced By
+ {new Date(order.createdAt).toLocaleDateString()} + + {order.items.map(oi => `${oi.item.name} x${oi.quantity}`).join(', ')} + {order.placedBy.name}
+
+ ) : ( +

No past orders.

+ )} +
+ + {/* Rental History */} +
+

Rental History

+ {pastRentals.length > 0 ? ( +
+ + + + + + + + + + + {pastRentals.map(rental => ( + + + + + + + ))} + +
ToolRentedReturnedBy
{rental.tool.name} + {new Date(rental.createdAt).toLocaleDateString()} + + {rental.returnedAt ? new Date(rental.returnedAt).toLocaleDateString() : '-'} + {rental.rentedBy.name}
+
+ ) : ( +

No past rentals.

+ )} +
+
+ ); +} diff --git a/app/inventory/layout.tsx b/app/inventory/layout.tsx new file mode 100644 index 00000000..fda2a5f7 --- /dev/null +++ b/app/inventory/layout.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useSession } from "@/lib/auth-client"; +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +interface AccessInfo { + allowed: boolean; + reason?: string; + isAdmin: boolean; + teamId?: string; + teamName?: string; +} + +export default function InventoryLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { data: session, isPending } = useSession(); + const pathname = usePathname(); + const [access, setAccess] = useState(null); + const [accessLoading, setAccessLoading] = useState(true); + + useEffect(() => { + if (!session) return; + fetch('/api/inventory/access') + .then(res => res.json()) + .then(data => { + setAccess(data); + setAccessLoading(false); + }) + .catch(() => { + setAccess({ allowed: false, reason: 'Failed to check access.', isAdmin: false }); + setAccessLoading(false); + }); + }, [session]); + + const getTabClass = (tabPath: string) => { + const isActive = tabPath === '/inventory' + ? pathname === '/inventory' + : pathname.startsWith(tabPath); + + return `px-6 py-3 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ + isActive + ? 'text-orange-500 border-orange-500' + : 'text-brown-800 border-transparent hover:text-orange-500' + }`; + }; + + if (isPending) { + return ( +
+
+
+ ); + } + + if (!session) { + return ( +
+
+

Sign In Required

+

You must be logged in to access inventory.

+ + Sign In + +
+
+ ); + } + + if (accessLoading) { + return ( +
+
+
+ ); + } + + if (!access?.allowed && !access?.isAdmin) { + return ( +
+
+

Access Denied

+

{access?.reason || 'You do not have access to inventory.'}

+ + Back to Dashboard + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + + + +

Inventory

+ {access?.teamName && ( + / {access.teamName} + )} +
+
+ + {/* Tabs */} +
+
+
+ + Dashboard + + + Browse Parts + + + Tools + + + Team + + {access?.isAdmin && ( + + Admin + + )} +
+
+
+ + {/* Content */} +
+ {children} +
+
+ ); +} diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx new file mode 100644 index 00000000..ca464ed0 --- /dev/null +++ b/app/inventory/page.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useSession } from '@/lib/auth-client'; +import { ItemCard } from '@/app/components/inventory/ItemCard'; +import { CartPanel } from '@/app/components/inventory/CartPanel'; +import { CheckoutModal } from '@/app/components/inventory/CheckoutModal'; + +interface Item { + id: string; + name: string; + description?: string; + imageUrl?: string; + stock: number; + category: string; + maxPerTeam: number; + teamUsed: number; +} + +interface CartItem { + itemId: string; + name: string; + quantity: number; +} + +interface Order { + id: string; + status: string; +} + +export default function BrowsePartsPage() { + const { data: session } = useSession(); + const [items, setItems] = useState([]); + const [cart, setCart] = useState([]); + const [activeCategory, setActiveCategory] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasActiveOrder, setHasActiveOrder] = useState(false); + const [checkoutOpen, setCheckoutOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [cartOpen, setCartOpen] = useState(false); + + const fetchItems = useCallback(async () => { + try { + const res = await fetch('/api/inventory/items'); + if (!res.ok) throw new Error('Failed to load items'); + const data = await res.json(); + setItems(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load items'); + } finally { + setLoading(false); + } + }, []); + + const checkActiveOrder = useCallback(async () => { + try { + const res = await fetch('/api/inventory/orders'); + if (!res.ok) return; + const orders: Order[] = await res.json(); + setHasActiveOrder(orders.some(o => o.status !== 'COMPLETED')); + } catch { + // Ignore - user might not be on a team yet + } + }, []); + + useEffect(() => { + if (!session) return; + fetchItems(); + checkActiveOrder(); + }, [session, fetchItems, checkActiveOrder]); + + const categories = Array.from(new Set(items.map(i => i.category))).sort(); + const filteredItems = activeCategory + ? items.filter(i => i.category === activeCategory) + : items; + + const addToCart = (itemId: string, quantity: number) => { + const item = items.find(i => i.id === itemId); + if (!item) return; + + setCart(prev => { + const existing = prev.find(c => c.itemId === itemId); + if (existing) { + return prev.map(c => + c.itemId === itemId ? { ...c, quantity: c.quantity + quantity } : c + ); + } + return [...prev, { itemId, name: item.name, quantity }]; + }); + }; + + const updateQuantity = (itemId: string, qty: number) => { + if (qty <= 0) { + setCart(prev => prev.filter(c => c.itemId !== itemId)); + } else { + setCart(prev => prev.map(c => (c.itemId === itemId ? { ...c, quantity: qty } : c))); + } + }; + + const removeFromCart = (itemId: string) => { + setCart(prev => prev.filter(c => c.itemId !== itemId)); + }; + + const handleCheckout = async (floor: number, location: string) => { + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch('/api/inventory/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + items: cart.map(c => ({ itemId: c.itemId, quantity: c.quantity })), + floor, + location, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to place order'); + } + + setCart([]); + setCheckoutOpen(false); + setSuccessMessage('Order placed successfully! Check your dashboard for status updates.'); + setHasActiveOrder(true); + fetchItems(); + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to place order'); + } finally { + setIsSubmitting(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Success message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Error message */} + {error && ( +
+ {error} + +
+ )} + + {/* Active order banner */} + {hasActiveOrder && ( +
+ Your team has an active order. You cannot place another order until it is completed. +
+ )} + + {/* Category filters */} + {categories.length > 1 && ( +
+ + {categories.map(cat => ( + + ))} +
+ )} + +
+ {/* Items grid */} +
+ {filteredItems.length === 0 ? ( +

No items found.

+ ) : ( +
+ {filteredItems.map(item => ( + + ))} +
+ )} +
+ + {/* Cart - desktop sidebar */} +
+
+ setCheckoutOpen(true)} + disabled={hasActiveOrder} + /> +
+
+ + {/* Cart - mobile toggle */} +
+ {!cartOpen && ( + + )} +
+ + {cartOpen && ( +
+
setCartOpen(false)} /> +
+ { + setCartOpen(false); + setCheckoutOpen(true); + }} + disabled={hasActiveOrder} + /> +
+
+ )} +
+ + {/* Checkout modal */} + setCheckoutOpen(false)} + items={cart} + onConfirm={handleCheckout} + isSubmitting={isSubmitting} + /> +
+ ); +} diff --git a/app/inventory/team/page.tsx b/app/inventory/team/page.tsx new file mode 100644 index 00000000..96716cc6 --- /dev/null +++ b/app/inventory/team/page.tsx @@ -0,0 +1,502 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useSession } from '@/lib/auth-client'; + +interface AccessInfo { + allowed: boolean; + reason?: string; + isAdmin: boolean; + teamId?: string; + teamName?: string; +} + +interface TeamMember { + id: string; + name: string; + slackDisplayName?: string; + image?: string; +} + +interface TeamDetail { + id: string; + name: string; + locked: boolean; + members: TeamMember[]; +} + +interface TeamListItem { + id: string; + name: string; + locked: boolean; + _count: { members: number }; +} + +export default function TeamPage() { + const { data: session } = useSession(); + const [access, setAccess] = useState(null); + const [team, setTeam] = useState(null); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Create team form + const [newTeamName, setNewTeamName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + + // Edit team name + const [editingName, setEditingName] = useState(false); + const [editName, setEditName] = useState(''); + const [isSavingName, setIsSavingName] = useState(false); + + // Add member + const [addSlackId, setAddSlackId] = useState(''); + const [isAddingMember, setIsAddingMember] = useState(false); + + // Leave/remove + const [isLeaving, setIsLeaving] = useState(false); + const [removingUserId, setRemovingUserId] = useState(null); + const [confirmLeave, setConfirmLeave] = useState(false); + + const showSuccess = (msg: string) => { + setSuccessMessage(msg); + setTimeout(() => setSuccessMessage(null), 5000); + }; + + const fetchAccess = useCallback(async () => { + try { + const res = await fetch('/api/inventory/access'); + if (!res.ok) throw new Error('Failed to check access'); + const data = await res.json(); + setAccess(data); + return data as AccessInfo; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to check access'); + return null; + } + }, []); + + const fetchTeamDetail = useCallback(async (teamId: string) => { + try { + const res = await fetch(`/api/inventory/teams/${teamId}`); + if (!res.ok) throw new Error('Failed to load team'); + const data = await res.json(); + setTeam(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load team'); + } + }, []); + + const fetchTeams = useCallback(async () => { + try { + const res = await fetch('/api/inventory/teams'); + if (!res.ok) throw new Error('Failed to load teams'); + const data = await res.json(); + setTeams(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load teams'); + } + }, []); + + const loadData = useCallback(async () => { + setLoading(true); + const accessData = await fetchAccess(); + if (accessData?.teamId) { + await fetchTeamDetail(accessData.teamId); + } else { + setTeam(null); + await fetchTeams(); + } + setLoading(false); + }, [fetchAccess, fetchTeamDetail, fetchTeams]); + + useEffect(() => { + if (!session) return; + loadData(); + }, [session, loadData]); + + const handleCreateTeam = async () => { + if (!newTeamName.trim()) return; + setIsCreating(true); + setError(null); + + try { + const res = await fetch('/api/inventory/teams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTeamName.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to create team'); + } + + setNewTeamName(''); + showSuccess('Team created!'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create team'); + } finally { + setIsCreating(false); + } + }; + + const handleJoinTeam = async (teamId: string) => { + setError(null); + + try { + const res = await fetch(`/api/inventory/teams/${teamId}/join`, { + method: 'POST', + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to join team'); + } + + showSuccess('Joined team!'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to join team'); + } + }; + + const handleSaveName = async () => { + if (!team || !editName.trim()) return; + setIsSavingName(true); + setError(null); + + try { + const res = await fetch(`/api/inventory/teams/${team.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editName.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to update team name'); + } + + setEditingName(false); + showSuccess('Team name updated!'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update team name'); + } finally { + setIsSavingName(false); + } + }; + + const handleAddMember = async () => { + if (!team || !addSlackId.trim()) return; + setIsAddingMember(true); + setError(null); + + try { + const res = await fetch(`/api/inventory/teams/${team.id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slackId: addSlackId.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to add member'); + } + + setAddSlackId(''); + showSuccess('Member added!'); + await fetchTeamDetail(team.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add member'); + } finally { + setIsAddingMember(false); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!team) return; + setRemovingUserId(userId); + setError(null); + + try { + const res = await fetch(`/api/inventory/teams/${team.id}/members/${userId}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to remove member'); + } + + showSuccess('Member removed.'); + await fetchTeamDetail(team.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove member'); + } finally { + setRemovingUserId(null); + } + }; + + const handleLeaveTeam = async () => { + if (!team) return; + setIsLeaving(true); + setError(null); + + try { + const res = await fetch(`/api/inventory/teams/${team.id}/leave`, { + method: 'POST', + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to leave team'); + } + + setConfirmLeave(false); + showSuccess('You left the team.'); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to leave team'); + } finally { + setIsLeaving(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Success message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Error message */} + {error && ( +
+ {error} + +
+ )} + + {/* Has team view */} + {team ? ( +
+ {/* Team name */} +
+
+ {editingName ? ( +
+ setEditName(e.target.value)} + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-lg" + autoFocus + /> + + +
+ ) : ( + <> +

{team.name}

+ {team.locked ? ( + + Locked + + ) : ( + + )} + + )} +
+
+ + {/* Members */} +
+

Members

+
+ {team.members.map(member => ( +
+ {member.image ? ( + + ) : ( +
+ ? +
+ )} +
+ {member.name} + {member.slackDisplayName && ( + {member.slackDisplayName} + )} +
+ {member.id !== session?.user.id && !team.locked && ( + + )} +
+ ))} +
+
+ + {/* Add member */} + {!team.locked && ( +
+

Add Member

+
+ setAddSlackId(e.target.value)} + placeholder="Slack User ID" + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + /> + +
+
+ )} + + {/* Leave team */} +
+ {confirmLeave ? ( +
+ Are you sure you want to leave? + + +
+ ) : ( + + )} +
+
+ ) : ( + /* No team view */ +
+ {/* Create team */} +
+

Create a Team

+
+ setNewTeamName(e.target.value)} + placeholder="Team name" + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + onKeyDown={e => { + if (e.key === 'Enter') handleCreateTeam(); + }} + /> + +
+
+ + {/* Join a team */} +
+

Join a Team

+ {teams.length === 0 ? ( +

No teams available to join.

+ ) : ( +
+ {teams.map(t => ( +
+
+ {t.name} + + {t._count.members} member{t._count.members !== 1 ? 's' : ''} + + {t.locked && ( + + Locked + + )} +
+ +
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/app/inventory/tools/page.tsx b/app/inventory/tools/page.tsx new file mode 100644 index 00000000..d4881f06 --- /dev/null +++ b/app/inventory/tools/page.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useSession } from '@/lib/auth-client'; +import { ToolCard } from '@/app/components/inventory/ToolCard'; +import { RentalTimer } from '@/app/components/inventory/RentalTimer'; + +interface Tool { + id: string; + name: string; + description?: string; + imageUrl?: string; + available: boolean; +} + +interface Rental { + id: string; + toolId: string; + status: 'CHECKED_OUT' | 'RETURNED'; + floor: number; + location: string; + dueAt: string | null; + createdAt: string; + returnedAt: string | null; + tool: { id: string; name: string; description?: string; imageUrl?: string }; + rentedBy: { id: string; name: string; email: string }; +} + +const MAX_CONCURRENT_RENTALS = 2; + +export default function ToolsPage() { + const { data: session } = useSession(); + const [tools, setTools] = useState([]); + const [rentals, setRentals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [rentModalToolId, setRentModalToolId] = useState(null); + const [floor, setFloor] = useState(1); + const [location, setLocation] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const activeRentals = rentals.filter(r => r.status === 'CHECKED_OUT'); + + const fetchTools = useCallback(async () => { + try { + const res = await fetch('/api/inventory/tools'); + if (!res.ok) throw new Error('Failed to load tools'); + const data = await res.json(); + setTools(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load tools'); + } + }, []); + + const fetchRentals = useCallback(async () => { + try { + const res = await fetch('/api/inventory/rentals'); + if (!res.ok) return; + const data = await res.json(); + setRentals(data); + } catch { + // Ignore - user might not be on a team + } + }, []); + + useEffect(() => { + if (!session) return; + Promise.all([fetchTools(), fetchRentals()]).finally(() => setLoading(false)); + }, [session, fetchTools, fetchRentals]); + + const handleRent = async () => { + if (!rentModalToolId || !location.trim()) return; + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch('/api/inventory/rentals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolId: rentModalToolId, floor, location: location.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to rent tool'); + } + + setRentModalToolId(null); + setFloor(1); + setLocation(''); + setSuccessMessage('Tool rented successfully!'); + fetchTools(); + fetchRentals(); + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rent tool'); + } finally { + setIsSubmitting(false); + } + }; + + const openRentModal = (toolId: string) => { + setError(null); + setRentModalToolId(toolId); + setFloor(1); + setLocation(''); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Success message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Error message */} + {error && ( +
+ {error} + +
+ )} + + {/* Active rentals */} + {activeRentals.length > 0 && ( +
+

+ Active Rentals ({activeRentals.length} / {MAX_CONCURRENT_RENTALS}) +

+
+ {activeRentals.map(rental => ( +
+
+ {rental.tool.name} + + Floor {rental.floor} - {rental.location} + +
+ +
+ ))} +
+
+ )} + + {/* Tools grid */} +

Available Tools

+ {tools.length === 0 ? ( +

No tools available.

+ ) : ( +
+ {tools.map(tool => ( + + ))} +
+ )} + + {/* Rent modal */} + {rentModalToolId && ( +
+
setRentModalToolId(null)} /> +
+

Rent Tool

+ +

+ {tools.find(t => t.id === rentModalToolId)?.name} +

+ + {/* Floor dropdown */} +
+ + +
+ + {/* Location input */} +
+ + setLocation(e.target.value)} + placeholder="Room number or table" + className="w-full border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + /> +
+ + {/* Actions */} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/instrumentation-client.ts.bak b/instrumentation-client.ts.bak new file mode 100644 index 00000000..1e6e0137 --- /dev/null +++ b/instrumentation-client.ts.bak @@ -0,0 +1,47 @@ +// This file configures the initialization of Sentry on the client. +// The added config here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +if (typeof window !== "undefined") { + Sentry.init({ + dsn: "https://5e657a38b387207d41ded2b67cdec8ad@o40609.ingest.us.sentry.io/4510701182255104", + enabled: process.env.NODE_ENV !== "development", + + // Filter out browser extension noise + beforeSend(event) { + if ( + event.message?.includes("runtime.sendMessage") || + event.exception?.values?.some((e) => + e.value?.includes("runtime.sendMessage") + ) + ) { + return null; + } + return event; + }, + + // Add optional integrations for additional features + integrations: [Sentry.replayIntegration()], + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + // Enable logs to be sent to Sentry + enableLogs: true, + + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, + }); +} + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/lib/hooks/useInventorySSE.ts b/lib/hooks/useInventorySSE.ts new file mode 100644 index 00000000..f22a9f30 --- /dev/null +++ b/lib/hooks/useInventorySSE.ts @@ -0,0 +1,49 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" + +interface SSEEvent { + type: string + data: unknown +} + +export function useInventorySSE(teamId: string | null) { + const [lastEvent, setLastEvent] = useState(null) + const eventSourceRef = useRef(null) + const reconnectTimeoutRef = useRef | null>(null) + + const connect = useCallback(() => { + if (!teamId) return + + const url = `/api/inventory/sse?teamId=${encodeURIComponent(teamId)}` + const es = new EventSource(url) + eventSourceRef.current = es + + es.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data) + setLastEvent(parsed) + } catch {} + } + + es.onerror = () => { + es.close() + eventSourceRef.current = null + // Reconnect after 3 seconds + reconnectTimeoutRef.current = setTimeout(connect, 3000) + } + }, [teamId]) + + useEffect(() => { + connect() + return () => { + eventSourceRef.current?.close() + eventSourceRef.current = null + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + } + }, [connect]) + + return lastEvent +} diff --git a/lib/inventory/access.ts b/lib/inventory/access.ts new file mode 100644 index 00000000..aa216de2 --- /dev/null +++ b/lib/inventory/access.ts @@ -0,0 +1,91 @@ +import prisma from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import { getUserRoles, hasRole, Role } from "@/lib/permissions" +import { MIN_BITS_FOR_INVENTORY } from "./config" + +export async function checkInventoryAccess(userId: string) { + const [settings, balanceResult, user, roles] = await Promise.all([ + prisma.inventorySettings.findUnique({ where: { id: "singleton" } }), + prisma.currencyTransaction.aggregate({ + where: { userId }, + _sum: { amount: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + teamId: true, + team: { select: { id: true, name: true } }, + }, + }), + getUserRoles(userId), + ]) + + const isAdmin = hasRole(roles, Role.ADMIN) + const enabled = settings?.enabled ?? false + const balance = balanceResult._sum.amount ?? 0 + + if (isAdmin) { + return { + allowed: true, + isAdmin: true, + teamId: user?.teamId ?? null, + teamName: user?.team?.name ?? null, + balance, + enabled, + } + } + + if (!enabled) { + return { + allowed: false, + reason: "Inventory is currently disabled", + isAdmin: false, + teamId: null, + teamName: null, + balance, + enabled, + } + } + + if (balance < MIN_BITS_FOR_INVENTORY) { + return { + allowed: false, + reason: `You need at least ${MIN_BITS_FOR_INVENTORY} bits to access inventory`, + isAdmin: false, + teamId: null, + teamName: null, + balance, + enabled, + } + } + + return { + allowed: true, + isAdmin: false, + teamId: user?.teamId ?? null, + teamName: user?.team?.name ?? null, + balance, + enabled, + } +} + +export async function requireInventoryAccess() { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + } + + const access = await checkInventoryAccess(session.user.id) + if (!access.allowed) { + return { + error: NextResponse.json( + { error: access.reason ?? "Access denied" }, + { status: 403 } + ), + } + } + + return { session, access } +} diff --git a/lib/inventory/config.ts b/lib/inventory/config.ts new file mode 100644 index 00000000..007d0aca --- /dev/null +++ b/lib/inventory/config.ts @@ -0,0 +1,11 @@ +export const MIN_BITS_FOR_INVENTORY = parseInt( + process.env.MIN_BITS_FOR_INVENTORY ?? "0" +) +export const VENUE_FLOORS = parseInt(process.env.VENUE_FLOORS ?? "3") +export const TOOL_RENTAL_TIME_LIMIT_MINUTES = parseInt( + process.env.TOOL_RENTAL_TIME_LIMIT_MINUTES ?? "0" +) +export const MAX_TEAM_SIZE = parseInt(process.env.MAX_TEAM_SIZE ?? "4") +export const MAX_CONCURRENT_RENTALS = parseInt( + process.env.MAX_CONCURRENT_RENTALS ?? "2" +) diff --git a/lib/inventory/digikey.ts b/lib/inventory/digikey.ts new file mode 100644 index 00000000..c1a84f27 --- /dev/null +++ b/lib/inventory/digikey.ts @@ -0,0 +1,76 @@ +let tokenCache: { accessToken: string; expiresAt: number } | null = null + +async function getToken(): Promise { + if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) { + return tokenCache.accessToken + } + + const clientId = process.env.DIGIKEY_CLIENT_ID + const clientSecret = process.env.DIGIKEY_CLIENT_SECRET + if (!clientId || !clientSecret) { + throw new Error("DigiKey credentials not configured") + } + + const res = await fetch("https://api.digikey.com/v1/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "client_credentials", + }), + }) + + if (!res.ok) { + throw new Error(`DigiKey token request failed: ${res.status}`) + } + + const data = await res.json() + tokenCache = { + accessToken: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + } + return tokenCache.accessToken +} + +export async function searchDigiKey( + query: string +): Promise<{ name: string; imageUrl: string }[]> { + const token = await getToken() + + const res = await fetch( + "https://api.digikey.com/products/v4/search/keyword", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-DIGIKEY-Client-Id": process.env.DIGIKEY_CLIENT_ID!, + }, + body: JSON.stringify({ + Keywords: query, + RecordCount: 10, + }), + } + ) + + if (!res.ok) { + throw new Error(`DigiKey search failed: ${res.status}`) + } + + const data = await res.json() + const products = data.Products ?? data.products ?? [] + + return products + .filter( + (p: { PrimaryPhoto?: string; ProductDescription?: string }) => + p.PrimaryPhoto + ) + .map( + (p: { PrimaryPhoto: string; ProductDescription?: string; ManufacturerPartNumber?: string }) => ({ + name: + p.ProductDescription ?? p.ManufacturerPartNumber ?? "Unknown", + imageUrl: p.PrimaryPhoto, + }) + ) +} diff --git a/lib/inventory/notifications.ts b/lib/inventory/notifications.ts new file mode 100644 index 00000000..475a4396 --- /dev/null +++ b/lib/inventory/notifications.ts @@ -0,0 +1,19 @@ +import prisma from "@/lib/prisma" +import { sendSlackDM } from "@/lib/slack" + +export function notifyTeam(teamId: string, message: string) { + // Fire-and-forget: don't await, don't block the response + prisma.user + .findMany({ + where: { teamId }, + select: { slackId: true }, + }) + .then((members) => { + for (const member of members) { + if (member.slackId) { + sendSlackDM(member.slackId, message).catch(() => {}) + } + } + }) + .catch(() => {}) +} diff --git a/lib/inventory/sse.ts b/lib/inventory/sse.ts new file mode 100644 index 00000000..1e48d254 --- /dev/null +++ b/lib/inventory/sse.ts @@ -0,0 +1,42 @@ +const connections = new Map>() + +export function registerConnection( + key: string, + controller: ReadableStreamDefaultController +) { + if (!connections.has(key)) { + connections.set(key, new Set()) + } + connections.get(key)!.add(controller) +} + +export function removeConnection( + key: string, + controller: ReadableStreamDefaultController +) { + const set = connections.get(key) + if (set) { + set.delete(controller) + if (set.size === 0) connections.delete(key) + } +} + +export function pushSSE( + teamId: string, + event: { type: string; data: unknown } +) { + const encoded = new TextEncoder().encode( + `data: ${JSON.stringify(event)}\n\n` + ) + for (const key of [teamId, "admin"]) { + const set = connections.get(key) + if (!set) continue + for (const ctrl of set) { + try { + ctrl.enqueue(encoded) + } catch { + set.delete(ctrl) + } + } + } +} diff --git a/prisma/migrations/20260324032823_add_inventory_models/migration.sql b/prisma/migrations/20260324032823_add_inventory_models/migration.sql new file mode 100644 index 00000000..fd011c58 --- /dev/null +++ b/prisma/migrations/20260324032823_add_inventory_models/migration.sql @@ -0,0 +1,164 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('PLACED', 'IN_PROGRESS', 'READY', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "RentalStatus" AS ENUM ('CHECKED_OUT', 'RETURNED'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_IMPORT'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ORDER_STATUS_UPDATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_RENTAL_RETURN'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_LOCK'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_SETTINGS_UPDATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_CREATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_UPDATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_DELETE'; + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "teamId" TEXT; + +-- CreateTable +CREATE TABLE "team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "item" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "imageUrl" TEXT, + "stock" INTEGER NOT NULL, + "category" TEXT NOT NULL, + "maxPerTeam" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "item_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tool" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "imageUrl" TEXT, + "available" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tool_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "placedById" TEXT NOT NULL, + "status" "OrderStatus" NOT NULL DEFAULT 'PLACED', + "floor" INTEGER NOT NULL, + "location" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_item" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "order_item_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tool_rental" ( + "id" TEXT NOT NULL, + "toolId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "rentedById" TEXT NOT NULL, + "status" "RentalStatus" NOT NULL DEFAULT 'CHECKED_OUT', + "floor" INTEGER NOT NULL, + "location" TEXT NOT NULL, + "dueAt" TIMESTAMP(3), + "returnedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tool_rental_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventory_settings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "enabled" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "inventory_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "team_name_key" ON "team"("name"); + +-- CreateIndex +CREATE INDEX "order_teamId_idx" ON "order"("teamId"); + +-- CreateIndex +CREATE INDEX "order_placedById_idx" ON "order"("placedById"); + +-- CreateIndex +CREATE INDEX "order_item_orderId_idx" ON "order_item"("orderId"); + +-- CreateIndex +CREATE INDEX "order_item_itemId_idx" ON "order_item"("itemId"); + +-- CreateIndex +CREATE INDEX "tool_rental_toolId_idx" ON "tool_rental"("toolId"); + +-- CreateIndex +CREATE INDEX "tool_rental_teamId_idx" ON "tool_rental"("teamId"); + +-- CreateIndex +CREATE INDEX "tool_rental_rentedById_idx" ON "tool_rental"("rentedById"); + +-- CreateIndex +CREATE INDEX "user_teamId_idx" ON "user"("teamId"); + +-- AddForeignKey +ALTER TABLE "user" ADD CONSTRAINT "user_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order" ADD CONSTRAINT "order_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order" ADD CONSTRAINT "order_placedById_fkey" FOREIGN KEY ("placedById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_item" ADD CONSTRAINT "order_item_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "order"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_item" ADD CONSTRAINT "order_item_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "item"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tool_rental" ADD CONSTRAINT "tool_rental_toolId_fkey" FOREIGN KEY ("toolId") REFERENCES "tool"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tool_rental" ADD CONSTRAINT "tool_rental_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tool_rental" ADD CONSTRAINT "tool_rental_rentedById_fkey" FOREIGN KEY ("rentedById") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a541715..02ec7cdf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,3 @@ - generator client { provider = "prisma-client" output = "../app/generated/prisma" @@ -22,10 +21,10 @@ enum ProjectStage { } enum CurrencyTransactionType { - PROJECT_APPROVED // Bits credited when a project build is approved - ADMIN_GRANT // Reviewer manually credits bits - ADMIN_DEDUCTION // Reviewer manually debits bits (correction, etc.) - SHOP_PURCHASE // User spends bits on a shop item + PROJECT_APPROVED // Bits credited when a project build is approved + ADMIN_GRANT // Reviewer manually credits bits + ADMIN_DEDUCTION // Reviewer manually debits bits (correction, etc.) + SHOP_PURCHASE // User spends bits on a shop item } enum ReviewDecision { @@ -40,23 +39,35 @@ enum ReviewResult { REJECTED } +enum OrderStatus { + PLACED + IN_PROGRESS + READY + COMPLETED +} + +enum RentalStatus { + CHECKED_OUT + RETURNED +} + // This is the primary user object model User { - id String @id @default(cuid()) - email String @unique + id String @id @default(cuid()) + email String @unique name String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - emailVerified Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + emailVerified Boolean @default(false) image String? - slackId String? @unique + slackId String? @unique slackDisplayName String? verificationStatus String? - hackatimeUserId String? @unique + hackatimeUserId String? @unique bio String? - fraudConvicted Boolean @default(false) - tutorialDashboard Boolean @default(false) - tutorialProject Boolean @default(false) + fraudConvicted Boolean @default(false) + tutorialDashboard Boolean @default(false) + tutorialProject Boolean @default(false) eventPreference String? pronouns String? @@ -68,19 +79,25 @@ model User { encryptedAddressCountry String? encryptedBirthday String? - sessions Session[] - accounts Account[] - projects Project[] - roles UserRole[] - currencyTransactions CurrencyTransaction[] - kudosGiven Kudos[] - sidekickAssignments SidekickAssignment[] @relation("SidekickAssignments") - assignedSidekick SidekickAssignment? @relation("AssignedSidekick") - + sessions Session[] + accounts Account[] + projects Project[] + roles UserRole[] + currencyTransactions CurrencyTransaction[] + kudosGiven Kudos[] + sidekickAssignments SidekickAssignment[] @relation("SidekickAssignments") + assignedSidekick SidekickAssignment? @relation("AssignedSidekick") + + // Inventory relations + teamId String? + team Team? @relation("TeamMembers", fields: [teamId], references: [id]) + ordersPlaced Order[] @relation("OrdersPlaced") + toolsRented ToolRental[] @relation("ToolsRented") + + @@index([teamId]) @@map("user") } - // begin better-auth stuff model Session { id String @id @@ -129,6 +146,7 @@ model Verification { @@index([identifier]) @@map("verification") } + // end better-auth things enum ProjectTag { @@ -188,19 +206,19 @@ enum BadgeType { } model Project { - id String @id @default(cuid()) - title String - description String? - tags ProjectTag[] - isStarter Boolean @default(false) - starterProjectId String? - githubRepo String? - coverImage String? - noBomNeeded Boolean @default(false) - cartScreenshots String[] @default([]) - tier Int? - bitsAwarded Int? // Set atomically at build approval; null until then - hiddenFromGallery Boolean @default(false) + id String @id @default(cuid()) + title String + description String? + tags ProjectTag[] + isStarter Boolean @default(false) + starterProjectId String? + githubRepo String? + coverImage String? + noBomNeeded Boolean @default(false) + cartScreenshots String[] @default([]) + tier Int? + bitsAwarded Int? // Set atomically at build approval; null until then + hiddenFromGallery Boolean @default(false) // Stage-based status tracking designStatus ProjectStatus @default(draft) @@ -208,26 +226,26 @@ model Project { designReviewComments String? designReviewedAt DateTime? designReviewedBy String? - - buildStatus ProjectStatus @default(draft) - buildSubmissionNotes String? - buildReviewComments String? - buildReviewedAt DateTime? - buildReviewedBy String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - workSessions WorkSession[] - badges ProjectBadge[] - bomItems BOMItem[] - submissions ProjectSubmission[] - reviewActions ProjectReviewAction[] - kudos Kudos[] - featuredAt DateTime? - featuredById String? - hackatimeProjects HackatimeProject[] + + buildStatus ProjectStatus @default(draft) + buildSubmissionNotes String? + buildReviewComments String? + buildReviewedAt DateTime? + buildReviewedBy String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workSessions WorkSession[] + badges ProjectBadge[] + bomItems BOMItem[] + submissions ProjectSubmission[] + reviewActions ProjectReviewAction[] + kudos Kudos[] + featuredAt DateTime? + featuredById String? + hackatimeProjects HackatimeProject[] @@index([userId]) @@map("project") @@ -249,15 +267,15 @@ model HackatimeProject { } model ProjectSubmission { - id String @id @default(cuid()) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - stage ProjectStage - notes String? - preReviewed Boolean @default(false) - createdAt DateTime @default(now()) - reviews SubmissionReview[] - claim ReviewClaim? + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage ProjectStage + notes String? + preReviewed Boolean @default(false) + createdAt DateTime @default(now()) + reviews SubmissionReview[] + claim ReviewClaim? @@index([projectId]) @@map("project_submission") @@ -281,19 +299,19 @@ model ProjectReviewAction { } model WorkSession { - id String @id @default(cuid()) + id String @id @default(cuid()) title String hoursClaimed Float hoursApproved Float? content String? categories SessionCategory[] - stage ProjectStage @default(BUILD) + stage ProjectStage @default(BUILD) reviewComments String? reviewedAt DateTime? reviewedBy String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) media SessionMedia[] timelapses SessionTimelapse[] @@ -361,13 +379,13 @@ model BOMItem { } model ProjectBadge { - id String @id @default(cuid()) - badge BadgeType - claimedAt DateTime @default(now()) - grantedAt DateTime? - grantedBy String? - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + badge BadgeType + claimedAt DateTime @default(now()) + grantedAt DateTime? + grantedBy String? + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@unique([projectId, badge]) @@index([projectId]) @@ -414,7 +432,7 @@ enum AuditAction { ADMIN_REVIEW_HACKATIME ADMIN_APPROVE_BOM ADMIN_REJECT_BOM - + REVIEWER_APPROVE REVIEWER_RETURN REVIEWER_REJECT @@ -422,24 +440,34 @@ enum AuditAction { // Security events SUPERADMIN_GRANT - + // User actions on sensitive data USER_DELETE_PROJECT USER_SUBMIT_PROJECT USER_UNSUBMIT_PROJECT + + // Inventory actions + INVENTORY_IMPORT + INVENTORY_ORDER_STATUS_UPDATE + INVENTORY_RENTAL_RETURN + INVENTORY_TEAM_LOCK + INVENTORY_SETTINGS_UPDATE + INVENTORY_ITEM_CREATE + INVENTORY_ITEM_UPDATE + INVENTORY_ITEM_DELETE } model AuditLog { - id String @id @default(cuid()) - action AuditAction - actorId String? - actorEmail String? - actorIp String? + id String @id @default(cuid()) + action AuditAction + actorId String? + actorEmail String? + actorIp String? actorUserAgent String? - targetType String? - targetId String? - metadata Json? - createdAt DateTime @default(now()) + targetType String? + targetId String? + metadata Json? + createdAt DateTime @default(now()) @@index([actorId]) @@index([action]) @@ -454,14 +482,14 @@ model CurrencyTransaction { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projectId String? // Associated project (for PROJECT_APPROVED entries) - amount Int // Positive = credit, negative = debit (bits) + projectId String? // Associated project (for PROJECT_APPROVED entries) + amount Int // Positive = credit, negative = debit (bits) type CurrencyTransactionType note String? - shopItemId String? // Shop item ID for SHOP_PURCHASE transactions - balanceBefore Int // Balance before this entry (audit trail) - balanceAfter Int // Balance after this entry - createdBy String? // Admin user ID; null if system-generated + shopItemId String? // Shop item ID for SHOP_PURCHASE transactions + balanceBefore Int // Balance before this entry (audit trail) + balanceAfter Int // Balance after this entry + createdBy String? // Admin user ID; null if system-generated createdAt DateTime @default(now()) @@index([userId]) @@ -472,34 +500,34 @@ model CurrencyTransaction { // Admin-managed shop items (excludes hardcoded Event Invite & Flight Stipend) model ShopItem { - id String @id @default(cuid()) + id String @id @default(cuid()) name String description String longDescription String? imageUrl String? - price Int // Cost in bits - maxPerUser Int @default(0) // 0 = unlimited, 1 = one per user - active Boolean @default(true) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + price Int // Cost in bits + maxPerUser Int @default(0) // 0 = unlimited, 1 = one per user + active Boolean @default(true) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("shop_item") } // Temporary RSVP storage (fallback when Airtable is down) model TempRsvp { - id String @id @default(cuid()) - email String @unique - ip String? - utmSource String? - referredBy String? - firstName String? - lastName String? - finishedAccount Boolean @default(false) - syncedToAirtable Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + ip String? + utmSource String? + referredBy String? + firstName String? + lastName String? + finishedAccount Boolean @default(false) + syncedToAirtable Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([syncedToAirtable]) @@map("temp_rsvp") @@ -520,15 +548,15 @@ model SidekickAssignment { // Two-pass submission review record model SubmissionReview { - id String @id @default(cuid()) - submissionId String - submission ProjectSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - reviewerId String - result ReviewResult - isAdminReview Boolean @default(false) - feedback String // shown to submitter - reason String? // internal justification - invalidated Boolean @default(false) + id String @id @default(cuid()) + submissionId String + submission ProjectSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + reviewerId String + result ReviewResult + isAdminReview Boolean @default(false) + feedback String // shown to submitter + reason String? // internal justification + invalidated Boolean @default(false) // Optional overrides workUnitsOverride Float? @@ -602,4 +630,105 @@ model Kudos { @@map("kudos") } +// ── Inventory Models ──────────────────────────────────────────────── +model Team { + id String @id @default(cuid()) + name String @unique + locked Boolean @default(false) + members User[] @relation("TeamMembers") + orders Order[] + toolRentals ToolRental[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("team") +} + +model Item { + id String @id @default(cuid()) + name String + description String? + imageUrl String? + stock Int + category String + maxPerTeam Int + orderItems OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("item") +} + +model Tool { + id String @id @default(cuid()) + name String + description String? + imageUrl String? + available Boolean @default(true) + rentals ToolRental[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("tool") +} + +model Order { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id]) + placedById String + placedBy User @relation("OrdersPlaced", fields: [placedById], references: [id]) + status OrderStatus @default(PLACED) + floor Int + location String + items OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([teamId]) + @@index([placedById]) + @@map("order") +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + itemId String + item Item @relation(fields: [itemId], references: [id]) + quantity Int + + @@index([orderId]) + @@index([itemId]) + @@map("order_item") +} + +model ToolRental { + id String @id @default(cuid()) + toolId String + tool Tool @relation(fields: [toolId], references: [id]) + teamId String + team Team @relation(fields: [teamId], references: [id]) + rentedById String + rentedBy User @relation("ToolsRented", fields: [rentedById], references: [id]) + status RentalStatus @default(CHECKED_OUT) + floor Int + location String + dueAt DateTime? + returnedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([toolId]) + @@index([teamId]) + @@index([rentedById]) + @@map("tool_rental") +} + +model InventorySettings { + id String @id @default("singleton") + enabled Boolean @default(false) + + @@map("inventory_settings") +} From ead3b910cbdaed18c80e0370ab06be4f2d9e6c04 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:58:18 -0700 Subject: [PATCH 02/14] Inventory system: fix dogfood issues and deduplicate access fetching - Add visual distinction for disabled item cards (opacity, red text, "Unavailable" label) - Remove backdrop click-to-close on checkout modal to prevent accidental dismissal - Add title tooltips to truncated cart item names - Create InventoryAccessContext to share access data from layout, eliminating duplicate /api/inventory/access calls in admin layout, dashboard, and browse pages - Add relative z-10 to admin sub-tab links to fix click target overlap - Move themeColor from metadata to viewport export (Next.js deprecation fix) - Add dogfood-output to .gitignore Co-Authored-By: Claude Opus 4.6 --- .env.example | 12 +- .gitignore | 3 +- app/api/inventory/admin/assign-badge/route.ts | 35 + app/api/inventory/admin/items/import/route.ts | 28 +- app/api/inventory/admin/orders/[id]/route.ts | 60 +- app/api/inventory/admin/orders/route.ts | 8 +- .../admin/rentals/[id]/return/route.ts | 7 +- app/api/inventory/admin/tools/[id]/route.ts | 78 ++ app/api/inventory/admin/tools/route.ts | 48 + app/api/inventory/items/route.ts | 36 +- .../inventory/lookup/[slackUserId]/route.ts | 44 +- app/api/inventory/orders/[id]/cancel/route.ts | 69 ++ app/api/inventory/orders/route.ts | 143 +-- app/api/inventory/rentals/route.ts | 120 +-- app/api/inventory/sse/route.ts | 10 +- app/api/inventory/teams/[id]/join/route.ts | 11 +- app/api/inventory/teams/[id]/leave/route.ts | 14 +- .../teams/[id]/members/[userId]/route.ts | 14 +- app/api/inventory/teams/[id]/members/route.ts | 11 +- app/api/inventory/teams/route.ts | 3 + app/components/inventory/CartPanel.tsx | 134 ++- app/components/inventory/CheckoutModal.tsx | 68 +- app/components/inventory/ItemCard.tsx | 22 +- app/components/inventory/NFCScanner.tsx | 129 --- app/components/inventory/TeamPanel.tsx | 414 +++++++++ app/components/inventory/ToolCard.tsx | 16 +- app/dashboard/layout.tsx | 15 +- app/inventory/InventoryAccessContext.tsx | 34 + app/inventory/admin/items/page.tsx | 846 +++++++----------- app/inventory/admin/layout.tsx | 60 +- app/inventory/admin/page.tsx | 511 ++++++----- app/inventory/admin/rentals/page.tsx | 166 ---- app/inventory/admin/teams/page.tsx | 8 +- app/inventory/dashboard/page.tsx | 191 ++-- app/inventory/layout.tsx | 157 ++-- app/inventory/page.tsx | 354 ++++++-- app/inventory/team/page.tsx | 502 ----------- app/inventory/tools/page.tsx | 232 ----- app/layout.tsx | 7 +- instrumentation-client.ts.bak | 47 - instrumentation.ts | 14 + lib/inventory/access.ts | 14 +- lib/inventory/digikey.ts | 35 +- lib/inventory/notifications.ts | 118 ++- lib/inventory/overdue-checker.ts | 46 + lib/inventory/sse.ts | 3 +- lib/inventory/team-channel.ts | 130 +++ lib/inventory/teams.ts | 26 + lib/{hooks => inventory}/useInventorySSE.ts | 0 lib/slack.ts | 33 + middleware.ts | 2 +- .../migration.sql | 6 + .../20260325023442_add_nfc_id/migration.sql | 11 + .../migration.sql | 5 + .../migration.sql | 2 + .../migration.sql | 11 + .../migration.sql | 2 + prisma/schema.prisma | 26 +- 58 files changed, 2649 insertions(+), 2502 deletions(-) create mode 100644 app/api/inventory/admin/assign-badge/route.ts create mode 100644 app/api/inventory/admin/tools/[id]/route.ts create mode 100644 app/api/inventory/admin/tools/route.ts create mode 100644 app/api/inventory/orders/[id]/cancel/route.ts delete mode 100644 app/components/inventory/NFCScanner.tsx create mode 100644 app/components/inventory/TeamPanel.tsx create mode 100644 app/inventory/InventoryAccessContext.tsx delete mode 100644 app/inventory/admin/rentals/page.tsx delete mode 100644 app/inventory/team/page.tsx delete mode 100644 app/inventory/tools/page.tsx delete mode 100644 instrumentation-client.ts.bak create mode 100644 lib/inventory/overdue-checker.ts create mode 100644 lib/inventory/team-channel.ts create mode 100644 lib/inventory/teams.ts rename lib/{hooks => inventory}/useInventorySSE.ts (100%) create mode 100644 prisma/migrations/20260325023442_add_nfc_id/migration.sql create mode 100644 prisma/migrations/20260326042102_add_cancelled_order_status/migration.sql create mode 100644 prisma/migrations/20260326044116_add_notify_prefs/migration.sql create mode 100644 prisma/migrations/20260326051015_add_team_slack_channel/migration.sql create mode 100644 prisma/migrations/20260326053252_add_slack_pinned_ts/migration.sql diff --git a/.env.example b/.env.example index 853a879f..fc50e472 100644 --- a/.env.example +++ b/.env.example @@ -50,4 +50,14 @@ PULL_HCA_PII= PII_ENCRYPTION_KEY= # GitHub proxy API key for automated repo checks (gh-proxy.hackclub.com) -GH_PROXY_API_KEY= \ No newline at end of file +GH_PROXY_API_KEY= + +# Inventory +INVENTORY_ALLOW_MULTIPLE_ORDERS=false +MIN_BITS_FOR_INVENTORY=0 +VENUE_FLOORS=3 +MAX_TEAM_SIZE=4 +MAX_CONCURRENT_RENTALS=2 +TOOL_RENTAL_TIME_LIMIT_MINUTES=0 +DIGIKEY_CLIENT_ID= +DIGIKEY_CLIENT_SECRET= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 69c1c568..1b4eb0f4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin -.playwright-mcp \ No newline at end of file +.playwright-mcp +dogfood-output/ diff --git a/app/api/inventory/admin/assign-badge/route.ts b/app/api/inventory/admin/assign-badge/route.ts new file mode 100644 index 00000000..bd3c64d9 --- /dev/null +++ b/app/api/inventory/admin/assign-badge/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" + +export async function POST(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const body = await request.json() + const { userId, nfcId } = body + + if (!userId || !nfcId) { + return NextResponse.json( + { error: "userId and nfcId are required" }, + { status: 400 } + ) + } + + // Check if this nfcId is already assigned to someone else + const existing = await prisma.user.findUnique({ where: { nfcId } }) + if (existing && existing.id !== userId) { + return NextResponse.json( + { error: "This badge is already assigned to another user" }, + { status: 409 } + ) + } + + const user = await prisma.user.update({ + where: { id: userId }, + data: { nfcId }, + select: { id: true, name: true, slackDisplayName: true, nfcId: true }, + }) + + return NextResponse.json(user) +} diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts index 8ee27d2b..ffb8e9bd 100644 --- a/app/api/inventory/admin/items/import/route.ts +++ b/app/api/inventory/admin/items/import/route.ts @@ -21,14 +21,28 @@ export async function POST(request: Request) { const data = items.map((item: Record) => ({ name: item.name as string, - description: (item.description as string) ?? null, - imageUrl: (item.imageUrl as string) ?? null, - stock: item.stock as number, + description: ((item.description as string) || null), + imageUrl: ((item.imageUrl ?? item.image_url) as string) || null, + stock: Number(item.stock) || 0, category: item.category as string, - maxPerTeam: item.maxPerTeam as number, + maxPerTeam: Number(item.maxPerTeam ?? item.max_per_team) || 0, })) - const result2 = await prisma.item.createMany({ data }) + await prisma.$transaction( + data.map((item) => + prisma.item.upsert({ + where: { name: item.name }, + update: { + description: item.description, + imageUrl: item.imageUrl, + stock: item.stock, + category: item.category, + maxPerTeam: item.maxPerTeam, + }, + create: item, + }) + ) + ) await logAdminAction( AuditAction.INVENTORY_IMPORT, @@ -36,8 +50,8 @@ export async function POST(request: Request) { session.user.email, "Item", undefined, - { count: result2.count } + { count: data.length } ) - return NextResponse.json({ imported: result2.count }, { status: 201 }) + return NextResponse.json({ imported: data.length }, { status: 201 }) } diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts index 41140bc3..6542b8e2 100644 --- a/app/api/inventory/admin/orders/[id]/route.ts +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -3,10 +3,10 @@ import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { logAdminAction, AuditAction } from "@/lib/audit" import { pushSSE } from "@/lib/inventory/sse" -import { notifyTeam } from "@/lib/inventory/notifications" +import { notifyOrderUpdate } from "@/lib/inventory/notifications" import { OrderStatus } from "@/app/generated/prisma/client" -const VALID_STATUSES: OrderStatus[] = ["IN_PROGRESS", "READY", "COMPLETED"] +const VALID_STATUSES: OrderStatus[] = ["IN_PROGRESS", "READY", "COMPLETED", "CANCELLED"] export async function PATCH( request: Request, @@ -27,6 +27,56 @@ export async function PATCH( ) } + // If cancelling, restore stock + if (status === "CANCELLED") { + const existing = await prisma.order.findUnique({ + where: { id }, + include: { items: true }, + }) + if (!existing) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }) + } + if (existing.status === "READY" || existing.status === "COMPLETED" || existing.status === "CANCELLED") { + return NextResponse.json( + { error: "Cannot cancel an order that is already ready, completed, or cancelled" }, + { status: 400 } + ) + } + + const order = await prisma.$transaction(async (tx) => { + const updated = await tx.order.update({ + where: { id }, + data: { status }, + include: { + team: { select: { id: true, name: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + items: { include: { item: true } }, + }, + }) + for (const item of existing.items) { + await tx.item.update({ + where: { id: item.itemId }, + data: { stock: { increment: item.quantity } }, + }) + } + return updated + }) + + notifyOrderUpdate(order.teamId, order, "Cancelled") + + await logAdminAction( + AuditAction.INVENTORY_ORDER_CANCEL, + session.user.id, + session.user.email, + "Order", + id, + { orderId: id, status } + ) + + pushSSE(order.teamId, { type: "order_status_updated", data: order }) + return NextResponse.json(order) + } + const order = await prisma.order.update({ where: { id }, data: { status }, @@ -38,9 +88,9 @@ export async function PATCH( }) if (status === "READY") { - notifyTeam(order.teamId, "Your order is ready for pickup!") - } else if (status === "COMPLETED") { - notifyTeam(order.teamId, "Your order has been completed!") + notifyOrderUpdate(order.teamId, order, "Ready for Pickup") + } else if (status === "IN_PROGRESS") { + notifyOrderUpdate(order.teamId, order, "In Progress") } await logAdminAction( diff --git a/app/api/inventory/admin/orders/route.ts b/app/api/inventory/admin/orders/route.ts index 9014aaf7..a13d6ab2 100644 --- a/app/api/inventory/admin/orders/route.ts +++ b/app/api/inventory/admin/orders/route.ts @@ -3,14 +3,18 @@ import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { OrderStatus } from "@/app/generated/prisma/client" +const VALID_STATUSES = new Set(Object.values(OrderStatus)) + export async function GET(request: Request) { const adminResult = await requireAdmin() if ("error" in adminResult) return adminResult.error const { searchParams } = new URL(request.url) - const status = searchParams.get("status") as OrderStatus | null + const statusParam = searchParams.get("status") - const where = status ? { status } : {} + const where = statusParam && VALID_STATUSES.has(statusParam) + ? { status: statusParam as OrderStatus } + : {} const orders = await prisma.order.findMany({ where, diff --git a/app/api/inventory/admin/rentals/[id]/return/route.ts b/app/api/inventory/admin/rentals/[id]/return/route.ts index 316d85ba..9fe7fdc8 100644 --- a/app/api/inventory/admin/rentals/[id]/return/route.ts +++ b/app/api/inventory/admin/rentals/[id]/return/route.ts @@ -3,7 +3,7 @@ import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { logAdminAction, AuditAction } from "@/lib/audit" import { pushSSE } from "@/lib/inventory/sse" -import { notifyTeam } from "@/lib/inventory/notifications" +import { notifyRental } from "@/lib/inventory/notifications" export async function PATCH( _request: Request, @@ -53,10 +53,7 @@ export async function PATCH( return result }) - notifyTeam( - updated.teamId, - `Tool ${updated.tool.name} has been returned` - ) + notifyRental(updated.teamId, updated.tool.name, "Tool Returned") await logAdminAction( AuditAction.INVENTORY_RENTAL_RETURN, diff --git a/app/api/inventory/admin/tools/[id]/route.ts b/app/api/inventory/admin/tools/[id]/route.ts new file mode 100644 index 00000000..96b382f5 --- /dev/null +++ b/app/api/inventory/admin/tools/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + const { id } = await params + + const existing = await prisma.tool.findUnique({ where: { id } }) + if (!existing) return NextResponse.json({ error: "Tool not found" }, { status: 404 }) + + const body = await request.json() + const { name, description, imageUrl } = body + + const tool = await prisma.tool.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(imageUrl !== undefined && { imageUrl }), + }, + }) + + await logAdminAction( + AuditAction.INVENTORY_TOOL_UPDATE, + session.user.id, + session.user.email, + "Tool", + tool.id, + body + ) + + return NextResponse.json(tool) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + const { id } = await params + + const existing = await prisma.tool.findUnique({ where: { id } }) + if (!existing) return NextResponse.json({ error: "Tool not found" }, { status: 404 }) + + const activeRental = await prisma.toolRental.findFirst({ + where: { toolId: id, status: "CHECKED_OUT" }, + }) + if (activeRental) { + return NextResponse.json( + { error: "Cannot delete a tool that is currently rented out" }, + { status: 400 } + ) + } + + await prisma.tool.delete({ where: { id } }) + + await logAdminAction( + AuditAction.INVENTORY_TOOL_DELETE, + session.user.id, + session.user.email, + "Tool", + id, + { name: existing.name } + ) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/admin/tools/route.ts b/app/api/inventory/admin/tools/route.ts new file mode 100644 index 00000000..a07f34ea --- /dev/null +++ b/app/api/inventory/admin/tools/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" + +export async function GET() { + const result = await requireAdmin() + if ("error" in result) return result.error + + const tools = await prisma.tool.findMany({ orderBy: { name: "asc" } }) + return NextResponse.json(tools) +} + +export async function POST(request: Request) { + const result = await requireAdmin() + if ("error" in result) return result.error + + const { session } = result + + const body = await request.json() + const { name, description, imageUrl } = body + + if (!name) { + return NextResponse.json( + { error: "Name is required" }, + { status: 400 } + ) + } + + const tool = await prisma.tool.create({ + data: { + name, + description: description ?? null, + imageUrl: imageUrl ?? null, + }, + }) + + await logAdminAction( + AuditAction.INVENTORY_TOOL_CREATE, + session.user.id, + session.user.email, + "Tool", + tool.id, + { name } + ) + + return NextResponse.json(tool, { status: 201 }) +} diff --git a/app/api/inventory/items/route.ts b/app/api/inventory/items/route.ts index 8230f7cb..99feea6b 100644 --- a/app/api/inventory/items/route.ts +++ b/app/api/inventory/items/route.ts @@ -7,15 +7,15 @@ export async function GET() { const session = await auth.api.getSession({ headers: await headers() }) if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const items = await prisma.item.findMany({ - orderBy: { name: "asc" }, - }) - - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { teamId: true }, - }) + const [items, user] = await Promise.all([ + prisma.item.findMany({ orderBy: { name: "asc" } }), + prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }), + ]) + let usageMap = new Map() if (user?.teamId) { const usageRows = await prisma.orderItem.groupBy({ by: ["itemId"], @@ -24,23 +24,7 @@ export async function GET() { }, _sum: { quantity: true }, }) - - const usageMap = new Map( - usageRows.map((r) => [r.itemId, r._sum.quantity ?? 0]) - ) - - return NextResponse.json( - items.map((item) => ({ - id: item.id, - name: item.name, - description: item.description, - imageUrl: item.imageUrl, - stock: item.stock, - category: item.category, - maxPerTeam: item.maxPerTeam, - teamUsed: usageMap.get(item.id) ?? 0, - })) - ) + usageMap = new Map(usageRows.map((r) => [r.itemId, r._sum.quantity ?? 0])) } return NextResponse.json( @@ -52,7 +36,7 @@ export async function GET() { stock: item.stock, category: item.category, maxPerTeam: item.maxPerTeam, - teamUsed: 0, + teamUsed: usageMap.get(item.id) ?? 0, })) ) } diff --git a/app/api/inventory/lookup/[slackUserId]/route.ts b/app/api/inventory/lookup/[slackUserId]/route.ts index 38fb6715..826c3f6e 100644 --- a/app/api/inventory/lookup/[slackUserId]/route.ts +++ b/app/api/inventory/lookup/[slackUserId]/route.ts @@ -11,23 +11,35 @@ export async function GET( const { slackUserId } = await params - const user = await prisma.user.findUnique({ - where: { slackId: slackUserId }, - select: { - id: true, - name: true, - slackDisplayName: true, - image: true, - teamId: true, - team: { - select: { - id: true, - name: true, - }, + // Try slackId first, then nfcId (for HID badge readers that send tag UIDs) + const userSelect = { + id: true, + name: true, + slackId: true, + slackDisplayName: true, + nfcId: true, + image: true, + teamId: true, + team: { + select: { + id: true, + name: true, }, }, + } as const + + let user = await prisma.user.findUnique({ + where: { slackId: slackUserId }, + select: userSelect, }) + if (!user) { + user = await prisma.user.findUnique({ + where: { nfcId: slackUserId }, + select: userSelect, + }) + } + if (!user) { return NextResponse.json( { error: "User not found" }, @@ -40,6 +52,8 @@ export async function GET( user: { id: user.id, name: user.slackDisplayName ?? user.name, + slackId: user.slackId, + nfcId: user.nfcId, image: user.image, }, team: null, @@ -52,7 +66,7 @@ export async function GET( prisma.order.findFirst({ where: { teamId: user.teamId, - status: { not: "COMPLETED" }, + status: { notIn: ["COMPLETED", "CANCELLED"] }, }, include: { items: { include: { item: true } }, @@ -80,6 +94,8 @@ export async function GET( user: { id: user.id, name: user.slackDisplayName ?? user.name, + slackId: user.slackId, + nfcId: user.nfcId, image: user.image, }, team: user.team, diff --git a/app/api/inventory/orders/[id]/cancel/route.ts b/app/api/inventory/orders/[id]/cancel/route.ts new file mode 100644 index 00000000..f103f9f6 --- /dev/null +++ b/app/api/inventory/orders/[id]/cancel/route.ts @@ -0,0 +1,69 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { pushSSE } from "@/lib/inventory/sse" +import { notifyOrderUpdate } from "@/lib/inventory/notifications" + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + + const order = await prisma.order.findUnique({ + where: { id }, + include: { + team: { select: { id: true } }, + items: { include: { item: { select: { name: true } } } }, + }, + }) + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }) + } + + // Verify user is on the same team + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + + if (!user?.teamId || user.teamId !== order.teamId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + // Can only cancel if not yet READY or COMPLETED + if (order.status === "READY" || order.status === "COMPLETED" || order.status === "CANCELLED") { + return NextResponse.json( + { error: "Cannot cancel an order that is already ready, completed, or cancelled" }, + { status: 400 } + ) + } + + // Cancel and restore stock + await prisma.$transaction(async (tx) => { + await tx.order.update({ + where: { id }, + data: { status: "CANCELLED" }, + }) + + // Restore stock for each item + for (const item of order.items) { + await tx.item.update({ + where: { id: item.itemId }, + data: { stock: { increment: item.quantity } }, + }) + } + }) + + notifyOrderUpdate(order.teamId, order, "Cancelled") + pushSSE(order.teamId, { type: "order_status_updated", data: { id, status: "CANCELLED" } }) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts index c682b909..7852cc30 100644 --- a/app/api/inventory/orders/route.ts +++ b/app/api/inventory/orders/route.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { pushSSE } from "@/lib/inventory/sse" -import { notifyTeam } from "@/lib/inventory/notifications" +import { notifyOrderUpdate } from "@/lib/inventory/notifications" export async function GET() { const session = await auth.api.getSession({ headers: await headers() }) @@ -59,82 +59,91 @@ export async function POST(request: Request) { ) } - const order = await prisma.$transaction(async (tx) => { - const user = await tx.user.findUnique({ - where: { id: session.user.id }, - include: { team: true }, - }) - - if (!user?.teamId || !user.team) { - throw new Error("You must be on a team to place an order") - } - - if (user.team.locked) { - throw new Error("Your team is locked and cannot place orders") - } - - const activeOrder = await tx.order.findFirst({ - where: { - teamId: user.teamId, - status: { not: "COMPLETED" }, - }, - }) - - if (activeOrder) { - throw new Error("Your team already has an active order") - } + let order + try { + order = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + include: { team: true }, + }) - for (const { itemId, quantity } of items) { - const item = await tx.item.findUnique({ where: { id: itemId } }) - if (!item) { - throw new Error(`Item ${itemId} not found`) - } - if (item.stock < quantity) { - throw new Error(`Insufficient stock for ${item.name}`) + if (!user?.teamId || !user.team) { + throw new Error("You must be on a team to place an order") } - const usageResult = await tx.orderItem.aggregate({ - _sum: { quantity: true }, - where: { - itemId, - order: { teamId: user.teamId }, - }, - }) - const totalUsage = usageResult._sum.quantity ?? 0 + if (user.team.locked) { + throw new Error("Your team is locked and cannot place orders") + } - if (totalUsage + quantity > item.maxPerTeam) { - throw new Error( - `Exceeds max per team for ${item.name} (limit: ${item.maxPerTeam}, used: ${totalUsage})` - ) + const allowMultiple = process.env.INVENTORY_ALLOW_MULTIPLE_ORDERS === "true" + if (!allowMultiple) { + const activeOrder = await tx.order.findFirst({ + where: { + teamId: user.teamId, + status: { notIn: ["COMPLETED", "CANCELLED"] }, + }, + }) + + if (activeOrder) { + throw new Error("Your team already has an active order") + } } - await tx.item.update({ - where: { id: itemId }, - data: { stock: { decrement: quantity } }, - }) - } - - return tx.order.create({ - data: { - teamId: user.teamId, - placedById: session.user.id, - floor, - location, - items: { - create: items.map(({ itemId, quantity }) => ({ + for (const { itemId, quantity } of items) { + const item = await tx.item.findUnique({ where: { id: itemId } }) + if (!item) { + throw new Error(`Item ${itemId} not found`) + } + if (item.stock < quantity) { + throw new Error(`Insufficient stock for ${item.name}`) + } + + const usageResult = await tx.orderItem.aggregate({ + _sum: { quantity: true }, + where: { itemId, - quantity, - })), + order: { teamId: user.teamId }, + }, + }) + const totalUsage = usageResult._sum.quantity ?? 0 + + if (totalUsage + quantity > item.maxPerTeam) { + throw new Error( + `Exceeds max per team for ${item.name} (limit: ${item.maxPerTeam}, used: ${totalUsage})` + ) + } + + await tx.item.update({ + where: { id: itemId }, + data: { stock: { decrement: quantity } }, + }) + } + + return tx.order.create({ + data: { + teamId: user.teamId, + placedById: session.user.id, + floor, + location, + items: { + create: items.map(({ itemId, quantity }) => ({ + itemId, + quantity, + })), + }, }, - }, - include: { - items: { include: { item: true } }, - placedBy: { select: { id: true, name: true, email: true } }, - }, + include: { + items: { include: { item: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + }, + }) }) - }) + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to place order" + return NextResponse.json({ error: message }, { status: 400 }) + } - notifyTeam(order.teamId, "Your team's order has been placed!") + notifyOrderUpdate(order.teamId, order, "Placed") pushSSE(order.teamId, { type: "order_placed", data: order }) return NextResponse.json(order, { status: 201 }) diff --git a/app/api/inventory/rentals/route.ts b/app/api/inventory/rentals/route.ts index e96d964e..b729828f 100644 --- a/app/api/inventory/rentals/route.ts +++ b/app/api/inventory/rentals/route.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { pushSSE } from "@/lib/inventory/sse" -import { notifyTeam } from "@/lib/inventory/notifications" +import { notifyRental } from "@/lib/inventory/notifications" import { MAX_CONCURRENT_RENTALS, TOOL_RENTAL_TIME_LIMIT_MINUTES, @@ -59,64 +59,70 @@ export async function POST(request: Request) { ) } - const rental = await prisma.$transaction(async (tx) => { - const user = await tx.user.findUnique({ - where: { id: session.user.id }, - include: { team: true }, + let rental + try { + rental = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + include: { team: true }, + }) + + if (!user?.teamId || !user.team) { + throw new Error("You must be on a team to rent a tool") + } + + const tool = await tx.tool.findUnique({ where: { id: toolId } }) + if (!tool) { + throw new Error("Tool not found") + } + if (!tool.available) { + throw new Error("Tool is not available") + } + + const activeRentals = await tx.toolRental.count({ + where: { + teamId: user.teamId, + status: "CHECKED_OUT", + }, + }) + + if (activeRentals >= MAX_CONCURRENT_RENTALS) { + throw new Error( + `Your team already has ${MAX_CONCURRENT_RENTALS} active rental(s)` + ) + } + + await tx.tool.update({ + where: { id: toolId }, + data: { available: false }, + }) + + const dueAt = + TOOL_RENTAL_TIME_LIMIT_MINUTES > 0 + ? new Date(Date.now() + TOOL_RENTAL_TIME_LIMIT_MINUTES * 60 * 1000) + : null + + return tx.toolRental.create({ + data: { + toolId, + teamId: user.teamId, + rentedById: session.user.id, + floor, + location, + dueAt, + }, + include: { + tool: true, + rentedBy: { select: { id: true, name: true, email: true } }, + }, + }) }) + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to rent tool" + return NextResponse.json({ error: message }, { status: 400 }) + } - if (!user?.teamId || !user.team) { - throw new Error("You must be on a team to rent a tool") - } - - const tool = await tx.tool.findUnique({ where: { id: toolId } }) - if (!tool) { - throw new Error("Tool not found") - } - if (!tool.available) { - throw new Error("Tool is not available") - } - - const activeRentals = await tx.toolRental.count({ - where: { - teamId: user.teamId, - status: "CHECKED_OUT", - }, - }) - - if (activeRentals >= MAX_CONCURRENT_RENTALS) { - throw new Error( - `Your team already has ${MAX_CONCURRENT_RENTALS} active rental(s)` - ) - } - - await tx.tool.update({ - where: { id: toolId }, - data: { available: false }, - }) - - const dueAt = - TOOL_RENTAL_TIME_LIMIT_MINUTES > 0 - ? new Date(Date.now() + TOOL_RENTAL_TIME_LIMIT_MINUTES * 60 * 1000) - : null - - return tx.toolRental.create({ - data: { - toolId, - teamId: user.teamId, - rentedById: session.user.id, - floor, - location, - dueAt, - }, - include: { - tool: true, - rentedBy: { select: { id: true, name: true, email: true } }, - }, - }) - }) - - notifyTeam(rental.teamId, `Your team has rented: ${rental.tool.name}`) + notifyRental(rental.teamId, rental.tool.name, "Tool Rented") pushSSE(rental.teamId, { type: "rental_created", data: rental }) return NextResponse.json(rental, { status: 201 }) diff --git a/app/api/inventory/sse/route.ts b/app/api/inventory/sse/route.ts index dfb355f6..e5502c7e 100644 --- a/app/api/inventory/sse/route.ts +++ b/app/api/inventory/sse/route.ts @@ -3,6 +3,9 @@ import { headers } from "next/headers" import { NextRequest } from "next/server" import { registerConnection, removeConnection } from "@/lib/inventory/sse" +const encoder = new TextEncoder() +const KEEPALIVE = encoder.encode(": keepalive\n\n") + export async function GET(request: NextRequest) { const session = await auth.api.getSession({ headers: await headers() }) if (!session) { @@ -15,19 +18,16 @@ export async function GET(request: NextRequest) { start(controller) { registerConnection(teamId, controller) - // Send initial keepalive - controller.enqueue(new TextEncoder().encode(": keepalive\n\n")) + controller.enqueue(KEEPALIVE) - // Keepalive interval const interval = setInterval(() => { try { - controller.enqueue(new TextEncoder().encode(": keepalive\n\n")) + controller.enqueue(KEEPALIVE) } catch { clearInterval(interval) } }, 30_000) - // Clean up on abort request.signal.addEventListener("abort", () => { clearInterval(interval) removeConnection(teamId, controller) diff --git a/app/api/inventory/teams/[id]/join/route.ts b/app/api/inventory/teams/[id]/join/route.ts index d4bd041a..3dc75486 100644 --- a/app/api/inventory/teams/[id]/join/route.ts +++ b/app/api/inventory/teams/[id]/join/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" import { MAX_TEAM_SIZE } from "@/lib/inventory/config" +import { syncTeamChannel } from "@/lib/inventory/team-channel" export async function POST( request: Request, @@ -41,12 +42,12 @@ export async function POST( return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) } - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: session.user.id }, - data: { teamId: id }, - }) + await prisma.user.update({ + where: { id: session.user.id }, + data: { teamId: id }, }) + syncTeamChannel(id).catch(() => {}) + return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/[id]/leave/route.ts b/app/api/inventory/teams/[id]/leave/route.ts index 29b49e6a..d805170e 100644 --- a/app/api/inventory/teams/[id]/leave/route.ts +++ b/app/api/inventory/teams/[id]/leave/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" +import { removeFromTeam } from "@/lib/inventory/teams" export async function POST( request: Request, @@ -23,18 +24,7 @@ export async function POST( return NextResponse.json({ error: "You are not a member of this team" }, { status: 400 }) } - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: session.user.id }, - data: { teamId: null }, - }) - - const remaining = await tx.user.count({ where: { teamId: id } }) - - if (remaining === 0) { - await tx.team.delete({ where: { id } }) - } - }) + await removeFromTeam(session.user.id, id) return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/[id]/members/[userId]/route.ts b/app/api/inventory/teams/[id]/members/[userId]/route.ts index aef4f88a..02dd85e4 100644 --- a/app/api/inventory/teams/[id]/members/[userId]/route.ts +++ b/app/api/inventory/teams/[id]/members/[userId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" +import { removeFromTeam } from "@/lib/inventory/teams" export async function DELETE( request: Request, @@ -32,18 +33,7 @@ export async function DELETE( return NextResponse.json({ error: "User is not a member of this team" }, { status: 400 }) } - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: userId }, - data: { teamId: null }, - }) - - const remaining = await tx.user.count({ where: { teamId: id } }) - - if (remaining === 0) { - await tx.team.delete({ where: { id } }) - } - }) + await removeFromTeam(userId, id) return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/[id]/members/route.ts b/app/api/inventory/teams/[id]/members/route.ts index 5f43ed6d..507a203f 100644 --- a/app/api/inventory/teams/[id]/members/route.ts +++ b/app/api/inventory/teams/[id]/members/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" import { MAX_TEAM_SIZE } from "@/lib/inventory/config" +import { syncTeamChannel } from "@/lib/inventory/team-channel" export async function POST( request: Request, @@ -51,12 +52,12 @@ export async function POST( return NextResponse.json({ error: "User is already on a team" }, { status: 400 }) } - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: targetUser.id }, - data: { teamId: id }, - }) + await prisma.user.update({ + where: { id: targetUser.id }, + data: { teamId: id }, }) + syncTeamChannel(id).catch(() => {}) + return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/route.ts b/app/api/inventory/teams/route.ts index eb947281..5289efb6 100644 --- a/app/api/inventory/teams/route.ts +++ b/app/api/inventory/teams/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" +import { syncTeamChannel } from "@/lib/inventory/team-channel" export async function GET() { const session = await auth.api.getSession({ headers: await headers() }) @@ -65,5 +66,7 @@ export async function POST(request: Request) { return created }) + syncTeamChannel(team.id).catch(() => {}) + return NextResponse.json(team, { status: 201 }) } diff --git a/app/components/inventory/CartPanel.tsx b/app/components/inventory/CartPanel.tsx index 40322171..a75fc74c 100644 --- a/app/components/inventory/CartPanel.tsx +++ b/app/components/inventory/CartPanel.tsx @@ -6,66 +6,113 @@ interface CartItem { quantity: number; } +interface CartTool { + toolId: string; + name: string; +} + interface CartPanelProps { items: CartItem[]; + tools: CartTool[]; onUpdateQuantity: (itemId: string, qty: number) => void; - onRemove: (itemId: string) => void; + onRemoveItem: (itemId: string) => void; + onRemoveTool: (toolId: string) => void; onCheckout: () => void; disabled?: boolean; + hasActiveOrder?: boolean; } -export function CartPanel({ items, onUpdateQuantity, onRemove, onCheckout, disabled }: CartPanelProps) { +export function CartPanel({ items, tools, onUpdateQuantity, onRemoveItem, onRemoveTool, onCheckout, disabled, hasActiveOrder }: CartPanelProps) { const totalItems = items.reduce((sum, item) => sum + item.quantity, 0); - const isEmpty = items.length === 0; + const isEmpty = items.length === 0 && tools.length === 0; return (

Cart

{isEmpty ? ( -

No items in cart.

+

Nothing in cart.

) : ( -
    - {items.map(item => ( -
  • - {item.name} -
    -
    - - - {item.quantity} - - -
    - -
    -
  • - ))} -
+
+ {items.length > 0 && ( +
    + {items.map(item => ( +
  • + {item.name} +
    +
    + + + {item.quantity} + + +
    + +
    +
  • + ))} +
+ )} + + {tools.length > 0 && ( + <> + {items.length > 0 &&
} +
    + {tools.map(tool => ( +
  • +
    + Rental + {tool.name} +
    + +
  • + ))} +
+ + )} +
)}
-
- Total items - {totalItems} -
+ {(totalItems > 0 || tools.length > 0) && ( +
+ {totalItems > 0 && ( +
+ Parts + {totalItems} +
+ )} + {tools.length > 0 && ( +
+ Tool rentals + {tools.length} +
+ )} +
+ )} + {hasActiveOrder && ( +

Your team has an active parts order. You can place another once it is completed or cancelled.

+ )}
); diff --git a/app/components/inventory/CheckoutModal.tsx b/app/components/inventory/CheckoutModal.tsx index 81ac622d..4c631346 100644 --- a/app/components/inventory/CheckoutModal.tsx +++ b/app/components/inventory/CheckoutModal.tsx @@ -8,15 +8,23 @@ interface CheckoutItem { quantity: number; } +interface CheckoutTool { + toolId: string; + name: string; +} + interface CheckoutModalProps { isOpen: boolean; onClose: () => void; items: CheckoutItem[]; + tools: CheckoutTool[]; onConfirm: (floor: number, location: string) => void; isSubmitting?: boolean; + venueFloors?: number; + error?: string | null; } -export function CheckoutModal({ isOpen, onClose, items, onConfirm, isSubmitting }: CheckoutModalProps) { +export function CheckoutModal({ isOpen, onClose, items, tools, onConfirm, isSubmitting, venueFloors = 3, error }: CheckoutModalProps) { const [floor, setFloor] = useState(1); const [location, setLocation] = useState(''); @@ -26,22 +34,38 @@ export function CheckoutModal({ isOpen, onClose, items, onConfirm, isSubmitting return (
-
-
-

Confirm Order

+
+
+

Confirm Checkout

- {/* Order summary */} -
-

Items

-
    - {items.map(item => ( -
  • - {item.name} - x{item.quantity} -
  • - ))} -
-
+ {/* Parts summary */} + {items.length > 0 && ( +
+

Parts

+
    + {items.map(item => ( +
  • + {item.name} + x{item.quantity} +
  • + ))} +
+
+ )} + + {/* Tool rentals summary */} + {tools.length > 0 && ( +
+

Tool Rentals

+
    + {tools.map(tool => ( +
  • {tool.name}
  • + ))} +
+
+ )} + + {(items.length > 0 || tools.length > 0) &&
} {/* Floor dropdown */}
@@ -51,9 +75,9 @@ export function CheckoutModal({ isOpen, onClose, items, onConfirm, isSubmitting onChange={e => setFloor(Number(e.target.value))} className="w-full border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm" > - - - + {Array.from({ length: venueFloors }, (_, i) => ( + + ))}
@@ -69,6 +93,12 @@ export function CheckoutModal({ isOpen, onClose, items, onConfirm, isSubmitting />
+ {error && ( +
+ {error} +
+ )} + {/* Actions */}
diff --git a/app/components/inventory/NFCScanner.tsx b/app/components/inventory/NFCScanner.tsx deleted file mode 100644 index ac0f50da..00000000 --- a/app/components/inventory/NFCScanner.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client'; - -import { useState, useCallback } from 'react'; - -interface NFCScannerProps { - mode: 'read' | 'write'; - writeData?: string; - onRead: (slackUserId: string) => void; - onWriteComplete?: () => void; -} - -export function NFCScanner({ mode, writeData, onRead, onWriteComplete }: NFCScannerProps) { - const [scanning, setScanning] = useState(false); - const [status, setStatus] = useState(''); - const [manualId, setManualId] = useState(''); - - const supportsNFC = typeof window !== 'undefined' && 'NDEFReader' in window; - - const startScan = useCallback(async () => { - if (!supportsNFC) return; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const NDEFReader = (window as any).NDEFReader; - const reader = new NDEFReader(); - - if (mode === 'read') { - setScanning(true); - setStatus('Scanning... Hold NFC tag near device.'); - await reader.scan(); - - reader.addEventListener('reading', ({ message }: { message: { records: Array<{ recordType: string; data: ArrayBuffer }> } }) => { - for (const record of message.records) { - if (record.recordType === 'text') { - const decoder = new TextDecoder(); - const value = decoder.decode(record.data); - setScanning(false); - setStatus('Tag read successfully.'); - onRead(value); - return; - } - } - setStatus('No text record found on tag.'); - }); - - reader.addEventListener('readingerror', () => { - setStatus('Error reading tag. Try again.'); - }); - } else if (mode === 'write' && writeData) { - setScanning(true); - setStatus('Hold NFC tag near device to write...'); - await reader.write({ - records: [{ recordType: 'text', data: writeData }], - }); - setScanning(false); - setStatus('Tag written successfully.'); - onWriteComplete?.(); - } - } catch (err) { - setScanning(false); - setStatus(`NFC error: ${err instanceof Error ? err.message : 'Unknown error'}`); - } - }, [mode, writeData, onRead, onWriteComplete, supportsNFC]); - - const stopScan = () => { - setScanning(false); - setStatus(''); - }; - - // Fallback: manual entry - if (!supportsNFC) { - return ( -
-

- NFC not supported on this device -

-
- setManualId(e.target.value)} - placeholder="Enter Slack User ID" - className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" - /> - -
-
- ); - } - - return ( -
-
- - NFC {mode === 'read' ? 'Reader' : 'Writer'} - - {scanning && ( - - )} -
- - {status && ( -

{status}

- )} - - -
- ); -} diff --git a/app/components/inventory/TeamPanel.tsx b/app/components/inventory/TeamPanel.tsx new file mode 100644 index 00000000..270f8797 --- /dev/null +++ b/app/components/inventory/TeamPanel.tsx @@ -0,0 +1,414 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; + +interface TeamMember { + id: string; + name: string; + slackDisplayName?: string; + image?: string; +} + +interface TeamDetail { + id: string; + name: string; + locked: boolean; + members: TeamMember[]; +} + +interface TeamListItem { + id: string; + name: string; + locked: boolean; + _count: { members: number }; +} + +interface TeamPanelProps { + teamId: string | undefined; + currentUserId: string; + onTeamChanged: () => void; +} + +export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelProps) { + const [team, setTeam] = useState(null); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + + const [newTeamName, setNewTeamName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [editingName, setEditingName] = useState(false); + const [editName, setEditName] = useState(''); + const [isSavingName, setIsSavingName] = useState(false); + const [addSlackId, setAddSlackId] = useState(''); + const [isAddingMember, setIsAddingMember] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const [removingUserId, setRemovingUserId] = useState(null); + const [confirmLeave, setConfirmLeave] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const showSuccess = (msg: string) => { + setSuccessMessage(msg); + setTimeout(() => setSuccessMessage(null), 5000); + }; + + const fetchTeam = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/inventory/teams/${id}`); + if (res.ok) setTeam(await res.json()); + } catch {} + }, []); + + const fetchTeams = useCallback(async () => { + try { + const res = await fetch('/api/inventory/teams'); + if (res.ok) setTeams(await res.json()); + } catch {} + }, []); + + useEffect(() => { + setLoading(true); + if (teamId) { + fetchTeam(teamId).finally(() => setLoading(false)); + } else { + setTeam(null); + fetchTeams().finally(() => setLoading(false)); + } + }, [teamId, fetchTeam, fetchTeams]); + + const handleCreateTeam = async () => { + if (!newTeamName.trim()) return; + setIsCreating(true); + setError(null); + try { + const res = await fetch('/api/inventory/teams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTeamName.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to create team'); + } + setNewTeamName(''); + showSuccess('Team created!'); + onTeamChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create team'); + } finally { + setIsCreating(false); + } + }; + + const handleJoinTeam = async (id: string) => { + setError(null); + try { + const res = await fetch(`/api/inventory/teams/${id}/join`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to join team'); + } + showSuccess('Joined team!'); + onTeamChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to join team'); + } + }; + + const handleSaveName = async () => { + if (!team || !editName.trim()) return; + setIsSavingName(true); + setError(null); + try { + const res = await fetch(`/api/inventory/teams/${team.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editName.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to update team name'); + } + setEditingName(false); + showSuccess('Team name updated!'); + onTeamChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update team name'); + } finally { + setIsSavingName(false); + } + }; + + const handleAddMember = async () => { + if (!team || !addSlackId.trim()) return; + setIsAddingMember(true); + setError(null); + try { + const res = await fetch(`/api/inventory/teams/${team.id}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slackId: addSlackId.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to add member'); + } + setAddSlackId(''); + showSuccess('Member added!'); + await fetchTeam(team.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add member'); + } finally { + setIsAddingMember(false); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!team) return; + setRemovingUserId(userId); + setError(null); + try { + const res = await fetch(`/api/inventory/teams/${team.id}/members/${userId}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to remove member'); + } + showSuccess('Member removed.'); + await fetchTeam(team.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove member'); + } finally { + setRemovingUserId(null); + } + }; + + const handleLeaveTeam = async () => { + if (!team) return; + setIsLeaving(true); + setError(null); + try { + const res = await fetch(`/api/inventory/teams/${team.id}/leave`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to leave team'); + } + setConfirmLeave(false); + showSuccess('You left the team.'); + onTeamChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to leave team'); + } finally { + setIsLeaving(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + const messages = ( + <> + {successMessage && ( +
+ {successMessage} +
+ )} + {error && ( +
+ {error} + +
+ )} + + ); + + // No team -- show create/join UI + if (!teamId) { + return ( +
+ {messages} + +
+

Create a Team

+
+ setNewTeamName(e.target.value)} + placeholder="Team name" + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + onKeyDown={e => { if (e.key === 'Enter') handleCreateTeam(); }} + /> + +
+
+ +
+

Join a Team

+ {teams.length === 0 ? ( +

No teams available to join.

+ ) : ( +
+ {teams.map(t => ( +
+
+ {t.name} + + {t._count.members} member{t._count.members !== 1 ? 's' : ''} + + {t.locked && ( + + Locked + + )} +
+ +
+ ))} +
+ )} +
+
+ ); + } + + // Has team -- show team details + if (!team) return null; + + return ( +
+
+ {editingName ? ( +
+ setEditName(e.target.value)} + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-lg" + autoFocus + /> + + +
+ ) : ( + <> +

{team.name}

+ {team.locked ? ( + + Locked + + ) : ( + + )} + + )} +
+ + {messages} + +
+
+ {team.members.map(member => ( +
+ +
+ {member.name} + {member.slackDisplayName && ( + {member.slackDisplayName} + )} +
+ {member.id !== currentUserId && !team.locked && ( + + )} +
+ ))} +
+ + {!team.locked && ( +
+ setAddSlackId(e.target.value)} + placeholder="Slack User ID" + className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" + /> + +
+ )} + +
+ {confirmLeave ? ( +
+ Are you sure? + + +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/app/components/inventory/ToolCard.tsx b/app/components/inventory/ToolCard.tsx index a0484814..feb230db 100644 --- a/app/components/inventory/ToolCard.tsx +++ b/app/components/inventory/ToolCard.tsx @@ -9,9 +9,13 @@ interface ToolCardProps { available: boolean; }; onRent: (toolId: string) => void; + inCart?: boolean; + canRent?: boolean; } -export function ToolCard({ tool, onRent }: ToolCardProps) { +export function ToolCard({ tool, onRent, inCart, canRent = true }: ToolCardProps) { + const disabled = !tool.available || inCart || !canRent; + return (
{/* Image */} @@ -49,10 +53,14 @@ export function ToolCard({ tool, onRent }: ToolCardProps) {
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 3fe986cf..a350cabe 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -22,6 +22,7 @@ export default function DashboardLayout({ const router = useRouter(); const [isFraudSuspended, setIsFraudSuspended] = useState(false); + const [inventoryEnabled, setInventoryEnabled] = useState(false); useEffect(() => { if (session) { @@ -37,6 +38,12 @@ export default function DashboardLayout({ if (data?.fraudConvicted) setIsFraudSuspended(true); }) .catch(() => {}); + fetch('/api/inventory/access') + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data?.enabled || data?.isAdmin) setInventoryEnabled(true); + }) + .catch(() => {}); } }, [session]); @@ -147,9 +154,11 @@ export default function DashboardLayout({ Guidelines & FAQ - - Inventory - + {inventoryEnabled && ( + + Inventory → + + )}
diff --git a/app/inventory/InventoryAccessContext.tsx b/app/inventory/InventoryAccessContext.tsx new file mode 100644 index 00000000..c24caa91 --- /dev/null +++ b/app/inventory/InventoryAccessContext.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export interface AccessInfo { + allowed: boolean; + reason?: string; + isAdmin: boolean; + teamId?: string; + teamName?: string; + venueFloors?: number; + maxConcurrentRentals?: number; + allowMultipleOrders?: boolean; +} + +const InventoryAccessContext = createContext(null); + +export function InventoryAccessProvider({ + value, + children, +}: { + value: AccessInfo; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useInventoryAccess() { + return useContext(InventoryAccessContext); +} diff --git a/app/inventory/admin/items/page.tsx b/app/inventory/admin/items/page.tsx index 4cbfcb28..132210f6 100644 --- a/app/inventory/admin/items/page.tsx +++ b/app/inventory/admin/items/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; interface Item { id: string; @@ -12,9 +12,21 @@ interface Item { imageUrl?: string; } -interface DigiKeyImage { - url: string; +interface Tool { + id: string; + name: string; description?: string; + imageUrl?: string; + available: boolean; +} + +interface DigiKeyResult { + name: string; + description: string; + manufacturer: string; + partNumber: string; + imageUrl: string; + category: string; } interface CSVRow { @@ -29,7 +41,6 @@ interface CSVRow { function parseCSV(text: string): CSVRow[] { const lines = text.split('\n').filter((l) => l.trim()); if (lines.length < 2) return []; - const parseRow = (line: string): string[] => { const fields: string[] = []; let current = ''; @@ -37,57 +48,39 @@ function parseCSV(text: string): CSVRow[] { for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { - if (inQuotes && line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - } else if (ch === ',' && !inQuotes) { - fields.push(current.trim()); - current = ''; - } else { - current += ch; - } + if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } + } else if (ch === ',' && !inQuotes) { fields.push(current.trim()); current = ''; } else { current += ch; } } fields.push(current.trim()); return fields; }; - const headers = parseRow(lines[0]).map((h) => h.toLowerCase().replace(/\s+/g, '_')); const rows: CSVRow[] = []; - for (let i = 1; i < lines.length; i++) { const values = parseRow(lines[i]); if (values.length < headers.length) continue; - const nameIdx = headers.indexOf('name'); const descIdx = headers.indexOf('description'); const catIdx = headers.indexOf('category'); const stockIdx = headers.indexOf('stock'); const maxIdx = headers.indexOf('max_per_team'); const imgIdx = headers.indexOf('image_url'); - if (nameIdx === -1 || catIdx === -1 || stockIdx === -1 || maxIdx === -1) continue; - rows.push({ - name: values[nameIdx] || '', - description: descIdx >= 0 ? values[descIdx] || '' : '', - category: values[catIdx] || '', - stock: parseInt(values[stockIdx], 10) || 0, - max_per_team: parseInt(values[maxIdx], 10) || 0, - image_url: imgIdx >= 0 ? values[imgIdx] || '' : '', + name: values[nameIdx] || '', description: descIdx >= 0 ? values[descIdx] || '' : '', + category: values[catIdx] || '', stock: parseInt(values[stockIdx], 10) || 0, + max_per_team: parseInt(values[maxIdx], 10) || 0, image_url: imgIdx >= 0 ? values[imgIdx] || '' : '', }); } - return rows; } -export default function AdminItemsPage() { +export default function AdminInventoryPage() { + // Items const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); + const [itemsLoading, setItemsLoading] = useState(true); + const categories = useMemo(() => [...new Set(items.map((i) => i.category))].sort(), [items]); - // Add form const [formName, setFormName] = useState(''); const [formDescription, setFormDescription] = useState(''); const [formCategory, setFormCategory] = useState(''); @@ -97,369 +90,185 @@ export default function AdminItemsPage() { const [formSubmitting, setFormSubmitting] = useState(false); const [formError, setFormError] = useState(null); - // Edit - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [editDescription, setEditDescription] = useState(''); - const [editCategory, setEditCategory] = useState(''); - const [editStock, setEditStock] = useState(''); - const [editMaxPerTeam, setEditMaxPerTeam] = useState(''); - const [editImageUrl, setEditImageUrl] = useState(''); - const [editSubmitting, setEditSubmitting] = useState(false); + const [editingItemId, setEditingItemId] = useState(null); + const [editItemName, setEditItemName] = useState(''); + const [editItemDescription, setEditItemDescription] = useState(''); + const [editItemCategory, setEditItemCategory] = useState(''); + const [editItemStock, setEditItemStock] = useState(''); + const [editItemMaxPerTeam, setEditItemMaxPerTeam] = useState(''); + const [editItemImageUrl, setEditItemImageUrl] = useState(''); + const [editItemSubmitting, setEditItemSubmitting] = useState(false); - // DigiKey search - const [digiKeyQuery, setDigiKeyQuery] = useState(''); - const [digiKeyResults, setDigiKeyResults] = useState([]); + const [digiKeyResults, setDigiKeyResults] = useState([]); const [digiKeyLoading, setDigiKeyLoading] = useState(false); - const [digiKeyTarget, setDigiKeyTarget] = useState<'add' | 'edit'>('add'); + const [digiKeyContext, setDigiKeyContext] = useState<'item-add' | 'item-edit' | 'tool-add' | 'tool-edit' | null>(null); + const [pendingItemDkName, setPendingItemDkName] = useState(null); - // CSV import const [csvData, setCsvData] = useState(null); const [csvImporting, setCsvImporting] = useState(false); const [csvResult, setCsvResult] = useState(null); + // Tools + const [tools, setTools] = useState([]); + const [toolsLoading, setToolsLoading] = useState(true); + const [toolFormName, setToolFormName] = useState(''); + const [toolFormDescription, setToolFormDescription] = useState(''); + const [toolFormImageUrl, setToolFormImageUrl] = useState(''); + const [toolFormSubmitting, setToolFormSubmitting] = useState(false); + const [toolFormError, setToolFormError] = useState(null); + const [editingToolId, setEditingToolId] = useState(null); + const [editToolName, setEditToolName] = useState(''); + const [editToolDescription, setEditToolDescription] = useState(''); + const [editToolImageUrl, setEditToolImageUrl] = useState(''); + const [editToolSubmitting, setEditToolSubmitting] = useState(false); + const [pendingToolDkName, setPendingToolDkName] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const fetchItems = useCallback(async () => { - try { - const res = await fetch('/api/inventory/items'); - if (res.ok) { - const data = await res.json(); - setItems(data); - } - } catch { - // silently fail - } finally { - setLoading(false); - } + try { const res = await fetch('/api/inventory/items'); if (res.ok) setItems(await res.json()); } catch {} finally { setItemsLoading(false); } + }, []); + const fetchTools = useCallback(async () => { + try { const res = await fetch('/api/inventory/admin/tools'); if (res.ok) setTools(await res.json()); } catch {} finally { setToolsLoading(false); } }, []); + useEffect(() => { fetchItems(); }, [fetchItems]); + useEffect(() => { fetchTools(); }, [fetchTools]); - useEffect(() => { - fetchItems(); - }, [fetchItems]); + const handleDigiKeySearch = async (query: string, context: 'item-add' | 'item-edit' | 'tool-add' | 'tool-edit') => { + if (!query.trim()) return; + setDigiKeyLoading(true); setDigiKeyResults([]); setDigiKeyContext(context); + try { const res = await fetch(`/api/inventory/digikey/search?q=${encodeURIComponent(query)}`); if (res.ok) setDigiKeyResults(await res.json()); } catch {} finally { setDigiKeyLoading(false); } + }; + // Item handlers const handleAddItem = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - if (!formName || !formCategory || !formStock || !formMaxPerTeam) { - setFormError('Name, category, stock, and max per team are required.'); - return; - } + e.preventDefault(); setFormError(null); + if (!formName || !formCategory || !formStock || !formMaxPerTeam) { setFormError('Name, category, stock, and max per team are required.'); return; } setFormSubmitting(true); try { - const res = await fetch('/api/inventory/admin/items', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: formName, - description: formDescription || undefined, - category: formCategory, - stock: parseInt(formStock, 10), - maxPerTeam: parseInt(formMaxPerTeam, 10), - imageUrl: formImageUrl || undefined, - }), - }); - if (!res.ok) { - const err = await res.json().catch(() => null); - setFormError(err?.error || 'Failed to add item.'); - return; - } - setFormName(''); - setFormDescription(''); - setFormCategory(''); - setFormStock(''); - setFormMaxPerTeam(''); - setFormImageUrl(''); + const res = await fetch('/api/inventory/admin/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formName, description: formDescription || undefined, category: formCategory, stock: parseInt(formStock, 10), maxPerTeam: parseInt(formMaxPerTeam, 10), imageUrl: formImageUrl || undefined }) }); + if (!res.ok) { const err = await res.json().catch(() => null); setFormError(err?.error || 'Failed to add item.'); return; } + setFormName(''); setFormDescription(''); setFormCategory(''); setFormStock(''); setFormMaxPerTeam(''); setFormImageUrl(''); await fetchItems(); - } catch { - setFormError('Failed to add item.'); - } finally { - setFormSubmitting(false); - } + } catch { setFormError('Failed to add item.'); } finally { setFormSubmitting(false); } }; - const handleDeleteItem = async (id: string) => { - if (!confirm('Are you sure you want to delete this item?')) return; - try { - const res = await fetch(`/api/inventory/admin/items/${id}`, { - method: 'DELETE', - }); - if (res.ok) { - await fetchItems(); - } - } catch { - // silently fail - } + if (!confirm('Delete this item?')) return; + try { const res = await fetch(`/api/inventory/admin/items/${id}`, { method: 'DELETE' }); if (res.ok) await fetchItems(); } catch {} }; - - const startEdit = (item: Item) => { - setEditingId(item.id); - setEditName(item.name); - setEditDescription(item.description || ''); - setEditCategory(item.category); - setEditStock(String(item.stock)); - setEditMaxPerTeam(String(item.maxPerTeam)); - setEditImageUrl(item.imageUrl || ''); - }; - - const cancelEdit = () => { - setEditingId(null); - }; - - const handleEditSubmit = async (id: string) => { - setEditSubmitting(true); + const startEditItem = (item: Item) => { setEditingItemId(item.id); setEditItemName(item.name); setEditItemDescription(item.description || ''); setEditItemCategory(item.category); setEditItemStock(String(item.stock)); setEditItemMaxPerTeam(String(item.maxPerTeam)); setEditItemImageUrl(item.imageUrl || ''); }; + const handleEditItemSubmit = async (id: string) => { + setEditItemSubmitting(true); try { - const res = await fetch(`/api/inventory/admin/items/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: editName, - description: editDescription || undefined, - category: editCategory, - stock: parseInt(editStock, 10), - maxPerTeam: parseInt(editMaxPerTeam, 10), - imageUrl: editImageUrl || undefined, - }), - }); - if (res.ok) { - setEditingId(null); - await fetchItems(); - } - } catch { - // silently fail - } finally { - setEditSubmitting(false); - } + const res = await fetch(`/api/inventory/admin/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: editItemName, description: editItemDescription || undefined, category: editItemCategory, stock: parseInt(editItemStock, 10), maxPerTeam: parseInt(editItemMaxPerTeam, 10), imageUrl: editItemImageUrl || undefined }) }); + if (res.ok) { setEditingItemId(null); await fetchItems(); } + } catch {} finally { setEditItemSubmitting(false); } }; - const handleDigiKeySearch = async () => { - if (!digiKeyQuery.trim()) return; - setDigiKeyLoading(true); - setDigiKeyResults([]); + // Tool handlers + const handleAddTool = async (e: React.FormEvent) => { + e.preventDefault(); setToolFormError(null); + if (!toolFormName) { setToolFormError('Name is required.'); return; } + setToolFormSubmitting(true); try { - const res = await fetch( - `/api/inventory/digikey/search?q=${encodeURIComponent(digiKeyQuery)}` - ); - if (res.ok) { - const data = await res.json(); - setDigiKeyResults(data); - } - } catch { - // silently fail - } finally { - setDigiKeyLoading(false); - } + const res = await fetch('/api/inventory/admin/tools', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: toolFormName, description: toolFormDescription || undefined, imageUrl: toolFormImageUrl || undefined }) }); + if (!res.ok) { const err = await res.json().catch(() => null); setToolFormError(err?.error || 'Failed to add tool.'); return; } + setToolFormName(''); setToolFormDescription(''); setToolFormImageUrl(''); + await fetchTools(); + } catch { setToolFormError('Failed to add tool.'); } finally { setToolFormSubmitting(false); } }; - - const selectDigiKeyImage = (url: string) => { - if (digiKeyTarget === 'add') { - setFormImageUrl(url); - } else { - setEditImageUrl(url); - } - setDigiKeyResults([]); - setDigiKeyQuery(''); + const handleDeleteTool = async (id: string) => { + if (!confirm('Delete this tool?')) return; setDeleteError(null); + try { const res = await fetch(`/api/inventory/admin/tools/${id}`, { method: 'DELETE' }); if (res.ok) await fetchTools(); else { const err = await res.json().catch(() => null); setDeleteError(err?.error || 'Failed to delete.'); } } catch { setDeleteError('Failed to delete.'); } + }; + const startEditTool = (tool: Tool) => { setEditingToolId(tool.id); setEditToolName(tool.name); setEditToolDescription(tool.description || ''); setEditToolImageUrl(tool.imageUrl || ''); }; + const handleEditToolSubmit = async (id: string) => { + setEditToolSubmitting(true); + try { + const res = await fetch(`/api/inventory/admin/tools/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: editToolName, description: editToolDescription || undefined, imageUrl: editToolImageUrl || undefined }) }); + if (res.ok) { setEditingToolId(null); await fetchTools(); } + } catch {} finally { setEditToolSubmitting(false); } }; const handleCSVFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setCsvResult(null); - const reader = new FileReader(); - reader.onload = (ev) => { - const text = ev.target?.result as string; - const parsed = parseCSV(text); - setCsvData(parsed); - }; - reader.readAsText(file); + const file = e.target.files?.[0]; if (!file) return; setCsvResult(null); + const reader = new FileReader(); reader.onload = (ev) => { setCsvData(parseCSV(ev.target?.result as string)); }; reader.readAsText(file); }; - const handleCSVImport = async () => { - if (!csvData || csvData.length === 0) return; - setCsvImporting(true); - setCsvResult(null); + if (!csvData || csvData.length === 0) return; setCsvImporting(true); setCsvResult(null); try { - const res = await fetch('/api/inventory/admin/items/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items: csvData }), - }); - if (res.ok) { - const data = await res.json(); - setCsvResult(`Imported ${data.count ?? csvData.length} items successfully.`); - setCsvData(null); - await fetchItems(); - } else { - const err = await res.json().catch(() => null); - setCsvResult(`Import failed: ${err?.error || 'Unknown error'}`); - } - } catch { - setCsvResult('Import failed.'); - } finally { - setCsvImporting(false); - } + const res = await fetch('/api/inventory/admin/items/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: csvData }) }); + if (res.ok) { const data = await res.json(); setCsvResult(`Imported ${data.imported ?? csvData.length} items.`); setCsvData(null); await fetchItems(); } + else { const err = await res.json().catch(() => null); setCsvResult(`Import failed: ${err?.error || 'Unknown error'}`); } + } catch { setCsvResult('Import failed.'); } finally { setCsvImporting(false); } }; return ( -
- {/* Add Item Form */} -
-

- Add Item -

-
-
-
- - setFormName(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - required - /> +
+ {/* ==================== ITEMS ==================== */} +
+

Items

+ + {/* Add Item */} +
+

Add Item

+ +
+
+ + { setFormName(e.target.value); setPendingItemDkName(null); }} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" required /> + {pendingItemDkName && pendingItemDkName !== formName && ( + + )} +
+
+ + setFormCategory(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" placeholder="Select or type new..." required /> + {categories.map((c) => +
+
+ + setFormStock(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" min="0" required /> +
+
+ + setFormMaxPerTeam(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" min="0" required /> +
- - setFormCategory(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - required - /> + + setFormDescription(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" />
- - setFormStock(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - min="0" - required - /> + + setFormImageUrl(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" placeholder="https://..." />
-
- - setFormMaxPerTeam(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - min="0" - required - /> + {formError &&

{formError}

} +
+ + {formName.trim() && ( + + )}
-
-
- - setFormDescription(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - /> -
-
- - setFormImageUrl(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - placeholder="https://..." - /> -
- {formError &&

{formError}

} - - -
- - {/* DigiKey Image Search */} -
-

- DigiKey Image Search -

-
- - -
-
- setDigiKeyQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleDigiKeySearch()} - placeholder="Search for component images..." - className="flex-1 border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - /> - + {digiKeyResults.length > 0 && digiKeyContext === 'item-add' && ( +
+ {digiKeyResults.map((result, i) => ( + + ))} +
+ )} +
- {digiKeyResults.length > 0 && ( -
- {digiKeyResults.map((img, i) => ( - - ))} -
- )} -
- {/* Items Table */} -
-

- Items ({items.length}) -

- {loading ? ( -
-
-
+ {/* Items Table */} + {itemsLoading ? ( +
) : items.length === 0 ? (

No items in inventory.

) : ( @@ -467,89 +276,46 @@ export default function AdminItemsPage() { - - - - - - + + + + + + {items.map((item) => ( - {editingId === item.id ? ( + {editingItemId === item.id ? ( <> + - + + - - @@ -559,31 +325,11 @@ export default function AdminItemsPage() { - + @@ -594,78 +340,148 @@ export default function AdminItemsPage() {
- Name - - Category - - Stock - - Max/Team - - Image - - Actions - NameCategoryStockMax/TeamImageActions
setEditItemName(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" /> - setEditName(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" - /> - - setEditCategory(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" - /> + setEditItemCategory(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" /> + {categories.map((c) => setEditItemStock(e.target.value)} className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" min="0" /> setEditItemMaxPerTeam(e.target.value)} className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" min="0" /> - setEditStock(e.target.value)} - className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" - min="0" - /> - - setEditMaxPerTeam(e.target.value)} - className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" - min="0" - /> - - setEditImageUrl(e.target.value)} - className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" - placeholder="URL" - /> +
+ setEditItemImageUrl(e.target.value)} className="flex-1 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm min-w-0" placeholder="URL" /> + +
+ {digiKeyResults.length > 0 && digiKeyContext === 'item-edit' && editingItemId === item.id && ( +
+ {digiKeyResults.slice(0, 4).map((result, i) => ( + + ))} +
+ )}
- - + +
{item.category} {item.stock} {item.maxPerTeam} - {item.imageUrl ? ( - {item.name} - ) : ( - -- - )} - {item.imageUrl ? {item.name} : --}
- - + +
)} -
- {/* CSV Import */} -
-

- CSV Import -

-

- Expected columns: name, description, category, stock, max_per_team, image_url -

- + {/* CSV Import */} +
+

CSV Import

+

Expected columns: name, description, category, stock, max_per_team, image_url

+ + {csvData && csvData.length > 0 && ( +
+

Preview ({csvData.length} rows):

+
+ + + {csvData.map((row, i) => ())} +
NameDescCategoryStockMaxImage
{row.name}{row.description}{row.category}{row.stock}{row.max_per_team}{row.image_url}
+
+ +
+ )} + {csvResult &&

{csvResult}

} +
+
+ + {/* ==================== TOOLS ==================== */} +
+

Tools

+ + {/* Add Tool */} +
+

Add Tool

+

Each row = one physical tool. Add one entry per unit.

+
+
+
+ + { setToolFormName(e.target.value); setPendingToolDkName(null); }} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" placeholder="e.g., Soldering Iron - Hakko FX888D" required /> + {pendingToolDkName && pendingToolDkName !== toolFormName && ( + + )} +
+
+ + setToolFormImageUrl(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" placeholder="https://..." /> +
+
+
+ + setToolFormDescription(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" /> +
+ {toolFormError &&

{toolFormError}

} +
+ + {toolFormName.trim() && ( + + )} +
+ {digiKeyResults.length > 0 && digiKeyContext === 'tool-add' && ( +
+ {digiKeyResults.map((result, i) => ( + + ))} +
+ )} +
+
- {csvData && csvData.length > 0 && ( -
-

- Preview ({csvData.length} rows): -

-
- - - - - - - - - + {/* Tools Table */} + {deleteError &&

{deleteError}

} + {toolsLoading ? ( +
+ ) : tools.length === 0 ? ( +

No tools registered.

+ ) : ( +
+
NameDescriptionCategoryStockMax/TeamImage URL
+ + + + + + + + + + + {tools.map((tool) => ( + + {editingToolId === tool.id ? ( + <> + + + + + + + ) : ( + <> + + + + + + + )} - - - {csvData.map((row, i) => ( - - - - - - - - - ))} - -
NameDescriptionStatusImageActions
setEditToolName(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" /> setEditToolDescription(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" />{tool.available ? 'Available' : 'Rented'} +
+ setEditToolImageUrl(e.target.value)} className="flex-1 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm min-w-0" placeholder="URL" /> + +
+ {digiKeyResults.length > 0 && digiKeyContext === 'tool-edit' && editingToolId === tool.id && ( +
+ {digiKeyResults.slice(0, 4).map((result, i) => ( + + ))} +
+ )} +
+
+ + +
+
{tool.name}{tool.description || '--'}{tool.available ? 'Available' : 'Rented'}{tool.imageUrl ? {tool.name} : --} +
+ + +
+
{row.name} - {row.description} - {row.category}{row.stock}{row.max_per_team} - {row.image_url} -
-
- + ))} + +
)} - - {csvResult && ( -

- {csvResult} -

- )} -
+
); } diff --git a/app/inventory/admin/layout.tsx b/app/inventory/admin/layout.tsx index 8b30c811..23239782 100644 --- a/app/inventory/admin/layout.tsx +++ b/app/inventory/admin/layout.tsx @@ -1,48 +1,21 @@ 'use client'; -import { useSession } from "@/lib/auth-client"; import { usePathname } from 'next/navigation'; -import { useEffect, useState } from 'react'; import Link from 'next/link'; - -interface Role { - role: string; -} +import { useInventoryAccess } from '../InventoryAccessContext'; export default function AdminInventoryLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const { data: session, isPending } = useSession(); const pathname = usePathname(); - const [isAdmin, setIsAdmin] = useState(false); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!session) { - setLoading(false); - return; - } - fetch('/api/user/roles') - .then(res => res.json()) - .then((data: { roles: string[] }) => { - const admin = data.roles.some( - (r) => r === 'ADMIN' || r === 'REVIEWER' - ); - setIsAdmin(admin); - setLoading(false); - }) - .catch(() => { - setIsAdmin(false); - setLoading(false); - }); - }, [session]); + const access = useInventoryAccess(); + const isAdmin = access?.isAdmin ?? false; const tabs = [ - { label: 'Orders', href: '/inventory/admin' }, - { label: 'Rentals', href: '/inventory/admin/rentals' }, - { label: 'Items', href: '/inventory/admin/items' }, + { label: 'Activity', href: '/inventory/admin' }, + { label: 'Inventory', href: '/inventory/admin/items' }, { label: 'Teams', href: '/inventory/admin/teams' }, { label: 'Settings', href: '/inventory/admin/settings' }, ]; @@ -53,34 +26,13 @@ export default function AdminInventoryLayout({ ? pathname === '/inventory/admin' : pathname.startsWith(href); - return `px-4 py-2 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ + return `relative z-10 px-4 py-2 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ isActive ? 'text-orange-500 border-orange-500' : 'text-brown-800 border-transparent hover:text-orange-500' }`; }; - if (isPending || loading) { - return ( -
-
-
- ); - } - - if (!session) { - return ( -
-
-

- Not Authenticated -

-

Please sign in to access admin.

-
-
- ); - } - if (!isAdmin) { return (
diff --git a/app/inventory/admin/page.tsx b/app/inventory/admin/page.tsx index c3338944..3329970a 100644 --- a/app/inventory/admin/page.tsx +++ b/app/inventory/admin/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import { useInventorySSE } from '@/lib/hooks/useInventorySSE'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useInventorySSE } from '@/lib/inventory/useInventorySSE'; interface OrderItem { id: string; @@ -15,20 +15,32 @@ interface Order { placedBy: { id: string; name: string; email?: string }; floor: number; location: string; - status: 'PLACED' | 'IN_PROGRESS' | 'READY' | 'COMPLETED'; + status: 'PLACED' | 'IN_PROGRESS' | 'READY' | 'COMPLETED' | 'CANCELLED'; items: OrderItem[]; createdAt: string; } +interface Rental { + id: string; + tool: { id: string; name: string }; + team: { id: string; name: string }; + rentedBy: { id: string; name: string; email?: string }; + floor: number; + location: string; + createdAt: string; + dueAt?: string; +} + interface LookupResult { user: { + id: string; name: string; email?: string; - slackId: string; - }; - team?: { - name: string; + slackId?: string; + nfcId?: string; + image?: string; }; + team?: { name: string }; activeOrder?: Order; activeRentals?: { id: string; toolName: string; checkedOutAt: string }[]; } @@ -38,50 +50,70 @@ const STATUS_COLORS: Record = { IN_PROGRESS: 'bg-orange-400 text-cream-50 border-orange-500', READY: 'bg-orange-500 text-cream-50 border-orange-600', COMPLETED: 'bg-brown-800 text-cream-50 border-brown-900', + CANCELLED: 'bg-cream-200 text-brown-800/50 border-brown-800/30', }; -const STATUS_TABS = ['All', 'PLACED', 'IN_PROGRESS', 'READY', 'COMPLETED'] as const; +const STATUS_TABS = ['All', 'PLACED', 'IN_PROGRESS', 'READY', 'COMPLETED', 'CANCELLED'] as const; -export default function AdminOrdersPage() { +export default function AdminActivityPage() { + // Orders const [orders, setOrders] = useState([]); - const [loading, setLoading] = useState(true); + const [ordersLoading, setOrdersLoading] = useState(true); const [statusFilter, setStatusFilter] = useState('All'); const [updating, setUpdating] = useState(null); - // NFC / Lookup + // Rentals + const [rentals, setRentals] = useState([]); + const [rentalsLoading, setRentalsLoading] = useState(true); + const [returning, setReturning] = useState(null); + + // Lookup const [lookupInput, setLookupInput] = useState(''); const [lookupResult, setLookupResult] = useState(null); const [lookupLoading, setLookupLoading] = useState(false); const [lookupError, setLookupError] = useState(null); + // Badge + const [assigningBadge, setAssigningBadge] = useState(false); + const [badgeInput, setBadgeInput] = useState(''); + const [badgeAssigning, setBadgeAssigning] = useState(false); + const [badgeError, setBadgeError] = useState(null); + const [badgeSuccess, setBadgeSuccess] = useState(null); + const badgeInputRef = useRef(null); + + const hidBuffer = useRef(''); + const hidTimer = useRef | null>(null); + const lookupInputRef = useRef(null); + const sseEvent = useInventorySSE('admin'); const fetchOrders = useCallback(async () => { try { const params = statusFilter !== 'All' ? `?status=${statusFilter}` : ''; const res = await fetch(`/api/inventory/admin/orders${params}`); - if (res.ok) { - const data = await res.json(); - setOrders(data); - } - } catch { - // silently fail - } finally { - setLoading(false); + if (res.ok) setOrders(await res.json()); + } catch {} finally { + setOrdersLoading(false); } }, [statusFilter]); - useEffect(() => { - setLoading(true); - fetchOrders(); - }, [fetchOrders]); + const fetchRentals = useCallback(async () => { + try { + const res = await fetch('/api/inventory/admin/rentals'); + if (res.ok) setRentals(await res.json()); + } catch {} finally { + setRentalsLoading(false); + } + }, []); - // Refetch on SSE event + useEffect(() => { setOrdersLoading(true); fetchOrders(); }, [fetchOrders]); + useEffect(() => { fetchRentals(); }, [fetchRentals]); useEffect(() => { - if (sseEvent) { - fetchOrders(); - } - }, [sseEvent, fetchOrders]); + if (!sseEvent) return; + const type = sseEvent.type; + if (type === 'order_placed' || type === 'order_status_updated') fetchOrders(); + if (type === 'rental_created' || type === 'rental_returned') fetchRentals(); + }, [sseEvent, fetchOrders, fetchRentals]); const updateStatus = async (orderId: string, newStatus: string) => { setUpdating(orderId); @@ -91,264 +123,293 @@ export default function AdminOrdersPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }), }); - if (res.ok) { - await fetchOrders(); + if (res.ok) await fetchOrders(); + } catch {} finally { setUpdating(null); } + }; + + const markReturned = async (rentalId: string) => { + setReturning(rentalId); + try { + const res = await fetch(`/api/inventory/admin/rentals/${rentalId}/return`, { method: 'PATCH' }); + if (res.ok) await fetchRentals(); + } catch {} finally { setReturning(null); } + }; + + const isOverdue = (dueAt?: string) => dueAt ? new Date(dueAt) < new Date() : false; + + // HID NFC + const handleHidInput = useCallback((e: KeyboardEvent) => { + const active = document.activeElement; + const isLookupFocused = active === lookupInputRef.current; + const isBadgeFocused = active === badgeInputRef.current; + const noInputFocused = active === document.body || active === null; + if (!isLookupFocused && !isBadgeFocused && !noInputFocused) return; + if (e.key.length !== 1 && e.key !== 'Enter') return; + + if (e.key === 'Enter') { + if (hidBuffer.current.length >= 6) { + e.preventDefault(); + const value = hidBuffer.current; + hidBuffer.current = ''; + if (hidTimer.current) clearTimeout(hidTimer.current); + if (isBadgeFocused || assigningBadge) { + setBadgeInput(value); + } else { + setLookupInput(value); + handleLookup(value); + } } - } catch { - // silently fail - } finally { - setUpdating(null); + hidBuffer.current = ''; + return; } - }; + + if (hidTimer.current) clearTimeout(hidTimer.current); + hidBuffer.current += e.key; + hidTimer.current = setTimeout(() => { hidBuffer.current = ''; }, 80); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assigningBadge]); + + useEffect(() => { + document.addEventListener('keydown', handleHidInput); + return () => document.removeEventListener('keydown', handleHidInput); + }, [handleHidInput]); const handleLookup = async (slackId?: string) => { const id = slackId || lookupInput.trim(); if (!id) return; - setLookupLoading(true); - setLookupError(null); - setLookupResult(null); + setLookupLoading(true); setLookupError(null); setLookupResult(null); try { const res = await fetch(`/api/inventory/lookup/${encodeURIComponent(id)}`); - if (!res.ok) { - setLookupError('User not found.'); - return; - } - const data = await res.json(); - setLookupResult(data); - } catch { - setLookupError('Lookup failed.'); - } finally { - setLookupLoading(false); - } + if (!res.ok) { setLookupError('User not found.'); return; } + setLookupResult(await res.json()); + } catch { setLookupError('Lookup failed.'); } finally { setLookupLoading(false); } }; const handleNFCScan = async () => { try { - if (!('NDEFReader' in window)) { - setLookupError('NFC not supported on this device.'); - return; - } + if (!('NDEFReader' in window)) { setLookupError('NFC not supported on this device.'); return; } // @ts-expect-error NDEFReader is not in all TS libs const ndef = new NDEFReader(); await ndef.scan(); - ndef.addEventListener('reading', ({ serialNumber }: { serialNumber: string }) => { - handleLookup(serialNumber); + ndef.addEventListener('reading', ({ message }: { message: { records: Array<{ recordType: string; data: ArrayBuffer }> } }) => { + for (const record of message.records) { + if (record.recordType === 'text') { + handleLookup(new TextDecoder().decode(record.data)); + return; + } + } + setLookupError('No Slack ID found on badge.'); }); - } catch { - setLookupError('NFC scan failed or was cancelled.'); - } + } catch { setLookupError('NFC scan failed or was cancelled.'); } }; - const getNextStatus = (status: string): string | null => { - switch (status) { - case 'PLACED': return 'IN_PROGRESS'; - case 'IN_PROGRESS': return 'READY'; - case 'READY': return 'COMPLETED'; - default: return null; - } + const handleAssignBadge = async () => { + if (!lookupResult || !badgeInput.trim()) return; + setBadgeAssigning(true); setBadgeError(null); setBadgeSuccess(null); + try { + const res = await fetch('/api/inventory/admin/assign-badge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: lookupResult.user.id, nfcId: badgeInput.trim() }), + }); + if (!res.ok) { const err = await res.json().catch(() => null); setBadgeError(err?.error || 'Failed to assign badge.'); return; } + setBadgeSuccess(`Badge ${badgeInput.trim()} assigned.`); + setBadgeInput(''); setAssigningBadge(false); + } catch { setBadgeError('Failed to assign badge.'); } finally { setBadgeAssigning(false); } }; + const getNextStatus = (status: string): string | null => { + switch (status) { case 'PLACED': return 'IN_PROGRESS'; case 'IN_PROGRESS': return 'READY'; case 'READY': return 'COMPLETED'; default: return null; } + }; const getActionLabel = (status: string): string | null => { - switch (status) { - case 'PLACED': return 'Start'; - case 'IN_PROGRESS': return 'Mark Ready'; - case 'READY': return 'Mark Completed'; - default: return null; - } + switch (status) { case 'PLACED': return 'Start'; case 'IN_PROGRESS': return 'Mark Ready'; case 'READY': return 'Mark Completed'; default: return null; } }; return ( -
- {/* NFC Lookup Section */} -
-

- Badge Lookup -

+
+ {/* Badge Lookup */} +
+

Badge Lookup

- - setLookupInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleLookup()} - placeholder="Enter Slack user ID..." - className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" - /> + + setLookupInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleLookup()} placeholder="Tap badge or enter Slack ID..." className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" />
- - + +
- {lookupLoading && ( -

Looking up...

- )} - {lookupError && ( -

{lookupError}

- )} + {lookupLoading &&

Looking up...

} + {lookupError &&

{lookupError}

} {lookupResult && (

{lookupResult.user.name}

- {lookupResult.user.email && ( -

{lookupResult.user.email}

- )} -

- Slack: {lookupResult.user.slackId} -

- {lookupResult.team && ( -

- Team: {lookupResult.team.name} -

- )} + {lookupResult.user.email &&

{lookupResult.user.email}

} + {lookupResult.user.slackId &&

Slack: {lookupResult.user.slackId}

} + {lookupResult.user.nfcId &&

Badge: {lookupResult.user.nfcId}

} + {lookupResult.team &&

Team: {lookupResult.team.name}

}
- +
{lookupResult.activeOrder && (
-

- Active Order -

- - {lookupResult.activeOrder.status.replace('_', ' ')} - +

Active Order

+ {lookupResult.activeOrder.status.replace('_', ' ')}
    - {lookupResult.activeOrder.items.map((item) => ( -
  • - {item.item.name} x{item.quantity} -
  • - ))} + {lookupResult.activeOrder.items.map((item) =>
  • {item.item.name} x{item.quantity}
  • )}
)} {lookupResult.activeRentals && lookupResult.activeRentals.length > 0 && (
-

- Active Rentals -

+

Active Rentals

    - {lookupResult.activeRentals.map((r) => ( -
  • {r.toolName}
  • - ))} + {lookupResult.activeRentals.map((r) =>
  • {r.toolName}
  • )}
)} + +
+ {!assigningBadge ? ( + + ) : ( +
+

Tap badge on reader or enter ID:

+
+ setBadgeInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAssignBadge()} placeholder="Tap badge..." className="flex-1 border-2 border-brown-800 bg-cream-50 px-3 py-1 text-sm text-brown-800" /> + + +
+
+ )} + {badgeError &&

{badgeError}

} + {badgeSuccess &&

{badgeSuccess}

} +
)}
- {/* Status Filter Tabs */} -
- {STATUS_TABS.map((tab) => ( - - ))} -
+ {/* === Orders Section === */} +
+

Orders

- {/* Orders List */} - {loading ? ( -
-
+
+ {STATUS_TABS.map((tab) => ( + + ))}
- ) : orders.length === 0 ? ( -
-

No orders found

-
- ) : ( -
- {orders.map((order) => { - const nextStatus = getNextStatus(order.status); - const actionLabel = getActionLabel(order.status); - - return ( -
-
-
-

- {order.team.name} -

-

- Placed by {order.placedBy.name} - {order.floor && ` -- Floor ${order.floor}`} - {order.location && ` -- ${order.location}`} -

-

- {new Date(order.createdAt).toLocaleString()} -

-
- - {order.status.replace('_', ' ')} - -
- {/* Items */} -
-
    - {order.items.map((item) => ( -
  • - {item.item.name} x{item.quantity} -
  • - ))} + {ordersLoading ? ( +
    + ) : orders.length === 0 ? ( +

    No orders found.

    + ) : ( +
    + {orders.map((order) => { + const nextStatus = getNextStatus(order.status); + const actionLabel = getActionLabel(order.status); + return ( +
    +
    +
    +

    + {order.team.name} + #{order.id.slice(-6).toUpperCase()} +

    +

    + Placed by {order.placedBy.name} + {order.floor && ` -- Floor ${order.floor}`} + {order.location && ` -- ${order.location}`} +

    +

    {new Date(order.createdAt).toLocaleString()}

    +
    + {order.status.replace('_', ' ')} +
    +
      + {order.items.map((item) =>
    • {item.item.name} x{item.quantity}
    • )}
    +
    + {nextStatus && actionLabel && ( + + )} + {(order.status === 'PLACED' || order.status === 'IN_PROGRESS') && ( + + )} +
    + ); + })} +
    + )} +
- {/* Actions */} - {nextStatus && actionLabel && ( - - )} -
- ); - })} -
- )} + {/* === Rentals Section === */} +
+

Active Rentals

+ + {rentalsLoading ? ( +
+ ) : rentals.length === 0 ? ( +

No active rentals.

+ ) : ( +
+ + + + + + + + + + + + + + {rentals.map((rental) => { + const overdue = isOverdue(rental.dueAt); + return ( + + + + + + + + + + ); + })} + +
ToolTeamRented ByLocationChecked OutDue AtActions
+ {rental.tool.name} + {overdue && Overdue} + {rental.team.name}{rental.rentedBy.name} + {rental.floor && `Floor ${rental.floor}`} + {rental.floor && rental.location && ' - '} + {rental.location} + {!rental.floor && !rental.location && '--'} + {new Date(rental.createdAt).toLocaleString()}{rental.dueAt ? new Date(rental.dueAt).toLocaleString() : '--'} + +
+
+ )} +
); } diff --git a/app/inventory/admin/rentals/page.tsx b/app/inventory/admin/rentals/page.tsx deleted file mode 100644 index 8151c3be..00000000 --- a/app/inventory/admin/rentals/page.tsx +++ /dev/null @@ -1,166 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { useInventorySSE } from '@/lib/hooks/useInventorySSE'; - -interface Rental { - id: string; - tool: { id: string; name: string }; - team: { id: string; name: string }; - rentedBy: { id: string; name: string; email?: string }; - floor: number; - location: string; - createdAt: string; - dueAt?: string; -} - -export default function AdminRentalsPage() { - const [rentals, setRentals] = useState([]); - const [loading, setLoading] = useState(true); - const [returning, setReturning] = useState(null); - - const sseEvent = useInventorySSE('admin'); - - const fetchRentals = useCallback(async () => { - try { - const res = await fetch('/api/inventory/admin/rentals'); - if (res.ok) { - const data = await res.json(); - setRentals(data); - } - } catch { - // silently fail - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchRentals(); - }, [fetchRentals]); - - // Refetch on SSE event - useEffect(() => { - if (sseEvent) { - fetchRentals(); - } - }, [sseEvent, fetchRentals]); - - const markReturned = async (rentalId: string) => { - setReturning(rentalId); - try { - const res = await fetch(`/api/inventory/admin/rentals/${rentalId}/return`, { - method: 'PATCH', - }); - if (res.ok) { - await fetchRentals(); - } - } catch { - // silently fail - } finally { - setReturning(null); - } - }; - - const isOverdue = (dueAt?: string) => { - if (!dueAt) return false; - return new Date(dueAt) < new Date(); - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-

- Active Rentals ({rentals.length}) -

- - {rentals.length === 0 ? ( -

No active rentals.

- ) : ( -
- - - - - - - - - - - - - - {rentals.map((rental) => { - const overdue = isOverdue(rental.dueAt); - return ( - - - - - - - - - - ); - })} - -
- Tool - - Team - - Rented By - - Location - - Checked Out - - Due At - - Actions -
- {rental.tool.name} - {overdue && ( - - Overdue - - )} - {rental.team.name}{rental.rentedBy.name} - {rental.floor && `Floor ${rental.floor}`} - {rental.floor && rental.location && ' - '} - {rental.location} - {!rental.floor && !rental.location && '--'} - - {new Date(rental.createdAt).toLocaleString()} - - {rental.dueAt - ? new Date(rental.dueAt).toLocaleString() - : '--'} - - -
-
- )} -
- ); -} diff --git a/app/inventory/admin/teams/page.tsx b/app/inventory/admin/teams/page.tsx index 801b2bc0..85bf240e 100644 --- a/app/inventory/admin/teams/page.tsx +++ b/app/inventory/admin/teams/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; interface TeamMember { id: string; @@ -97,8 +97,8 @@ export default function AdminTeamsPage() { {teams.map((team) => ( - <> - + + +
+ )} + {/* Active Order */}

Active Order

{activeOrder ? (
- +
+ #{activeOrder.id.slice(-6).toUpperCase()} Floor {activeOrder.floor} {activeOrder.location} Placed by {activeOrder.placedBy.name} @@ -185,6 +170,15 @@ export default function DashboardPage() { ))} + {(activeOrder.status === 'PLACED' || activeOrder.status === 'IN_PROGRESS') && ( + + )}
) : (

No active order.

@@ -218,27 +212,12 @@ export default function DashboardPage() { )}
- {/* Team Info */} - {team && ( -
-

Team

-
-

{team.name}

-
- {team.members.map(member => ( -
- {member.image ? ( - - ) : ( -
- )} - {member.name} -
- ))} -
-
-
- )} + {/* Team */} + {/* Order History */}
@@ -248,21 +227,27 @@ export default function DashboardPage() { + - + {pastOrders.map(order => ( + - + ))} diff --git a/app/inventory/layout.tsx b/app/inventory/layout.tsx index fda2a5f7..ebfa623d 100644 --- a/app/inventory/layout.tsx +++ b/app/inventory/layout.tsx @@ -1,17 +1,14 @@ 'use client'; -import { useSession } from "@/lib/auth-client"; +import { useSession, signOut } from "@/lib/auth-client"; import { usePathname } from 'next/navigation'; +import { notFound } from 'next/navigation'; import { useEffect, useState } from 'react'; import Link from 'next/link'; - -interface AccessInfo { - allowed: boolean; - reason?: string; - isAdmin: boolean; - teamId?: string; - teamName?: string; -} +import { NoiseOverlay } from '../components/NoiseOverlay'; +import { UserMenu } from '../components/UserMenu'; +import { useRoles, Role } from "@/lib/hooks/useRoles"; +import { InventoryAccessProvider, type AccessInfo } from './InventoryAccessContext'; export default function InventoryLayout({ children, @@ -19,6 +16,7 @@ export default function InventoryLayout({ children: React.ReactNode; }>) { const { data: session, isPending } = useSession(); + const { hasRole } = useRoles(); const pathname = usePathname(); const [access, setAccess] = useState(null); const [accessLoading, setAccessLoading] = useState(true); @@ -27,7 +25,7 @@ export default function InventoryLayout({ if (!session) return; fetch('/api/inventory/access') .then(res => res.json()) - .then(data => { + .then((data: AccessInfo) => { setAccess(data); setAccessLoading(false); }) @@ -42,117 +40,94 @@ export default function InventoryLayout({ ? pathname === '/inventory' : pathname.startsWith(tabPath); - return `px-6 py-3 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ + return `px-4 md:px-6 py-3 text-sm uppercase tracking-wider transition-colors border-b-2 -mb-[2px] ${ isActive - ? 'text-orange-500 border-orange-500' - : 'text-brown-800 border-transparent hover:text-orange-500' + ? 'text-orange-500 border-orange-500 font-bold' + : 'text-brown-800 border-transparent hover:text-brown-800' }`; }; if (isPending) { return ( -
+
); } if (!session) { - return ( -
-
-

Sign In Required

-

You must be logged in to access inventory.

- - Sign In - -
-
- ); + notFound(); } if (accessLoading) { return ( -
-
-
+ <> +
+
+
+ + ); } if (!access?.allowed && !access?.isAdmin) { - return ( -
-
-

Access Denied

-

{access?.reason || 'You do not have access to inventory.'}

- - Back to Dashboard - -
-
- ); + notFound(); } return ( -
- {/* Header */} -
-
- - - - + <> +
+ {/* Header */} +
+ + Stasis -

Inventory

- {access?.teamName && ( - / {access.teamName} - )} +
+ signOut({ fetchOptions: { onSuccess: () => { window.location.href = '/' } } })} + /> +
-
- {/* Tabs */} -
-
-
- - Dashboard - - - Browse Parts - - - Tools - - - Team - - {access?.isAdmin && ( - - Admin + {/* Tabs */} +
+
+
+ + Home - )} + + Browse + + {access?.isAdmin && ( + + Admin + + )} + + ← Dashboard + +
-
- {/* Content */} -
- {children} + {/* Content */} +
+ + {children} + +
-
+ + + ); } diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx index ca464ed0..a52a1710 100644 --- a/app/inventory/page.tsx +++ b/app/inventory/page.tsx @@ -3,8 +3,11 @@ import { useEffect, useState, useCallback } from 'react'; import { useSession } from '@/lib/auth-client'; import { ItemCard } from '@/app/components/inventory/ItemCard'; +import { ToolCard } from '@/app/components/inventory/ToolCard'; import { CartPanel } from '@/app/components/inventory/CartPanel'; import { CheckoutModal } from '@/app/components/inventory/CheckoutModal'; +import { RentalTimer } from '@/app/components/inventory/RentalTimer'; +import { useInventoryAccess } from './InventoryAccessContext'; interface Item { id: string; @@ -17,36 +20,87 @@ interface Item { teamUsed: number; } +interface Tool { + id: string; + name: string; + description?: string; + imageUrl?: string; + available: boolean; +} + interface CartItem { itemId: string; name: string; quantity: number; } +interface CartTool { + toolId: string; + name: string; +} + interface Order { id: string; status: string; } -export default function BrowsePartsPage() { +interface Rental { + id: string; + status: 'CHECKED_OUT' | 'RETURNED'; + floor: number; + location: string; + dueAt: string | null; + tool: { id: string; name: string }; + rentedBy: { id: string; name: string; email: string }; +} + +const DEFAULT_MAX_CONCURRENT_RENTALS = 2; +const DEFAULT_VENUE_FLOORS = 3; + +export default function BrowsePage() { const { data: session } = useSession(); + const access = useInventoryAccess(); + + // Items const [items, setItems] = useState([]); - const [cart, setCart] = useState([]); + const [cart, setCart] = useState(() => { + if (typeof window === 'undefined') return []; + try { return JSON.parse(localStorage.getItem('inventory-cart-items') || '[]'); } catch { return []; } + }); const [activeCategory, setActiveCategory] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hasActiveOrder, setHasActiveOrder] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + // Tools + const [tools, setTools] = useState([]); + const [rentals, setRentals] = useState([]); + const [toolsLoading, setToolsLoading] = useState(true); + const [maxRentals, setMaxRentals] = useState(DEFAULT_MAX_CONCURRENT_RENTALS); + + // Unified cart for tools + const [cartTools, setCartTools] = useState(() => { + if (typeof window === 'undefined') return []; + try { return JSON.parse(localStorage.getItem('inventory-cart-tools') || '[]'); } catch { return []; } + }); + + // Persist cart to localStorage + useEffect(() => { localStorage.setItem('inventory-cart-items', JSON.stringify(cart)); }, [cart]); + useEffect(() => { localStorage.setItem('inventory-cart-tools', JSON.stringify(cartTools)); }, [cartTools]); + + // Unified checkout const [checkoutOpen, setCheckoutOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); const [cartOpen, setCartOpen] = useState(false); + const activeRentals = rentals.filter(r => r.status === 'CHECKED_OUT'); + const fetchItems = useCallback(async () => { try { const res = await fetch('/api/inventory/items'); if (!res.ok) throw new Error('Failed to load items'); - const data = await res.json(); - setItems(data); + setItems(await res.json()); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load items'); } finally { @@ -59,17 +113,44 @@ export default function BrowsePartsPage() { const res = await fetch('/api/inventory/orders'); if (!res.ok) return; const orders: Order[] = await res.json(); - setHasActiveOrder(orders.some(o => o.status !== 'COMPLETED')); - } catch { - // Ignore - user might not be on a team yet + setHasActiveOrder(orders.some(o => o.status !== 'COMPLETED' && o.status !== 'CANCELLED')); + } catch {} + }, []); + + const fetchTools = useCallback(async () => { + try { + const res = await fetch('/api/inventory/tools'); + if (!res.ok) throw new Error('Failed to load tools'); + setTools(await res.json()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load tools'); } }, []); + const fetchRentals = useCallback(async () => { + try { + const res = await fetch('/api/inventory/rentals'); + if (!res.ok) return; + setRentals(await res.json()); + } catch {} + }, []); + + const venueFloors = access?.venueFloors ?? DEFAULT_VENUE_FLOORS; + const allowMultipleOrders = access?.allowMultipleOrders ?? false; + + useEffect(() => { + if (access?.maxConcurrentRentals) setMaxRentals(access.maxConcurrentRentals); + }, [access?.maxConcurrentRentals]); + useEffect(() => { if (!session) return; - fetchItems(); - checkActiveOrder(); - }, [session, fetchItems, checkActiveOrder]); + Promise.all([ + fetchItems(), + checkActiveOrder(), + fetchTools(), + fetchRentals(), + ]).finally(() => setToolsLoading(false)); + }, [session, fetchItems, checkActiveOrder, fetchTools, fetchRentals]); const categories = Array.from(new Set(items.map(i => i.category))).sort(); const filteredItems = activeCategory @@ -79,13 +160,10 @@ export default function BrowsePartsPage() { const addToCart = (itemId: string, quantity: number) => { const item = items.find(i => i.id === itemId); if (!item) return; - setCart(prev => { const existing = prev.find(c => c.itemId === itemId); if (existing) { - return prev.map(c => - c.itemId === itemId ? { ...c, quantity: c.quantity + quantity } : c - ); + return prev.map(c => c.itemId === itemId ? { ...c, quantity: c.quantity + quantity } : c); } return [...prev, { itemId, name: item.name, quantity }]; }); @@ -103,40 +181,81 @@ export default function BrowsePartsPage() { setCart(prev => prev.filter(c => c.itemId !== itemId)); }; + const addToolToCart = (toolId: string) => { + const tool = tools.find(t => t.id === toolId); + if (!tool) return; + if (cartTools.some(t => t.toolId === toolId)) return; // already in cart + setCartTools(prev => [...prev, { toolId, name: tool.name }]); + }; + + const removeToolFromCart = (toolId: string) => { + setCartTools(prev => prev.filter(t => t.toolId !== toolId)); + }; + const handleCheckout = async (floor: number, location: string) => { setIsSubmitting(true); setError(null); try { - const res = await fetch('/api/inventory/orders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - items: cart.map(c => ({ itemId: c.itemId, quantity: c.quantity })), - floor, - location, - }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to place order'); + const results: string[] = []; + + // Place parts order + if (cart.length > 0) { + const res = await fetch('/api/inventory/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + items: cart.map(c => ({ itemId: c.itemId, quantity: c.quantity })), + floor, + location, + }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to place order'); + } + results.push('Parts order placed'); + setHasActiveOrder(true); + } + + // Create tool rentals + if (cartTools.length > 0) { + const rentalResults = await Promise.all( + cartTools.map(async (tool) => { + const res = await fetch('/api/inventory/rentals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolId: tool.toolId, floor, location }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || `Failed to rent ${tool.name}`); + } + return `${tool.name} rented`; + }) + ); + results.push(...rentalResults); } setCart([]); + setCartTools([]); setCheckoutOpen(false); - setSuccessMessage('Order placed successfully! Check your dashboard for status updates.'); - setHasActiveOrder(true); + setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); fetchItems(); + fetchTools(); + fetchRentals(); setTimeout(() => setSuccessMessage(null), 5000); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to place order'); + setError(err instanceof Error ? err.message : 'Checkout failed'); } finally { setIsSubmitting(false); } }; - if (loading) { + const canRentMore = activeRentals.length + cartTools.length < maxRentals; + const cartTotal = cart.reduce((s, c) => s + c.quantity, 0) + cartTools.length; + + if (loading && toolsLoading) { return (
@@ -146,94 +265,132 @@ export default function BrowsePartsPage() { return (
- {/* Success message */} {successMessage && (
{successMessage}
)} - - {/* Error message */} - {error && ( + {error && !checkoutOpen && (
{error} - +
)} - {/* Active order banner */} - {hasActiveOrder && ( -
- Your team has an active order. You cannot place another order until it is completed. -
- )} +
+
+
+

Parts

- {/* Category filters */} - {categories.length > 1 && ( -
- - {categories.map(cat => ( - - ))} -
- )} + {categories.length > 1 && ( +
+ + {categories.map(cat => ( + + ))} +
+ )} -
- {/* Items grid */} -
- {filteredItems.length === 0 ? ( -

No items found.

- ) : ( -
- {filteredItems.map(item => ( - - ))} -
- )} + {filteredItems.length === 0 ? ( +

No items found.

+ ) : ( +
+ {filteredItems.map(item => ( + + ))} +
+ )} +
+ +
+

Tools

+ + {activeRentals.length > 0 && ( +
+

+ Active Rentals ({activeRentals.length} / {maxRentals}) +

+
+ {activeRentals.map(rental => ( +
+
+ {rental.tool.name} + + Floor {rental.floor} - {rental.location} + +
+ +
+ ))} +
+
+ )} + + {tools.length === 0 ? ( +

No tools available.

+ ) : ( +
+ {tools.map(tool => { + const inCart = cartTools.some(t => t.toolId === tool.id); + return ( + + ); + })} +
+ )} +
- {/* Cart - desktop sidebar */}
setCheckoutOpen(true)} - disabled={hasActiveOrder} + onRemoveItem={removeFromCart} + onRemoveTool={removeToolFromCart} + onCheckout={() => { setError(null); setCheckoutOpen(true); }} + disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0 && cartTools.length === 0} + hasActiveOrder={!allowMultipleOrders && hasActiveOrder} />
- {/* Cart - mobile toggle */}
{!cartOpen && ( )}
@@ -244,26 +401,31 @@ export default function BrowsePartsPage() {
{ setCartOpen(false); - setCheckoutOpen(true); + setError(null); setCheckoutOpen(true); }} - disabled={hasActiveOrder} + disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0 && cartTools.length === 0} + hasActiveOrder={!allowMultipleOrders && hasActiveOrder} />
)}
- {/* Checkout modal */} setCheckoutOpen(false)} + onClose={() => { setCheckoutOpen(false); setError(null); }} items={cart} + tools={cartTools} onConfirm={handleCheckout} isSubmitting={isSubmitting} + venueFloors={venueFloors} + error={error} />
); diff --git a/app/inventory/team/page.tsx b/app/inventory/team/page.tsx deleted file mode 100644 index 96716cc6..00000000 --- a/app/inventory/team/page.tsx +++ /dev/null @@ -1,502 +0,0 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { useSession } from '@/lib/auth-client'; - -interface AccessInfo { - allowed: boolean; - reason?: string; - isAdmin: boolean; - teamId?: string; - teamName?: string; -} - -interface TeamMember { - id: string; - name: string; - slackDisplayName?: string; - image?: string; -} - -interface TeamDetail { - id: string; - name: string; - locked: boolean; - members: TeamMember[]; -} - -interface TeamListItem { - id: string; - name: string; - locked: boolean; - _count: { members: number }; -} - -export default function TeamPage() { - const { data: session } = useSession(); - const [access, setAccess] = useState(null); - const [team, setTeam] = useState(null); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - - // Create team form - const [newTeamName, setNewTeamName] = useState(''); - const [isCreating, setIsCreating] = useState(false); - - // Edit team name - const [editingName, setEditingName] = useState(false); - const [editName, setEditName] = useState(''); - const [isSavingName, setIsSavingName] = useState(false); - - // Add member - const [addSlackId, setAddSlackId] = useState(''); - const [isAddingMember, setIsAddingMember] = useState(false); - - // Leave/remove - const [isLeaving, setIsLeaving] = useState(false); - const [removingUserId, setRemovingUserId] = useState(null); - const [confirmLeave, setConfirmLeave] = useState(false); - - const showSuccess = (msg: string) => { - setSuccessMessage(msg); - setTimeout(() => setSuccessMessage(null), 5000); - }; - - const fetchAccess = useCallback(async () => { - try { - const res = await fetch('/api/inventory/access'); - if (!res.ok) throw new Error('Failed to check access'); - const data = await res.json(); - setAccess(data); - return data as AccessInfo; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to check access'); - return null; - } - }, []); - - const fetchTeamDetail = useCallback(async (teamId: string) => { - try { - const res = await fetch(`/api/inventory/teams/${teamId}`); - if (!res.ok) throw new Error('Failed to load team'); - const data = await res.json(); - setTeam(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load team'); - } - }, []); - - const fetchTeams = useCallback(async () => { - try { - const res = await fetch('/api/inventory/teams'); - if (!res.ok) throw new Error('Failed to load teams'); - const data = await res.json(); - setTeams(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load teams'); - } - }, []); - - const loadData = useCallback(async () => { - setLoading(true); - const accessData = await fetchAccess(); - if (accessData?.teamId) { - await fetchTeamDetail(accessData.teamId); - } else { - setTeam(null); - await fetchTeams(); - } - setLoading(false); - }, [fetchAccess, fetchTeamDetail, fetchTeams]); - - useEffect(() => { - if (!session) return; - loadData(); - }, [session, loadData]); - - const handleCreateTeam = async () => { - if (!newTeamName.trim()) return; - setIsCreating(true); - setError(null); - - try { - const res = await fetch('/api/inventory/teams', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newTeamName.trim() }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to create team'); - } - - setNewTeamName(''); - showSuccess('Team created!'); - await loadData(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create team'); - } finally { - setIsCreating(false); - } - }; - - const handleJoinTeam = async (teamId: string) => { - setError(null); - - try { - const res = await fetch(`/api/inventory/teams/${teamId}/join`, { - method: 'POST', - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to join team'); - } - - showSuccess('Joined team!'); - await loadData(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to join team'); - } - }; - - const handleSaveName = async () => { - if (!team || !editName.trim()) return; - setIsSavingName(true); - setError(null); - - try { - const res = await fetch(`/api/inventory/teams/${team.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: editName.trim() }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to update team name'); - } - - setEditingName(false); - showSuccess('Team name updated!'); - await loadData(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update team name'); - } finally { - setIsSavingName(false); - } - }; - - const handleAddMember = async () => { - if (!team || !addSlackId.trim()) return; - setIsAddingMember(true); - setError(null); - - try { - const res = await fetch(`/api/inventory/teams/${team.id}/members`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slackId: addSlackId.trim() }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to add member'); - } - - setAddSlackId(''); - showSuccess('Member added!'); - await fetchTeamDetail(team.id); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add member'); - } finally { - setIsAddingMember(false); - } - }; - - const handleRemoveMember = async (userId: string) => { - if (!team) return; - setRemovingUserId(userId); - setError(null); - - try { - const res = await fetch(`/api/inventory/teams/${team.id}/members/${userId}`, { - method: 'DELETE', - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to remove member'); - } - - showSuccess('Member removed.'); - await fetchTeamDetail(team.id); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to remove member'); - } finally { - setRemovingUserId(null); - } - }; - - const handleLeaveTeam = async () => { - if (!team) return; - setIsLeaving(true); - setError(null); - - try { - const res = await fetch(`/api/inventory/teams/${team.id}/leave`, { - method: 'POST', - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to leave team'); - } - - setConfirmLeave(false); - showSuccess('You left the team.'); - await loadData(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to leave team'); - } finally { - setIsLeaving(false); - } - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
- {/* Success message */} - {successMessage && ( -
- {successMessage} -
- )} - - {/* Error message */} - {error && ( -
- {error} - -
- )} - - {/* Has team view */} - {team ? ( -
- {/* Team name */} -
-
- {editingName ? ( -
- setEditName(e.target.value)} - className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-lg" - autoFocus - /> - - -
- ) : ( - <> -

{team.name}

- {team.locked ? ( - - Locked - - ) : ( - - )} - - )} -
-
- - {/* Members */} -
-

Members

-
- {team.members.map(member => ( -
- {member.image ? ( - - ) : ( -
- ? -
- )} -
- {member.name} - {member.slackDisplayName && ( - {member.slackDisplayName} - )} -
- {member.id !== session?.user.id && !team.locked && ( - - )} -
- ))} -
-
- - {/* Add member */} - {!team.locked && ( -
-

Add Member

-
- setAddSlackId(e.target.value)} - placeholder="Slack User ID" - className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" - /> - -
-
- )} - - {/* Leave team */} -
- {confirmLeave ? ( -
- Are you sure you want to leave? - - -
- ) : ( - - )} -
-
- ) : ( - /* No team view */ -
- {/* Create team */} -
-

Create a Team

-
- setNewTeamName(e.target.value)} - placeholder="Team name" - className="flex-1 border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" - onKeyDown={e => { - if (e.key === 'Enter') handleCreateTeam(); - }} - /> - -
-
- - {/* Join a team */} -
-

Join a Team

- {teams.length === 0 ? ( -

No teams available to join.

- ) : ( -
- {teams.map(t => ( -
-
- {t.name} - - {t._count.members} member{t._count.members !== 1 ? 's' : ''} - - {t.locked && ( - - Locked - - )} -
- -
- ))} -
- )} -
-
- )} -
- ); -} diff --git a/app/inventory/tools/page.tsx b/app/inventory/tools/page.tsx deleted file mode 100644 index d4881f06..00000000 --- a/app/inventory/tools/page.tsx +++ /dev/null @@ -1,232 +0,0 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { useSession } from '@/lib/auth-client'; -import { ToolCard } from '@/app/components/inventory/ToolCard'; -import { RentalTimer } from '@/app/components/inventory/RentalTimer'; - -interface Tool { - id: string; - name: string; - description?: string; - imageUrl?: string; - available: boolean; -} - -interface Rental { - id: string; - toolId: string; - status: 'CHECKED_OUT' | 'RETURNED'; - floor: number; - location: string; - dueAt: string | null; - createdAt: string; - returnedAt: string | null; - tool: { id: string; name: string; description?: string; imageUrl?: string }; - rentedBy: { id: string; name: string; email: string }; -} - -const MAX_CONCURRENT_RENTALS = 2; - -export default function ToolsPage() { - const { data: session } = useSession(); - const [tools, setTools] = useState([]); - const [rentals, setRentals] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [rentModalToolId, setRentModalToolId] = useState(null); - const [floor, setFloor] = useState(1); - const [location, setLocation] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); - - const activeRentals = rentals.filter(r => r.status === 'CHECKED_OUT'); - - const fetchTools = useCallback(async () => { - try { - const res = await fetch('/api/inventory/tools'); - if (!res.ok) throw new Error('Failed to load tools'); - const data = await res.json(); - setTools(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load tools'); - } - }, []); - - const fetchRentals = useCallback(async () => { - try { - const res = await fetch('/api/inventory/rentals'); - if (!res.ok) return; - const data = await res.json(); - setRentals(data); - } catch { - // Ignore - user might not be on a team - } - }, []); - - useEffect(() => { - if (!session) return; - Promise.all([fetchTools(), fetchRentals()]).finally(() => setLoading(false)); - }, [session, fetchTools, fetchRentals]); - - const handleRent = async () => { - if (!rentModalToolId || !location.trim()) return; - setIsSubmitting(true); - setError(null); - - try { - const res = await fetch('/api/inventory/rentals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolId: rentModalToolId, floor, location: location.trim() }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to rent tool'); - } - - setRentModalToolId(null); - setFloor(1); - setLocation(''); - setSuccessMessage('Tool rented successfully!'); - fetchTools(); - fetchRentals(); - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to rent tool'); - } finally { - setIsSubmitting(false); - } - }; - - const openRentModal = (toolId: string) => { - setError(null); - setRentModalToolId(toolId); - setFloor(1); - setLocation(''); - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
- {/* Success message */} - {successMessage && ( -
- {successMessage} -
- )} - - {/* Error message */} - {error && ( -
- {error} - -
- )} - - {/* Active rentals */} - {activeRentals.length > 0 && ( -
-

- Active Rentals ({activeRentals.length} / {MAX_CONCURRENT_RENTALS}) -

-
- {activeRentals.map(rental => ( -
-
- {rental.tool.name} - - Floor {rental.floor} - {rental.location} - -
- -
- ))} -
-
- )} - - {/* Tools grid */} -

Available Tools

- {tools.length === 0 ? ( -

No tools available.

- ) : ( -
- {tools.map(tool => ( - - ))} -
- )} - - {/* Rent modal */} - {rentModalToolId && ( -
-
setRentModalToolId(null)} /> -
-

Rent Tool

- -

- {tools.find(t => t.id === rentModalToolId)?.name} -

- - {/* Floor dropdown */} -
- - -
- - {/* Location input */} -
- - setLocation(e.target.value)} - placeholder="Room number or table" - className="w-full border-2 border-brown-800 bg-cream-50 text-brown-800 px-3 py-2 text-sm placeholder:text-brown-800/30" - /> -
- - {/* Actions */} -
- - -
-
-
- )} -
- ); -} diff --git a/app/layout.tsx b/app/layout.tsx index 0d8d202f..def780ad 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Libre_Barcode_128, Libre_Barcode_39 } from "next/font/google"; import localFont from "next/font/local"; import Script from "next/script"; @@ -23,11 +23,14 @@ const libreBarcode39 = Libre_Barcode_39({ variable: "--font-qr", }); +export const viewport: Viewport = { + themeColor: "#C4B9A2", +}; + export const metadata: Metadata = { metadataBase: new URL("https://stasis.hackclub.com"), title: "Stasis", description: "A High School Hardware Hackathon in Austin, TX on May 15-18", - themeColor:"#C4B9A2", icons: { icon: "/favicon.svg", }, diff --git a/instrumentation-client.ts.bak b/instrumentation-client.ts.bak deleted file mode 100644 index 1e6e0137..00000000 --- a/instrumentation-client.ts.bak +++ /dev/null @@ -1,47 +0,0 @@ -// This file configures the initialization of Sentry on the client. -// The added config here will be used whenever a users loads a page in their browser. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -if (typeof window !== "undefined") { - Sentry.init({ - dsn: "https://5e657a38b387207d41ded2b67cdec8ad@o40609.ingest.us.sentry.io/4510701182255104", - enabled: process.env.NODE_ENV !== "development", - - // Filter out browser extension noise - beforeSend(event) { - if ( - event.message?.includes("runtime.sendMessage") || - event.exception?.values?.some((e) => - e.value?.includes("runtime.sendMessage") - ) - ) { - return null; - } - return event; - }, - - // Add optional integrations for additional features - integrations: [Sentry.replayIntegration()], - - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - // Enable logs to be sent to Sentry - enableLogs: true, - - // Define how likely Replay events are sampled. - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, - - // Define how likely Replay events are sampled when an error occurs. - replaysOnErrorSampleRate: 1.0, - - // Enable sending user PII (Personally Identifiable Information) - // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii - sendDefaultPii: true, - }); -} - -export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/instrumentation.ts b/instrumentation.ts index 7cbe93c1..3a8620d4 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -3,6 +3,20 @@ import * as Sentry from "@sentry/nextjs"; export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { await import("./sentry.server.config"); + const { startOverdueChecker } = await import("./lib/inventory/overdue-checker"); + startOverdueChecker(); + + // Backfill Slack group DMs for teams that don't have one yet + const { syncTeamChannel } = await import("./lib/inventory/team-channel"); + const prisma = (await import("./lib/prisma")).default; + prisma.team.findMany({ + where: { slackChannelId: null }, + select: { id: true }, + }).then((teams) => { + for (const team of teams) { + syncTeamChannel(team.id).catch(() => {}); + } + }).catch(() => {}); } if (process.env.NEXT_RUNTIME === "edge") { diff --git a/lib/inventory/access.ts b/lib/inventory/access.ts index aa216de2..928775ea 100644 --- a/lib/inventory/access.ts +++ b/lib/inventory/access.ts @@ -3,7 +3,11 @@ import { auth } from "@/lib/auth" import { headers } from "next/headers" import { NextResponse } from "next/server" import { getUserRoles, hasRole, Role } from "@/lib/permissions" -import { MIN_BITS_FOR_INVENTORY } from "./config" +import { + MIN_BITS_FOR_INVENTORY, + VENUE_FLOORS, + MAX_CONCURRENT_RENTALS, +} from "./config" export async function checkInventoryAccess(userId: string) { const [settings, balanceResult, user, roles] = await Promise.all([ @@ -25,15 +29,19 @@ export async function checkInventoryAccess(userId: string) { const isAdmin = hasRole(roles, Role.ADMIN) const enabled = settings?.enabled ?? false const balance = balanceResult._sum.amount ?? 0 + const allowMultipleOrders = process.env.INVENTORY_ALLOW_MULTIPLE_ORDERS === "true" + const config = { venueFloors: VENUE_FLOORS, maxConcurrentRentals: MAX_CONCURRENT_RENTALS, allowMultipleOrders } if (isAdmin) { return { allowed: true, + reason: null, isAdmin: true, teamId: user?.teamId ?? null, teamName: user?.team?.name ?? null, balance, enabled, + ...config, } } @@ -46,6 +54,7 @@ export async function checkInventoryAccess(userId: string) { teamName: null, balance, enabled, + ...config, } } @@ -58,16 +67,19 @@ export async function checkInventoryAccess(userId: string) { teamName: null, balance, enabled, + ...config, } } return { allowed: true, + reason: null, isAdmin: false, teamId: user?.teamId ?? null, teamName: user?.team?.name ?? null, balance, enabled, + ...config, } } diff --git a/lib/inventory/digikey.ts b/lib/inventory/digikey.ts index c1a84f27..3850abbb 100644 --- a/lib/inventory/digikey.ts +++ b/lib/inventory/digikey.ts @@ -33,9 +33,18 @@ async function getToken(): Promise { return tokenCache.accessToken } +export interface DigiKeyResult { + name: string + description: string + manufacturer: string + partNumber: string + imageUrl: string + category: string +} + export async function searchDigiKey( query: string -): Promise<{ name: string; imageUrl: string }[]> { +): Promise { const token = await getToken() const res = await fetch( @@ -61,16 +70,18 @@ export async function searchDigiKey( const data = await res.json() const products = data.Products ?? data.products ?? [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any return products - .filter( - (p: { PrimaryPhoto?: string; ProductDescription?: string }) => - p.PrimaryPhoto - ) - .map( - (p: { PrimaryPhoto: string; ProductDescription?: string; ManufacturerPartNumber?: string }) => ({ - name: - p.ProductDescription ?? p.ManufacturerPartNumber ?? "Unknown", - imageUrl: p.PrimaryPhoto, - }) - ) + .filter((p: any) => p.PhotoUrl || p.PrimaryPhoto) + .map((p: any) => ({ + name: p.ManufacturerProductNumber ?? p.ManufacturerPartNumber ?? "Unknown", + description: + p.Description?.ProductDescription ?? + p.ProductDescription ?? + "", + manufacturer: p.Manufacturer?.Name ?? "", + partNumber: p.ManufacturerProductNumber ?? p.ManufacturerPartNumber ?? "", + imageUrl: p.PhotoUrl ?? p.PrimaryPhoto, + category: p.Category?.Name ?? "", + })) } diff --git a/lib/inventory/notifications.ts b/lib/inventory/notifications.ts index 475a4396..7bc92865 100644 --- a/lib/inventory/notifications.ts +++ b/lib/inventory/notifications.ts @@ -1,19 +1,109 @@ import prisma from "@/lib/prisma" -import { sendSlackDM } from "@/lib/slack" - -export function notifyTeam(teamId: string, message: string) { - // Fire-and-forget: don't await, don't block the response - prisma.user - .findMany({ - where: { teamId }, - select: { slackId: true }, +import { sendSlackMessage } from "@/lib/slack" + +export function shortOrderId(id: string): string { + return id.slice(-6).toUpperCase() +} + +export function notifyTeam( + teamId: string, + message: string, + blocks?: Record[] +) { + prisma.team + .findUnique({ + where: { id: teamId }, + select: { slackChannelId: true }, }) - .then((members) => { - for (const member of members) { - if (member.slackId) { - sendSlackDM(member.slackId, message).catch(() => {}) - } - } + .then((team) => { + if (!team?.slackChannelId) return + sendSlackMessage( + team.slackChannelId, + message, + blocks ? { blocks } : undefined + ).catch(() => {}) }) .catch(() => {}) } + +interface OrderForNotification { + id: string + items: Array<{ item: { name: string }; quantity: number }> + floor: number + location: string +} + +export function notifyOrderUpdate( + teamId: string, + order: OrderForNotification, + action: string +) { + const id = shortOrderId(order.id) + const itemList = order.items + .map((i) => `${i.item.name} x${i.quantity}`) + .join("\n") + const fallback = `${action} (Order #${id}): ${order.items.map((i) => `${i.item.name} x${i.quantity}`).join(", ")} -- Floor ${order.floor}, ${order.location}` + + const blocks: Record[] = [ + { + type: "header", + text: { type: "plain_text", text: action }, + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Order*\n#${id}` }, + { type: "mrkdwn", text: `*Location*\nFloor ${order.floor}, ${order.location}` }, + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: `*Items*\n${itemList}` }, + }, + { + type: "context", + elements: [ + { type: "mrkdwn", text: "Stasis Inventory" }, + ], + }, + ] + + notifyTeam(teamId, fallback, blocks) +} + +export function notifyRental( + teamId: string, + toolName: string, + title: string, + detail?: string +) { + const fallback = detail + ? `${title}: ${toolName} -- ${detail}` + : `${title}: ${toolName}` + + const fields: Record[] = [ + { type: "mrkdwn", text: `*Tool*\n${toolName}` }, + ] + if (detail) { + fields.push({ type: "mrkdwn", text: `*Details*\n${detail}` }) + } + + const blocks: Record[] = [ + { + type: "header", + text: { type: "plain_text", text: title }, + }, + { + type: "section", + fields, + }, + { + type: "context", + elements: [ + { type: "mrkdwn", text: "Stasis Inventory" }, + ], + }, + ] + + notifyTeam(teamId, fallback, blocks) +} diff --git a/lib/inventory/overdue-checker.ts b/lib/inventory/overdue-checker.ts new file mode 100644 index 00000000..6cd1e5dd --- /dev/null +++ b/lib/inventory/overdue-checker.ts @@ -0,0 +1,46 @@ +import prisma from "@/lib/prisma" +import { notifyRental } from "./notifications" + +const CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes + +let started = false +const notifiedRentals = new Set() + +export function startOverdueChecker() { + if (started) return + started = true + + setInterval(async () => { + try { + const overdueRentals = await prisma.toolRental.findMany({ + where: { + status: "CHECKED_OUT", + dueAt: { lt: new Date() }, + }, + include: { + tool: { select: { name: true } }, + team: { select: { id: true, name: true } }, + }, + }) + + for (const rental of overdueRentals) { + if (notifiedRentals.has(rental.id)) continue + notifiedRentals.add(rental.id) + notifyRental( + rental.teamId, + rental.tool.name, + "Rental Overdue", + `Due at ${rental.dueAt!.toLocaleString()}. Please return it to the hardware station.` + ) + } + + // Clean up returned rentals from the set + const overdueIds = new Set(overdueRentals.map((r) => r.id)) + for (const id of notifiedRentals) { + if (!overdueIds.has(id)) notifiedRentals.delete(id) + } + } catch (err) { + console.error("[OverdueChecker] Error checking overdue rentals:", err) + } + }, CHECK_INTERVAL_MS) +} diff --git a/lib/inventory/sse.ts b/lib/inventory/sse.ts index 1e48d254..c8be8911 100644 --- a/lib/inventory/sse.ts +++ b/lib/inventory/sse.ts @@ -1,4 +1,5 @@ const connections = new Map>() +const encoder = new TextEncoder() export function registerConnection( key: string, @@ -25,7 +26,7 @@ export function pushSSE( teamId: string, event: { type: string; data: unknown } ) { - const encoded = new TextEncoder().encode( + const encoded = encoder.encode( `data: ${JSON.stringify(event)}\n\n` ) for (const key of [teamId, "admin"]) { diff --git a/lib/inventory/team-channel.ts b/lib/inventory/team-channel.ts new file mode 100644 index 00000000..099505a6 --- /dev/null +++ b/lib/inventory/team-channel.ts @@ -0,0 +1,130 @@ +import prisma from "@/lib/prisma" + +async function slackApi(method: string, body: Record) { + const token = process.env.SLACK_BOT_TOKEN + if (!token) return null + + const res = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + const data = await res.json() + if (!data.ok && data.error !== "already_pinned") { + console.error(`[syncTeamChannel] ${method} failed:`, data.error) + return null + } + return data +} + +function buildWelcomeBlocks(teamName: string, memberNames: string[]) { + const memberList = memberNames.length > 0 + ? memberNames.map((n) => `- ${n}`).join("\n") + : "_No members yet_" + + return [ + { + type: "header", + text: { type: "plain_text", text: `Team: ${teamName}` }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "This is your team's Stasis Inventory channel. Order updates, rental notifications, and coordination all happen here.", + }, + }, + { type: "divider" }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Members (${memberNames.length})*\n${memberList}`, + }, + }, + ] +} + +/** + * Create or update a Slack group DM for a team. + * Posts a welcome message and updates it when membership changes. + */ +export async function syncTeamChannel(teamId: string) { + const token = process.env.SLACK_BOT_TOKEN + if (!token) return + + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { + name: true, + slackChannelId: true, + slackWelcomeTs: true, + members: { select: { name: true, slackId: true } }, + }, + }) + + if (!team) return + + const slackIds = team.members + .map((m) => m.slackId) + .filter((id): id is string => !!id && /^[UW]/.test(id)) + + if (slackIds.length === 0) return + + const memberNames = team.members + .map((m) => m.name) + .filter(Boolean) as string[] + + try { + const openData = await slackApi("conversations.open", { + users: slackIds.join(","), + }) + if (!openData) return + + const channelId = openData.channel.id + const channelChanged = channelId !== team.slackChannelId + + const blocks = buildWelcomeBlocks(team.name, memberNames) + const text = `Team: ${team.name} -- ${memberNames.length} members` + + if (team.slackWelcomeTs && !channelChanged) { + // Update the existing welcome message + await slackApi("chat.update", { + channel: channelId, + ts: team.slackWelcomeTs, + text, + blocks, + }) + } else { + // Post new welcome message + const msgData = await slackApi("chat.postMessage", { + channel: channelId, + text, + blocks, + }) + + if (msgData) { + await prisma.team.update({ + where: { id: teamId }, + data: { + slackChannelId: channelId, + slackWelcomeTs: msgData.ts, + }, + }) + return + } + } + + if (channelChanged) { + await prisma.team.update({ + where: { id: teamId }, + data: { slackChannelId: channelId }, + }) + } + } catch (err) { + console.error("[syncTeamChannel] Error:", err) + } +} diff --git a/lib/inventory/teams.ts b/lib/inventory/teams.ts new file mode 100644 index 00000000..8192b9d3 --- /dev/null +++ b/lib/inventory/teams.ts @@ -0,0 +1,26 @@ +import prisma from "@/lib/prisma" +import { syncTeamChannel } from "./team-channel" + +/** + * Remove a user from a team. Deletes the team if no members remain, + * otherwise syncs the Slack channel. + */ +export async function removeFromTeam(userId: string, teamId: string) { + const remaining = await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { teamId: null }, + }) + + const count = await tx.user.count({ where: { teamId } }) + + if (count === 0) { + await tx.team.delete({ where: { id: teamId } }) + } + return count + }) + + if (remaining > 0) { + syncTeamChannel(teamId).catch(() => {}) + } +} diff --git a/lib/hooks/useInventorySSE.ts b/lib/inventory/useInventorySSE.ts similarity index 100% rename from lib/hooks/useInventorySSE.ts rename to lib/inventory/useInventorySSE.ts diff --git a/lib/slack.ts b/lib/slack.ts index 4f64c571..ced757ec 100644 --- a/lib/slack.ts +++ b/lib/slack.ts @@ -1,3 +1,36 @@ +export async function sendSlackMessage( + channel: string, + text: string, + options?: { blocks?: Record[] } +): Promise<{ ok: boolean; error?: string }> { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + return { ok: false, error: "SLACK_BOT_TOKEN not configured" }; + } + + try { + const blocks = options?.blocks ?? [ + { type: "section", text: { type: "mrkdwn", text } }, + ]; + + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ channel, text, blocks }), + }); + const data = await res.json(); + if (!data.ok) { + return { ok: false, error: `chat.postMessage: ${data.error}` }; + } + return { ok: true }; + } catch (error) { + return { ok: false, error: String(error) }; + } +} + export async function sendSlackDM( slackId: string, text: string, diff --git a/middleware.ts b/middleware.ts index 25ed010e..b3cb4494 100644 --- a/middleware.ts +++ b/middleware.ts @@ -6,7 +6,7 @@ const securityHeaders = { "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' https://stasis-staging.hackclub-assets.com https://stasis.hackclub-assets.com https://avatars.slack-edge.com https://github.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://*.s3.amazonaws.com https://blueprint.hackclub.com https://cdn.hackclub.com https://user-cdn.hackclub-assets.com https://*.airtableusercontent.com https://www.freeiconspng.com https://hc-cdn.hel1.your-objectstorage.com data: blob:", + "img-src 'self' https://stasis-staging.hackclub-assets.com https://stasis.hackclub-assets.com https://avatars.slack-edge.com https://github.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://*.s3.amazonaws.com https://blueprint.hackclub.com https://cdn.hackclub.com https://user-cdn.hackclub-assets.com https://*.airtableusercontent.com https://www.freeiconspng.com https://hc-cdn.hel1.your-objectstorage.com https://mm.digikey.com data: blob:", "media-src 'self' https://stasis-staging.hackclub-assets.com https://stasis.hackclub-assets.com blob:", "connect-src 'self' https://api2.hackclub.com", "font-src 'self'", diff --git a/prisma/migrations/20260324032823_add_inventory_models/migration.sql b/prisma/migrations/20260324032823_add_inventory_models/migration.sql index fd011c58..3132b2ed 100644 --- a/prisma/migrations/20260324032823_add_inventory_models/migration.sql +++ b/prisma/migrations/20260324032823_add_inventory_models/migration.sql @@ -20,6 +20,9 @@ ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_SETTINGS_UPDATE'; ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_CREATE'; ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_UPDATE'; ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ITEM_DELETE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TOOL_CREATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TOOL_UPDATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TOOL_DELETE'; -- AlterTable ALTER TABLE "user" ADD COLUMN "teamId" TEXT; @@ -115,6 +118,9 @@ CREATE TABLE "inventory_settings" ( -- CreateIndex CREATE UNIQUE INDEX "team_name_key" ON "team"("name"); +-- CreateIndex +CREATE UNIQUE INDEX "item_name_key" ON "item"("name"); + -- CreateIndex CREATE INDEX "order_teamId_idx" ON "order"("teamId"); diff --git a/prisma/migrations/20260325023442_add_nfc_id/migration.sql b/prisma/migrations/20260325023442_add_nfc_id/migration.sql new file mode 100644 index 00000000..96a5d0e4 --- /dev/null +++ b/prisma/migrations/20260325023442_add_nfc_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[nfcId]` on the table `user` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "nfcId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_nfcId_key" ON "user"("nfcId"); diff --git a/prisma/migrations/20260326042102_add_cancelled_order_status/migration.sql b/prisma/migrations/20260326042102_add_cancelled_order_status/migration.sql new file mode 100644 index 00000000..f753e3e4 --- /dev/null +++ b/prisma/migrations/20260326042102_add_cancelled_order_status/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ORDER_CANCEL'; + +-- AlterEnum +ALTER TYPE "OrderStatus" ADD VALUE 'CANCELLED'; diff --git a/prisma/migrations/20260326044116_add_notify_prefs/migration.sql b/prisma/migrations/20260326044116_add_notify_prefs/migration.sql new file mode 100644 index 00000000..e485559e --- /dev/null +++ b/prisma/migrations/20260326044116_add_notify_prefs/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "notifyPrefs" JSONB; diff --git a/prisma/migrations/20260326051015_add_team_slack_channel/migration.sql b/prisma/migrations/20260326051015_add_team_slack_channel/migration.sql new file mode 100644 index 00000000..68cb5d04 --- /dev/null +++ b/prisma/migrations/20260326051015_add_team_slack_channel/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `notifyPrefs` on the `user` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "team" ADD COLUMN "slackChannelId" TEXT; + +-- AlterTable +ALTER TABLE "user" DROP COLUMN "notifyPrefs"; diff --git a/prisma/migrations/20260326053252_add_slack_pinned_ts/migration.sql b/prisma/migrations/20260326053252_add_slack_pinned_ts/migration.sql new file mode 100644 index 00000000..f1b0351d --- /dev/null +++ b/prisma/migrations/20260326053252_add_slack_pinned_ts/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "team" ADD COLUMN "slackPinnedTs" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02ec7cdf..a8987a22 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ enum OrderStatus { IN_PROGRESS READY COMPLETED + CANCELLED } enum RentalStatus { @@ -62,6 +63,7 @@ model User { image String? slackId String? @unique slackDisplayName String? + nfcId String? @unique verificationStatus String? hackatimeUserId String? @unique bio String? @@ -449,12 +451,16 @@ enum AuditAction { // Inventory actions INVENTORY_IMPORT INVENTORY_ORDER_STATUS_UPDATE + INVENTORY_ORDER_CANCEL INVENTORY_RENTAL_RETURN INVENTORY_TEAM_LOCK INVENTORY_SETTINGS_UPDATE INVENTORY_ITEM_CREATE INVENTORY_ITEM_UPDATE INVENTORY_ITEM_DELETE + INVENTORY_TOOL_CREATE + INVENTORY_TOOL_UPDATE + INVENTORY_TOOL_DELETE } model AuditLog { @@ -633,21 +639,23 @@ model Kudos { // ── Inventory Models ──────────────────────────────────────────────── model Team { - id String @id @default(cuid()) - name String @unique - locked Boolean @default(false) - members User[] @relation("TeamMembers") - orders Order[] - toolRentals ToolRental[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique + locked Boolean @default(false) + slackChannelId String? + slackWelcomeTs String? + members User[] @relation("TeamMembers") + orders Order[] + toolRentals ToolRental[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("team") } model Item { id String @id @default(cuid()) - name String + name String @unique description String? imageUrl String? stock Int From 90e46a58e3a31e255c4365b6123f0b42ae15f250 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:09:37 -0700 Subject: [PATCH 03/14] Fix 3 critical security/logic issues in inventory system - Verify caller is a team member or admin before allowing member removal (previously any authenticated user could kick anyone off any team) - Exclude cancelled orders from maxPerTeam quota calculations in both item display and order validation queries - Verify SSE subscribers belong to the requested team (or are admin) to prevent cross-team event snooping Co-Authored-By: Claude Opus 4.6 --- app/api/inventory/items/route.ts | 2 +- app/api/inventory/orders/route.ts | 2 +- app/api/inventory/sse/route.ts | 24 +++++++++++++++++++ .../teams/[id]/members/[userId]/route.ts | 13 ++++++++-- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/api/inventory/items/route.ts b/app/api/inventory/items/route.ts index 99feea6b..396684bb 100644 --- a/app/api/inventory/items/route.ts +++ b/app/api/inventory/items/route.ts @@ -20,7 +20,7 @@ export async function GET() { const usageRows = await prisma.orderItem.groupBy({ by: ["itemId"], where: { - order: { teamId: user.teamId }, + order: { teamId: user.teamId, status: { not: "CANCELLED" } }, }, _sum: { quantity: true }, }) diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts index 7852cc30..110e93aa 100644 --- a/app/api/inventory/orders/route.ts +++ b/app/api/inventory/orders/route.ts @@ -102,7 +102,7 @@ export async function POST(request: Request) { _sum: { quantity: true }, where: { itemId, - order: { teamId: user.teamId }, + order: { teamId: user.teamId, status: { not: "CANCELLED" } }, }, }) const totalUsage = usageResult._sum.quantity ?? 0 diff --git a/app/api/inventory/sse/route.ts b/app/api/inventory/sse/route.ts index e5502c7e..cb07fe5a 100644 --- a/app/api/inventory/sse/route.ts +++ b/app/api/inventory/sse/route.ts @@ -2,6 +2,7 @@ import { auth } from "@/lib/auth" import { headers } from "next/headers" import { NextRequest } from "next/server" import { registerConnection, removeConnection } from "@/lib/inventory/sse" +import prisma from "@/lib/prisma" const encoder = new TextEncoder() const KEEPALIVE = encoder.encode(": keepalive\n\n") @@ -14,6 +15,29 @@ export async function GET(request: NextRequest) { const teamId = request.nextUrl.searchParams.get("teamId") || "admin" + // Verify the user belongs to the requested team or is an admin + if (teamId === "admin") { + const isAdmin = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: "ADMIN" }, + }) + if (!isAdmin) { + return new Response("Forbidden", { status: 403 }) + } + } else { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + if (user?.teamId !== teamId) { + const isAdmin = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: "ADMIN" }, + }) + if (!isAdmin) { + return new Response("Forbidden", { status: 403 }) + } + } + } + const stream = new ReadableStream({ start(controller) { registerConnection(teamId, controller) diff --git a/app/api/inventory/teams/[id]/members/[userId]/route.ts b/app/api/inventory/teams/[id]/members/[userId]/route.ts index 02dd85e4..10148fd1 100644 --- a/app/api/inventory/teams/[id]/members/[userId]/route.ts +++ b/app/api/inventory/teams/[id]/members/[userId]/route.ts @@ -28,8 +28,17 @@ export async function DELETE( return NextResponse.json({ error: "Team is locked" }, { status: 403 }) } - const isMember = team.members.some((m) => m.id === userId) - if (!isMember) { + // Verify caller is a member of this team or an admin + const callerIsMember = team.members.some((m) => m.id === session.user.id) + const callerIsAdmin = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: "ADMIN" }, + }) + if (!callerIsMember && !callerIsAdmin) { + return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) + } + + const targetIsMember = team.members.some((m) => m.id === userId) + if (!targetIsMember) { return NextResponse.json({ error: "User is not a member of this team" }, { status: 400 }) } From f634f2816a991f7a890916adbadff8164f5304aa Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:07:49 -0700 Subject: [PATCH 04/14] Security hardening: comprehensive inventory system audit fixes - Add input sanitization (DOMPurify) to all user-supplied fields across inventory routes (team names, locations, item names, descriptions) - Fix IDOR: require team membership/admin to add members or view team details - Fix authorization: restrict member removal to self-leave or admin only - Fix race condition: use Serializable isolation on order stock transactions - Remove email from non-admin API responses to protect minor PII - Add requireInventoryAccess() gate to items, tools, teams, orders, rentals - Validate quantity (positive int), floor (1-N range), location (max 200 chars), team name (max 100 chars), imageUrl (HTTPS-only, rejects javascript:/data:) - Add audit logging for all user actions (order place/cancel, rental create, team create/join/leave/delete/rename, member add/kick, badge assign) - Cap SSE connections (50/key, 500 total) to prevent DoS - Block team deletion when active orders or rentals exist - Fix shop purchase error re-throw that could leak internal state - Add import hardening (500 item cap, Infinity blocked, sanitized strings) Co-Authored-By: Claude Opus 4.6 --- app/api/inventory/admin/assign-badge/route.ts | 27 ++++++- app/api/inventory/admin/items/[id]/route.ts | 22 ++++-- app/api/inventory/admin/items/import/route.ts | 35 ++++++--- app/api/inventory/admin/items/route.ts | 26 +++++-- app/api/inventory/admin/tools/[id]/route.ts | 8 ++- app/api/inventory/admin/tools/route.ts | 8 ++- app/api/inventory/items/[id]/route.ts | 7 +- app/api/inventory/items/route.ts | 8 +-- app/api/inventory/orders/[id]/cancel/route.ts | 10 +++ app/api/inventory/orders/route.ts | 71 ++++++++++++++----- app/api/inventory/rentals/route.ts | 63 ++++++++++++---- app/api/inventory/sse/route.ts | 7 +- app/api/inventory/teams/[id]/join/route.ts | 20 ++++-- app/api/inventory/teams/[id]/leave/route.ts | 9 +++ .../teams/[id]/members/[userId]/route.ts | 20 ++++-- app/api/inventory/teams/[id]/members/route.ts | 23 ++++++ app/api/inventory/teams/[id]/route.ts | 56 ++++++++++++++- app/api/inventory/teams/route.ts | 36 ++++++---- app/api/inventory/tools/route.ts | 7 +- app/api/shop/purchase/route.ts | 3 +- lib/inventory/sse.ts | 21 +++++- lib/inventory/validation.ts | 63 ++++++++++++++++ prisma/schema.prisma | 15 +++- 23 files changed, 466 insertions(+), 99 deletions(-) create mode 100644 lib/inventory/validation.ts diff --git a/app/api/inventory/admin/assign-badge/route.ts b/app/api/inventory/admin/assign-badge/route.ts index bd3c64d9..dec76f2e 100644 --- a/app/api/inventory/admin/assign-badge/route.ts +++ b/app/api/inventory/admin/assign-badge/route.ts @@ -1,23 +1,35 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" +import { logAdminAction, AuditAction } from "@/lib/audit" +import { sanitize } from "@/lib/sanitize" export async function POST(request: Request) { const result = await requireAdmin() if ("error" in result) return result.error + const { session } = result + const body = await request.json() const { userId, nfcId } = body - if (!userId || !nfcId) { + if (!userId || typeof userId !== "string" || !nfcId || typeof nfcId !== "string") { return NextResponse.json( { error: "userId and nfcId are required" }, { status: 400 } ) } + const safeNfcId = sanitize(nfcId).trim() + if (safeNfcId.length === 0) { + return NextResponse.json( + { error: "nfcId is required" }, + { status: 400 } + ) + } + // Check if this nfcId is already assigned to someone else - const existing = await prisma.user.findUnique({ where: { nfcId } }) + const existing = await prisma.user.findUnique({ where: { nfcId: safeNfcId } }) if (existing && existing.id !== userId) { return NextResponse.json( { error: "This badge is already assigned to another user" }, @@ -27,9 +39,18 @@ export async function POST(request: Request) { const user = await prisma.user.update({ where: { id: userId }, - data: { nfcId }, + data: { nfcId: safeNfcId }, select: { id: true, name: true, slackDisplayName: true, nfcId: true }, }) + await logAdminAction( + AuditAction.INVENTORY_BADGE_ASSIGN, + session.user.id, + session.user.email, + "User", + userId, + { nfcId: safeNfcId } + ) + return NextResponse.json(user) } diff --git a/app/api/inventory/admin/items/[id]/route.ts b/app/api/inventory/admin/items/[id]/route.ts index a459a95c..f832aa3f 100644 --- a/app/api/inventory/admin/items/[id]/route.ts +++ b/app/api/inventory/admin/items/[id]/route.ts @@ -2,6 +2,13 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { logAdminAction, AuditAction } from "@/lib/audit" +import { + sanitizeName, + sanitizeDescription, + validateImageUrl, + validateNonNegativeInt, + validatePositiveInt, +} from "@/lib/inventory/validation" export async function PATCH( request: Request, @@ -19,14 +26,21 @@ export async function PATCH( const body = await request.json() const { name, description, imageUrl, stock, category, maxPerTeam } = body + if (stock !== undefined && !validateNonNegativeInt(stock)) { + return NextResponse.json({ error: "stock must be a non-negative integer" }, { status: 400 }) + } + if (maxPerTeam !== undefined && !validatePositiveInt(maxPerTeam)) { + return NextResponse.json({ error: "maxPerTeam must be a positive integer" }, { status: 400 }) + } + const item = await prisma.item.update({ where: { id }, data: { - ...(name !== undefined && { name }), - ...(description !== undefined && { description }), - ...(imageUrl !== undefined && { imageUrl }), + ...(name !== undefined && { name: sanitizeName(name) }), + ...(description !== undefined && { description: description ? sanitizeDescription(description) : null }), + ...(imageUrl !== undefined && { imageUrl: validateImageUrl(imageUrl) }), ...(stock !== undefined && { stock }), - ...(category !== undefined && { category }), + ...(category !== undefined && { category: sanitizeName(category) }), ...(maxPerTeam !== undefined && { maxPerTeam }), }, }) diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts index ffb8e9bd..8c090c4e 100644 --- a/app/api/inventory/admin/items/import/route.ts +++ b/app/api/inventory/admin/items/import/route.ts @@ -2,6 +2,11 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { logAdminAction, AuditAction } from "@/lib/audit" +import { + sanitizeName, + sanitizeDescription, + validateImageUrl, +} from "@/lib/inventory/validation" export async function POST(request: Request) { const result = await requireAdmin() @@ -19,14 +24,28 @@ export async function POST(request: Request) { ) } - const data = items.map((item: Record) => ({ - name: item.name as string, - description: ((item.description as string) || null), - imageUrl: ((item.imageUrl ?? item.image_url) as string) || null, - stock: Number(item.stock) || 0, - category: item.category as string, - maxPerTeam: Number(item.maxPerTeam ?? item.max_per_team) || 0, - })) + if (items.length > 500) { + return NextResponse.json( + { error: "Cannot import more than 500 items at once" }, + { status: 400 } + ) + } + + const data = items.map((item: Record) => { + const stock = Math.max(0, Math.floor(Number(item.stock) || 0)) + const maxPerTeam = Math.max(0, Math.floor(Number(item.maxPerTeam ?? item.max_per_team) || 0)) + if (!Number.isFinite(stock) || !Number.isFinite(maxPerTeam)) { + throw new Error("Invalid numeric values in import data") + } + return { + name: sanitizeName(String(item.name ?? "")), + description: item.description ? sanitizeDescription(String(item.description)) : null, + imageUrl: validateImageUrl(item.imageUrl ?? item.image_url), + stock, + category: sanitizeName(String(item.category ?? "")), + maxPerTeam, + } + }) await prisma.$transaction( data.map((item) => diff --git a/app/api/inventory/admin/items/route.ts b/app/api/inventory/admin/items/route.ts index ca5628c4..9dbaa058 100644 --- a/app/api/inventory/admin/items/route.ts +++ b/app/api/inventory/admin/items/route.ts @@ -2,6 +2,13 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { requireAdmin } from "@/lib/admin-auth" import { logAdminAction, AuditAction } from "@/lib/audit" +import { + sanitizeName, + sanitizeDescription, + validateImageUrl, + validateNonNegativeInt, + validatePositiveInt, +} from "@/lib/inventory/validation" export async function POST(request: Request) { const result = await requireAdmin() @@ -19,13 +26,24 @@ export async function POST(request: Request) { ) } + if (!validateNonNegativeInt(stock) || !validatePositiveInt(maxPerTeam)) { + return NextResponse.json( + { error: "stock and maxPerTeam must be valid positive integers" }, + { status: 400 } + ) + } + + const safeName = sanitizeName(name) + const safeDescription = description ? sanitizeDescription(description) : null + const safeImageUrl = validateImageUrl(imageUrl) + const item = await prisma.item.create({ data: { - name, - description: description ?? null, - imageUrl: imageUrl ?? null, + name: safeName, + description: safeDescription, + imageUrl: safeImageUrl, stock, - category, + category: sanitizeName(category), maxPerTeam, }, }) diff --git a/app/api/inventory/admin/tools/[id]/route.ts b/app/api/inventory/admin/tools/[id]/route.ts index 96b382f5..3b336d99 100644 --- a/app/api/inventory/admin/tools/[id]/route.ts +++ b/app/api/inventory/admin/tools/[id]/route.ts @@ -19,12 +19,14 @@ export async function PATCH( const body = await request.json() const { name, description, imageUrl } = body + const { sanitizeName, sanitizeDescription, validateImageUrl } = await import("@/lib/inventory/validation") + const tool = await prisma.tool.update({ where: { id }, data: { - ...(name !== undefined && { name }), - ...(description !== undefined && { description }), - ...(imageUrl !== undefined && { imageUrl }), + ...(name !== undefined && { name: sanitizeName(name) }), + ...(description !== undefined && { description: description ? sanitizeDescription(description) : null }), + ...(imageUrl !== undefined && { imageUrl: validateImageUrl(imageUrl) }), }, }) diff --git a/app/api/inventory/admin/tools/route.ts b/app/api/inventory/admin/tools/route.ts index a07f34ea..7d6a9e70 100644 --- a/app/api/inventory/admin/tools/route.ts +++ b/app/api/inventory/admin/tools/route.ts @@ -27,11 +27,13 @@ export async function POST(request: Request) { ) } + const { sanitizeName, sanitizeDescription, validateImageUrl } = await import("@/lib/inventory/validation") + const tool = await prisma.tool.create({ data: { - name, - description: description ?? null, - imageUrl: imageUrl ?? null, + name: sanitizeName(name), + description: description ? sanitizeDescription(description) : null, + imageUrl: validateImageUrl(imageUrl), }, }) diff --git a/app/api/inventory/items/[id]/route.ts b/app/api/inventory/items/[id]/route.ts index 44f5c000..fa592110 100644 --- a/app/api/inventory/items/[id]/route.ts +++ b/app/api/inventory/items/[id]/route.ts @@ -1,14 +1,13 @@ -import { auth } from "@/lib/auth" -import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" +import { requireInventoryAccess } from "@/lib/inventory/access" export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const result = await requireInventoryAccess() + if ("error" in result) return result.error const { id } = await params diff --git a/app/api/inventory/items/route.ts b/app/api/inventory/items/route.ts index 396684bb..2409ad14 100644 --- a/app/api/inventory/items/route.ts +++ b/app/api/inventory/items/route.ts @@ -1,11 +1,11 @@ -import { auth } from "@/lib/auth" -import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" +import { requireInventoryAccess } from "@/lib/inventory/access" export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const [items, user] = await Promise.all([ prisma.item.findMany({ orderBy: { name: "asc" } }), diff --git a/app/api/inventory/orders/[id]/cancel/route.ts b/app/api/inventory/orders/[id]/cancel/route.ts index f103f9f6..556d5615 100644 --- a/app/api/inventory/orders/[id]/cancel/route.ts +++ b/app/api/inventory/orders/[id]/cancel/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { pushSSE } from "@/lib/inventory/sse" import { notifyOrderUpdate } from "@/lib/inventory/notifications" +import { logAudit, AuditAction } from "@/lib/audit" export async function POST( _request: Request, @@ -62,6 +63,15 @@ export async function POST( } }) + logAudit({ + action: AuditAction.INVENTORY_ORDER_CANCEL_USER, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Order", + targetId: id, + metadata: { teamId: order.teamId }, + }).catch(() => {}) + notifyOrderUpdate(order.teamId, order, "Cancelled") pushSSE(order.teamId, { type: "order_status_updated", data: { id, status: "CANCELLED" } }) diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts index 110e93aa..7b7c091d 100644 --- a/app/api/inventory/orders/route.ts +++ b/app/api/inventory/orders/route.ts @@ -1,15 +1,20 @@ -import { auth } from "@/lib/auth" -import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" +import { Prisma } from "@/app/generated/prisma/client" import { pushSSE } from "@/lib/inventory/sse" import { notifyOrderUpdate } from "@/lib/inventory/notifications" +import { requireInventoryAccess } from "@/lib/inventory/access" +import { logAudit, AuditAction } from "@/lib/audit" +import { + sanitizeLocation, + validateFloor, + validatePositiveInt, +} from "@/lib/inventory/validation" export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -27,7 +32,7 @@ export async function GET() { where: { teamId: user.teamId }, include: { items: { include: { item: true } }, - placedBy: { select: { id: true, name: true, email: true } }, + placedBy: { select: { id: true, name: true } }, }, orderBy: { createdAt: "desc" }, }) @@ -36,10 +41,9 @@ export async function GET() { } export async function POST(request: Request) { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const body = await request.json() const { items, floor, location } = body as { @@ -52,13 +56,37 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Items are required" }, { status: 400 }) } - if (typeof floor !== "number" || !location) { + if (!validateFloor(floor)) { + return NextResponse.json( + { error: "Invalid floor number" }, + { status: 400 } + ) + } + + if (!location || typeof location !== "string") { return NextResponse.json( - { error: "Floor and location are required" }, + { error: "Location is required" }, { status: 400 } ) } + const safeLocation = sanitizeLocation(location) + if (safeLocation.length === 0) { + return NextResponse.json( + { error: "Location is required" }, + { status: 400 } + ) + } + + for (const { quantity } of items) { + if (!validatePositiveInt(quantity)) { + return NextResponse.json( + { error: "Each item quantity must be a positive integer" }, + { status: 400 } + ) + } + } + let order try { order = await prisma.$transaction(async (tx) => { @@ -92,7 +120,7 @@ export async function POST(request: Request) { for (const { itemId, quantity } of items) { const item = await tx.item.findUnique({ where: { id: itemId } }) if (!item) { - throw new Error(`Item ${itemId} not found`) + throw new Error("Item not found") } if (item.stock < quantity) { throw new Error(`Insufficient stock for ${item.name}`) @@ -124,7 +152,7 @@ export async function POST(request: Request) { teamId: user.teamId, placedById: session.user.id, floor, - location, + location: safeLocation, items: { create: items.map(({ itemId, quantity }) => ({ itemId, @@ -134,15 +162,26 @@ export async function POST(request: Request) { }, include: { items: { include: { item: true } }, - placedBy: { select: { id: true, name: true, email: true } }, + placedBy: { select: { id: true, name: true } }, }, }) + }, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }) } catch (err) { const message = err instanceof Error ? err.message : "Failed to place order" return NextResponse.json({ error: message }, { status: 400 }) } + logAudit({ + action: AuditAction.INVENTORY_ORDER_PLACE, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Order", + targetId: order.id, + metadata: { teamId: order.teamId, items: items.map(i => ({ itemId: i.itemId, quantity: i.quantity })), floor, location: safeLocation }, + }).catch(() => {}) + notifyOrderUpdate(order.teamId, order, "Placed") pushSSE(order.teamId, { type: "order_placed", data: order }) diff --git a/app/api/inventory/rentals/route.ts b/app/api/inventory/rentals/route.ts index b729828f..d62ff2a7 100644 --- a/app/api/inventory/rentals/route.ts +++ b/app/api/inventory/rentals/route.ts @@ -1,19 +1,22 @@ -import { auth } from "@/lib/auth" -import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { pushSSE } from "@/lib/inventory/sse" import { notifyRental } from "@/lib/inventory/notifications" +import { requireInventoryAccess } from "@/lib/inventory/access" +import { logAudit, AuditAction } from "@/lib/audit" import { MAX_CONCURRENT_RENTALS, TOOL_RENTAL_TIME_LIMIT_MINUTES, } from "@/lib/inventory/config" +import { + sanitizeLocation, + validateFloor, +} from "@/lib/inventory/validation" export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -31,7 +34,7 @@ export async function GET() { where: { teamId: user.teamId }, include: { tool: true, - rentedBy: { select: { id: true, name: true, email: true } }, + rentedBy: { select: { id: true, name: true } }, }, orderBy: { createdAt: "desc" }, }) @@ -40,10 +43,9 @@ export async function GET() { } export async function POST(request: Request) { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const body = await request.json() const { toolId, floor, location } = body as { @@ -52,9 +54,31 @@ export async function POST(request: Request) { location: string } - if (!toolId || typeof floor !== "number" || !location) { + if (!toolId || typeof toolId !== "string") { + return NextResponse.json( + { error: "toolId is required" }, + { status: 400 } + ) + } + + if (!validateFloor(floor)) { return NextResponse.json( - { error: "toolId, floor, and location are required" }, + { error: "Invalid floor number" }, + { status: 400 } + ) + } + + if (!location || typeof location !== "string") { + return NextResponse.json( + { error: "Location is required" }, + { status: 400 } + ) + } + + const safeLocation = sanitizeLocation(location) + if (safeLocation.length === 0) { + return NextResponse.json( + { error: "Location is required" }, { status: 400 } ) } @@ -108,12 +132,12 @@ export async function POST(request: Request) { teamId: user.teamId, rentedById: session.user.id, floor, - location, + location: safeLocation, dueAt, }, include: { tool: true, - rentedBy: { select: { id: true, name: true, email: true } }, + rentedBy: { select: { id: true, name: true } }, }, }) }) @@ -122,6 +146,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: message }, { status: 400 }) } + logAudit({ + action: AuditAction.INVENTORY_RENTAL_CREATE, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "ToolRental", + targetId: rental.id, + metadata: { toolId, teamId: rental.teamId, floor, location: safeLocation }, + }).catch(() => {}) + notifyRental(rental.teamId, rental.tool.name, "Tool Rented") pushSSE(rental.teamId, { type: "rental_created", data: rental }) diff --git a/app/api/inventory/sse/route.ts b/app/api/inventory/sse/route.ts index cb07fe5a..c629030b 100644 --- a/app/api/inventory/sse/route.ts +++ b/app/api/inventory/sse/route.ts @@ -40,7 +40,12 @@ export async function GET(request: NextRequest) { const stream = new ReadableStream({ start(controller) { - registerConnection(teamId, controller) + const accepted = registerConnection(teamId, controller) + if (!accepted) { + controller.enqueue(encoder.encode("data: {\"type\":\"error\",\"data\":\"Too many connections\"}\n\n")) + controller.close() + return + } controller.enqueue(KEEPALIVE) diff --git a/app/api/inventory/teams/[id]/join/route.ts b/app/api/inventory/teams/[id]/join/route.ts index 3dc75486..90177bd8 100644 --- a/app/api/inventory/teams/[id]/join/route.ts +++ b/app/api/inventory/teams/[id]/join/route.ts @@ -1,18 +1,17 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" -import { auth } from "@/lib/auth" -import { headers } from "next/headers" +import { requireInventoryAccess } from "@/lib/inventory/access" import { MAX_TEAM_SIZE } from "@/lib/inventory/config" import { syncTeamChannel } from "@/lib/inventory/team-channel" +import { logAudit, AuditAction } from "@/lib/audit" export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const { id } = await params @@ -47,6 +46,15 @@ export async function POST( data: { teamId: id }, }) + logAudit({ + action: AuditAction.INVENTORY_TEAM_JOIN, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + metadata: { teamName: team.name }, + }).catch(() => {}) + syncTeamChannel(id).catch(() => {}) return NextResponse.json({ success: true }) diff --git a/app/api/inventory/teams/[id]/leave/route.ts b/app/api/inventory/teams/[id]/leave/route.ts index d805170e..5900bf9a 100644 --- a/app/api/inventory/teams/[id]/leave/route.ts +++ b/app/api/inventory/teams/[id]/leave/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" import { removeFromTeam } from "@/lib/inventory/teams" +import { logAudit, AuditAction } from "@/lib/audit" export async function POST( request: Request, @@ -26,5 +27,13 @@ export async function POST( await removeFromTeam(session.user.id, id) + logAudit({ + action: AuditAction.INVENTORY_TEAM_LEAVE, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + }).catch(() => {}) + return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/[id]/members/[userId]/route.ts b/app/api/inventory/teams/[id]/members/[userId]/route.ts index 10148fd1..fea7bb6b 100644 --- a/app/api/inventory/teams/[id]/members/[userId]/route.ts +++ b/app/api/inventory/teams/[id]/members/[userId]/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" import { removeFromTeam } from "@/lib/inventory/teams" +import { logAudit, AuditAction } from "@/lib/audit" export async function DELETE( request: Request, @@ -28,13 +29,15 @@ export async function DELETE( return NextResponse.json({ error: "Team is locked" }, { status: 403 }) } - // Verify caller is a member of this team or an admin - const callerIsMember = team.members.some((m) => m.id === session.user.id) + // Only admins can remove other members (non-self removal) + // Users can remove themselves via the /leave endpoint const callerIsAdmin = await prisma.userRole.findFirst({ where: { userId: session.user.id, role: "ADMIN" }, }) - if (!callerIsMember && !callerIsAdmin) { - return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) + const isSelfRemoval = session.user.id === userId + + if (!isSelfRemoval && !callerIsAdmin) { + return NextResponse.json({ error: "Only admins can remove other team members" }, { status: 403 }) } const targetIsMember = team.members.some((m) => m.id === userId) @@ -44,5 +47,14 @@ export async function DELETE( await removeFromTeam(userId, id) + logAudit({ + action: isSelfRemoval ? AuditAction.INVENTORY_TEAM_LEAVE : AuditAction.INVENTORY_TEAM_KICK_MEMBER, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + metadata: { removedUserId: userId }, + }).catch(() => {}) + return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/[id]/members/route.ts b/app/api/inventory/teams/[id]/members/route.ts index 507a203f..4422938a 100644 --- a/app/api/inventory/teams/[id]/members/route.ts +++ b/app/api/inventory/teams/[id]/members/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth" import { headers } from "next/headers" import { MAX_TEAM_SIZE } from "@/lib/inventory/config" import { syncTeamChannel } from "@/lib/inventory/team-channel" +import { logAudit, AuditAction } from "@/lib/audit" export async function POST( request: Request, @@ -22,6 +23,19 @@ export async function POST( return NextResponse.json({ error: "slackId is required" }, { status: 400 }) } + // Verify caller is a member of this team or an admin + const caller = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + const callerIsAdmin = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: "ADMIN" }, + }) + + if (caller?.teamId !== id && !callerIsAdmin) { + return NextResponse.json({ error: "You must be a member of this team to add members" }, { status: 403 }) + } + const team = await prisma.team.findUnique({ where: { id }, include: { _count: { select: { members: true } } }, @@ -57,6 +71,15 @@ export async function POST( data: { teamId: id }, }) + logAudit({ + action: AuditAction.INVENTORY_TEAM_ADD_MEMBER, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + metadata: { addedUserId: targetUser.id }, + }).catch(() => {}) + syncTeamChannel(id).catch(() => {}) return NextResponse.json({ success: true }) diff --git a/app/api/inventory/teams/[id]/route.ts b/app/api/inventory/teams/[id]/route.ts index a09908eb..2b534d08 100644 --- a/app/api/inventory/teams/[id]/route.ts +++ b/app/api/inventory/teams/[id]/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" import { auth } from "@/lib/auth" import { headers } from "next/headers" +import { logAudit, AuditAction } from "@/lib/audit" +import { sanitizeName } from "@/lib/inventory/validation" export async function GET( request: Request, @@ -14,6 +16,19 @@ export async function GET( const { id } = await params + // Only allow members of the team or admins to view team details + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + const isAdmin = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: "ADMIN" }, + }) + + if (user?.teamId !== id && !isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const team = await prisma.team.findUnique({ where: { id }, include: { @@ -52,7 +67,10 @@ export async function PATCH( return NextResponse.json({ error: "Team name is required" }, { status: 400 }) } - const trimmedName = name.trim() + const safeName = sanitizeName(name) + if (safeName.length === 0) { + return NextResponse.json({ error: "Team name is required" }, { status: 400 }) + } const team = await prisma.team.findUnique({ where: { id }, @@ -72,16 +90,25 @@ export async function PATCH( return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) } - const existing = await prisma.team.findUnique({ where: { name: trimmedName } }) + const existing = await prisma.team.findUnique({ where: { name: safeName } }) if (existing && existing.id !== id) { return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) } const updated = await prisma.team.update({ where: { id }, - data: { name: trimmedName }, + data: { name: safeName }, }) + logAudit({ + action: AuditAction.INVENTORY_TEAM_RENAME, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + metadata: { oldName: team.name, newName: safeName }, + }).catch(() => {}) + return NextResponse.json(updated) } @@ -117,6 +144,20 @@ export async function DELETE( ) } + // Block delete if there are active orders or rentals + const activeOrders = await prisma.order.count({ + where: { teamId: id, status: { notIn: ["COMPLETED", "CANCELLED"] } }, + }) + const activeRentals = await prisma.toolRental.count({ + where: { teamId: id, status: "CHECKED_OUT" }, + }) + if (activeOrders > 0 || activeRentals > 0) { + return NextResponse.json( + { error: "Cannot delete a team with active orders or rentals" }, + { status: 400 } + ) + } + await prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: session.user.id }, @@ -126,5 +167,14 @@ export async function DELETE( await tx.team.delete({ where: { id } }) }) + logAudit({ + action: AuditAction.INVENTORY_TEAM_DELETE, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: id, + metadata: { name: team.name }, + }).catch(() => {}) + return NextResponse.json({ success: true }) } diff --git a/app/api/inventory/teams/route.ts b/app/api/inventory/teams/route.ts index 5289efb6..b53e4edb 100644 --- a/app/api/inventory/teams/route.ts +++ b/app/api/inventory/teams/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from "next/server" import prisma from "@/lib/prisma" -import { auth } from "@/lib/auth" -import { headers } from "next/headers" +import { requireInventoryAccess } from "@/lib/inventory/access" import { syncTeamChannel } from "@/lib/inventory/team-channel" +import { logAudit, AuditAction } from "@/lib/audit" +import { sanitizeName } from "@/lib/inventory/validation" export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error const teams = await prisma.team.findMany({ select: { @@ -25,10 +24,9 @@ export async function GET() { } export async function POST(request: Request) { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + const result = await requireInventoryAccess() + if ("error" in result) return result.error + const { session } = result const body = await request.json() const { name } = body @@ -37,9 +35,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Team name is required" }, { status: 400 }) } - const trimmedName = name.trim() + const safeName = sanitizeName(name) + if (safeName.length === 0) { + return NextResponse.json({ error: "Team name is required" }, { status: 400 }) + } - const existing = await prisma.team.findUnique({ where: { name: trimmedName } }) + const existing = await prisma.team.findUnique({ where: { name: safeName } }) if (existing) { return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) } @@ -55,7 +56,7 @@ export async function POST(request: Request) { const team = await prisma.$transaction(async (tx) => { const created = await tx.team.create({ - data: { name: trimmedName }, + data: { name: safeName }, }) await tx.user.update({ @@ -66,6 +67,15 @@ export async function POST(request: Request) { return created }) + logAudit({ + action: AuditAction.INVENTORY_TEAM_CREATE, + actorId: session.user.id, + actorEmail: session.user.email, + targetType: "Team", + targetId: team.id, + metadata: { name: safeName }, + }).catch(() => {}) + syncTeamChannel(team.id).catch(() => {}) return NextResponse.json(team, { status: 201 }) diff --git a/app/api/inventory/tools/route.ts b/app/api/inventory/tools/route.ts index 14a99ddb..bd8df09c 100644 --- a/app/api/inventory/tools/route.ts +++ b/app/api/inventory/tools/route.ts @@ -1,11 +1,10 @@ -import { auth } from "@/lib/auth" -import { headers } from "next/headers" import { NextResponse } from "next/server" import prisma from "@/lib/prisma" +import { requireInventoryAccess } from "@/lib/inventory/access" export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }) - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const result = await requireInventoryAccess() + if ("error" in result) return result.error const tools = await prisma.tool.findMany({ orderBy: { name: "asc" } }) return NextResponse.json(tools) diff --git a/app/api/shop/purchase/route.ts b/app/api/shop/purchase/route.ts index 1726e2f8..4e525aa5 100644 --- a/app/api/shop/purchase/route.ts +++ b/app/api/shop/purchase/route.ts @@ -146,6 +146,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Quantity exceeds maximum allowed" }, { status: 400 }) } } - throw error + console.error("Shop purchase error:", error) + return NextResponse.json({ error: "Purchase failed" }, { status: 500 }) } } diff --git a/lib/inventory/sse.ts b/lib/inventory/sse.ts index c8be8911..c8b9778e 100644 --- a/lib/inventory/sse.ts +++ b/lib/inventory/sse.ts @@ -1,14 +1,31 @@ const connections = new Map>() const encoder = new TextEncoder() +const MAX_CONNECTIONS_PER_KEY = 50 +const MAX_TOTAL_CONNECTIONS = 500 + +function totalConnectionCount(): number { + let count = 0 + for (const set of connections.values()) count += set.size + return count +} + export function registerConnection( key: string, controller: ReadableStreamDefaultController -) { +): boolean { + if (totalConnectionCount() >= MAX_TOTAL_CONNECTIONS) { + return false + } if (!connections.has(key)) { connections.set(key, new Set()) } - connections.get(key)!.add(controller) + const set = connections.get(key)! + if (set.size >= MAX_CONNECTIONS_PER_KEY) { + return false + } + set.add(controller) + return true } export function removeConnection( diff --git a/lib/inventory/validation.ts b/lib/inventory/validation.ts new file mode 100644 index 00000000..b6b8f395 --- /dev/null +++ b/lib/inventory/validation.ts @@ -0,0 +1,63 @@ +import { sanitize } from "@/lib/sanitize" +import { VENUE_FLOORS } from "./config" + +const MAX_NAME_LENGTH = 100 +const MAX_LOCATION_LENGTH = 200 +const MAX_DESCRIPTION_LENGTH = 2000 + +export function sanitizeName(input: string): string { + return sanitize(input).trim().slice(0, MAX_NAME_LENGTH) +} + +export function sanitizeLocation(input: string): string { + return sanitize(input).trim().slice(0, MAX_LOCATION_LENGTH) +} + +export function sanitizeDescription(input: string): string { + return sanitize(input).trim().slice(0, MAX_DESCRIPTION_LENGTH) +} + +export function validateFloor(floor: unknown): floor is number { + return ( + typeof floor === "number" && + Number.isInteger(floor) && + floor >= 1 && + floor <= VENUE_FLOORS + ) +} + +export function validatePositiveInt(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value > 0 && + Number.isFinite(value) + ) +} + +export function validateNonNegativeInt(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + Number.isFinite(value) + ) +} + +/** + * Validate that a URL is an HTTPS URL (or null/empty). + * Rejects javascript:, data:, and non-https schemes. + */ +export function validateImageUrl(url: unknown): string | null { + if (url === null || url === undefined || url === "") return null + if (typeof url !== "string") return null + const trimmed = url.trim() + if (trimmed === "") return null + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== "https:") return null + return trimmed + } catch { + return null + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45dac373..dabb44c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -462,7 +462,7 @@ enum AuditAction { USER_SUBMIT_PROJECT USER_UNSUBMIT_PROJECT - // Inventory actions + // Inventory admin actions INVENTORY_IMPORT INVENTORY_ORDER_STATUS_UPDATE INVENTORY_ORDER_CANCEL @@ -475,6 +475,19 @@ enum AuditAction { INVENTORY_TOOL_CREATE INVENTORY_TOOL_UPDATE INVENTORY_TOOL_DELETE + INVENTORY_BADGE_ASSIGN + + // Inventory user actions + INVENTORY_ORDER_PLACE + INVENTORY_ORDER_CANCEL_USER + INVENTORY_RENTAL_CREATE + INVENTORY_TEAM_CREATE + INVENTORY_TEAM_JOIN + INVENTORY_TEAM_LEAVE + INVENTORY_TEAM_DELETE + INVENTORY_TEAM_RENAME + INVENTORY_TEAM_KICK_MEMBER + INVENTORY_TEAM_ADD_MEMBER } model AuditLog { From 1f6fef476e2e7b0fcb10fafb91634da645845a35 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:10:27 -0700 Subject: [PATCH 05/14] Add migration for inventory audit action enum values Co-Authored-By: Claude Opus 4.6 --- .../migration.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 prisma/migrations/20260328231018_add_inventory_audit_actions/migration.sql diff --git a/prisma/migrations/20260328231018_add_inventory_audit_actions/migration.sql b/prisma/migrations/20260328231018_add_inventory_audit_actions/migration.sql new file mode 100644 index 00000000..d647360d --- /dev/null +++ b/prisma/migrations/20260328231018_add_inventory_audit_actions/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the column `slackPinnedTs` on the `team` table. All the data in the column will be lost. + +*/ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_BADGE_ASSIGN'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ORDER_PLACE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_ORDER_CANCEL_USER'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_RENTAL_CREATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_CREATE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_JOIN'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_LEAVE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_DELETE'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_RENAME'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_KICK_MEMBER'; +ALTER TYPE "AuditAction" ADD VALUE 'INVENTORY_TEAM_ADD_MEMBER'; + +-- AlterTable +ALTER TABLE "team" DROP COLUMN "slackPinnedTs", +ADD COLUMN "slackWelcomeTs" TEXT; From 8d53ebd93a11f31f6d599dac9539a398ac704e44 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:39:20 -0700 Subject: [PATCH 06/14] Fix inventory prerender error with loading boundary --- app/inventory/loading.tsx | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/inventory/loading.tsx diff --git a/app/inventory/loading.tsx b/app/inventory/loading.tsx new file mode 100644 index 00000000..a7856f75 --- /dev/null +++ b/app/inventory/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
+
+
+ ); +} From d261f908acf2b0bb85bb3889fa48c4cef2ace340 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:48:42 -0700 Subject: [PATCH 07/14] Fix Copilot review issues: inventory validation, notifications, team permissions --- app/api/inventory/admin/items/import/route.ts | 3 +++ app/api/inventory/admin/items/route.ts | 2 +- app/api/inventory/admin/orders/[id]/route.ts | 2 ++ app/api/inventory/orders/route.ts | 8 +++++++- app/api/inventory/teams/[id]/leave/route.ts | 18 ++++++++++++++---- .../teams/[id]/members/[userId]/route.ts | 11 ++++------- app/dashboard/layout.tsx | 2 +- lib/inventory/teams.ts | 9 ++++++++- 8 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts index 8c090c4e..8f47d663 100644 --- a/app/api/inventory/admin/items/import/route.ts +++ b/app/api/inventory/admin/items/import/route.ts @@ -37,6 +37,9 @@ export async function POST(request: Request) { if (!Number.isFinite(stock) || !Number.isFinite(maxPerTeam)) { throw new Error("Invalid numeric values in import data") } + if (maxPerTeam <= 0) { + throw new Error(`maxPerTeam must be a positive integer for item "${sanitizeName(String(item.name ?? ""))}"`) + } return { name: sanitizeName(String(item.name ?? "")), description: item.description ? sanitizeDescription(String(item.description)) : null, diff --git a/app/api/inventory/admin/items/route.ts b/app/api/inventory/admin/items/route.ts index 9dbaa058..6d8bf75d 100644 --- a/app/api/inventory/admin/items/route.ts +++ b/app/api/inventory/admin/items/route.ts @@ -28,7 +28,7 @@ export async function POST(request: Request) { if (!validateNonNegativeInt(stock) || !validatePositiveInt(maxPerTeam)) { return NextResponse.json( - { error: "stock and maxPerTeam must be valid positive integers" }, + { error: "stock must be a non-negative integer and maxPerTeam must be a positive integer" }, { status: 400 } ) } diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts index 6542b8e2..37cbf4bf 100644 --- a/app/api/inventory/admin/orders/[id]/route.ts +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -91,6 +91,8 @@ export async function PATCH( notifyOrderUpdate(order.teamId, order, "Ready for Pickup") } else if (status === "IN_PROGRESS") { notifyOrderUpdate(order.teamId, order, "In Progress") + } else if (status === "COMPLETED") { + notifyOrderUpdate(order.teamId, order, "Completed") } await logAdminAction( diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts index 7b7c091d..0993dccf 100644 --- a/app/api/inventory/orders/route.ts +++ b/app/api/inventory/orders/route.ts @@ -117,7 +117,13 @@ export async function POST(request: Request) { } } + // Merge duplicate itemIds by summing quantities + const mergedItems = new Map() for (const { itemId, quantity } of items) { + mergedItems.set(itemId, (mergedItems.get(itemId) ?? 0) + quantity) + } + + for (const [itemId, quantity] of mergedItems) { const item = await tx.item.findUnique({ where: { id: itemId } }) if (!item) { throw new Error("Item not found") @@ -154,7 +160,7 @@ export async function POST(request: Request) { floor, location: safeLocation, items: { - create: items.map(({ itemId, quantity }) => ({ + create: Array.from(mergedItems, ([itemId, quantity]) => ({ itemId, quantity, })), diff --git a/app/api/inventory/teams/[id]/leave/route.ts b/app/api/inventory/teams/[id]/leave/route.ts index 5900bf9a..a1a3eb81 100644 --- a/app/api/inventory/teams/[id]/leave/route.ts +++ b/app/api/inventory/teams/[id]/leave/route.ts @@ -16,15 +16,25 @@ export async function POST( const { id } = await params - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { teamId: true }, - }) + const [user, team] = await Promise.all([ + prisma.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }), + prisma.team.findUnique({ + where: { id }, + select: { locked: true }, + }), + ]) if (user?.teamId !== id) { return NextResponse.json({ error: "You are not a member of this team" }, { status: 400 }) } + if (team?.locked) { + return NextResponse.json({ error: "Team is locked and cannot be modified" }, { status: 400 }) + } + await removeFromTeam(session.user.id, id) logAudit({ diff --git a/app/api/inventory/teams/[id]/members/[userId]/route.ts b/app/api/inventory/teams/[id]/members/[userId]/route.ts index fea7bb6b..1bf57db7 100644 --- a/app/api/inventory/teams/[id]/members/[userId]/route.ts +++ b/app/api/inventory/teams/[id]/members/[userId]/route.ts @@ -29,15 +29,12 @@ export async function DELETE( return NextResponse.json({ error: "Team is locked" }, { status: 403 }) } - // Only admins can remove other members (non-self removal) - // Users can remove themselves via the /leave endpoint - const callerIsAdmin = await prisma.userRole.findFirst({ - where: { userId: session.user.id, role: "ADMIN" }, - }) + // Any current team member can remove other members (self-removal uses /leave) + const callerIsMember = team.members.some((m) => m.id === session.user.id) const isSelfRemoval = session.user.id === userId - if (!isSelfRemoval && !callerIsAdmin) { - return NextResponse.json({ error: "Only admins can remove other team members" }, { status: 403 }) + if (!callerIsMember) { + return NextResponse.json({ error: "Only team members can remove users from this team" }, { status: 403 }) } const targetIsMember = team.members.some((m) => m.id === userId) diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 7913c589..32f549c5 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -45,7 +45,7 @@ export default function DashboardLayout({ fetch('/api/inventory/access') .then(res => res.ok ? res.json() : null) .then(data => { - if (data?.enabled || data?.isAdmin) setInventoryEnabled(true); + if (data?.allowed || data?.isAdmin) setInventoryEnabled(true); }) .catch(() => {}); } diff --git a/lib/inventory/teams.ts b/lib/inventory/teams.ts index 8192b9d3..0515d993 100644 --- a/lib/inventory/teams.ts +++ b/lib/inventory/teams.ts @@ -15,7 +15,14 @@ export async function removeFromTeam(userId: string, teamId: string) { const count = await tx.user.count({ where: { teamId } }) if (count === 0) { - await tx.team.delete({ where: { id: teamId } }) + // Only delete team if no orders or rentals reference it + const [orderCount, rentalCount] = await Promise.all([ + tx.order.count({ where: { teamId } }), + tx.toolRental.count({ where: { teamId } }), + ]) + if (orderCount === 0 && rentalCount === 0) { + await tx.team.delete({ where: { id: teamId } }) + } } return count }) From 3dd18f6a158cb1286d2daa58a15e809263044ded Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:06:16 -0700 Subject: [PATCH 08/14] Fix Copilot review round 2: race conditions, validation, cart limits, partial checkout --- app/api/inventory/admin/items/route.ts | 9 +++++- app/api/inventory/admin/orders/[id]/route.ts | 30 ++++++++++--------- app/api/inventory/admin/tools/route.ts | 7 ++++- app/api/inventory/orders/[id]/cancel/route.ts | 18 +++++++++-- app/api/inventory/rentals/route.ts | 20 +++++++------ app/components/inventory/ItemCard.tsx | 5 ++-- app/inventory/page.tsx | 29 ++++++++++++++---- instrumentation.ts | 6 ++-- lib/inventory/useInventorySSE.ts | 4 ++- 9 files changed, 88 insertions(+), 40 deletions(-) diff --git a/app/api/inventory/admin/items/route.ts b/app/api/inventory/admin/items/route.ts index 6d8bf75d..8eaf4167 100644 --- a/app/api/inventory/admin/items/route.ts +++ b/app/api/inventory/admin/items/route.ts @@ -34,6 +34,13 @@ export async function POST(request: Request) { } const safeName = sanitizeName(name) + const safeCategory = sanitizeName(category) + if (!safeName || !safeCategory) { + return NextResponse.json( + { error: "Name and category are required" }, + { status: 400 } + ) + } const safeDescription = description ? sanitizeDescription(description) : null const safeImageUrl = validateImageUrl(imageUrl) @@ -43,7 +50,7 @@ export async function POST(request: Request) { description: safeDescription, imageUrl: safeImageUrl, stock, - category: sanitizeName(category), + category: safeCategory, maxPerTeam, }, }) diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts index 37cbf4bf..f1ee679c 100644 --- a/app/api/inventory/admin/orders/[id]/route.ts +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -29,21 +29,17 @@ export async function PATCH( // If cancelling, restore stock if (status === "CANCELLED") { - const existing = await prisma.order.findUnique({ - where: { id }, - include: { items: true }, - }) - if (!existing) { - return NextResponse.json({ error: "Order not found" }, { status: 404 }) - } - if (existing.status === "READY" || existing.status === "COMPLETED" || existing.status === "CANCELLED") { - return NextResponse.json( - { error: "Cannot cancel an order that is already ready, completed, or cancelled" }, - { status: 400 } - ) - } + // Validate and cancel inside transaction to prevent race conditions + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.order.findUnique({ + where: { id }, + include: { items: true }, + }) + if (!existing) return { error: "Order not found", status: 404 } as const + if (existing.status === "READY" || existing.status === "COMPLETED" || existing.status === "CANCELLED") { + return { error: "Cannot cancel an order that is already ready, completed, or cancelled", status: 400 } as const + } - const order = await prisma.$transaction(async (tx) => { const updated = await tx.order.update({ where: { id }, data: { status }, @@ -62,6 +58,12 @@ export async function PATCH( return updated }) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + const order = result + notifyOrderUpdate(order.teamId, order, "Cancelled") await logAdminAction( diff --git a/app/api/inventory/admin/tools/route.ts b/app/api/inventory/admin/tools/route.ts index 7d6a9e70..c9dcc0f4 100644 --- a/app/api/inventory/admin/tools/route.ts +++ b/app/api/inventory/admin/tools/route.ts @@ -29,9 +29,14 @@ export async function POST(request: Request) { const { sanitizeName, sanitizeDescription, validateImageUrl } = await import("@/lib/inventory/validation") + const safeName = sanitizeName(name) + if (!safeName) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }) + } + const tool = await prisma.tool.create({ data: { - name: sanitizeName(name), + name: safeName, description: description ? sanitizeDescription(description) : null, imageUrl: validateImageUrl(imageUrl), }, diff --git a/app/api/inventory/orders/[id]/cancel/route.ts b/app/api/inventory/orders/[id]/cancel/route.ts index 556d5615..f6d9d860 100644 --- a/app/api/inventory/orders/[id]/cancel/route.ts +++ b/app/api/inventory/orders/[id]/cancel/route.ts @@ -47,22 +47,34 @@ export async function POST( ) } - // Cancel and restore stock - await prisma.$transaction(async (tx) => { + // Cancel and restore stock (re-check status inside transaction to prevent race) + const cancelled = await prisma.$transaction(async (tx) => { + const current = await tx.order.findUnique({ where: { id }, select: { status: true } }) + if (!current || current.status === "READY" || current.status === "COMPLETED" || current.status === "CANCELLED") { + return false + } + await tx.order.update({ where: { id }, data: { status: "CANCELLED" }, }) - // Restore stock for each item for (const item of order.items) { await tx.item.update({ where: { id: item.itemId }, data: { stock: { increment: item.quantity } }, }) } + return true }) + if (!cancelled) { + return NextResponse.json( + { error: "Cannot cancel an order that is already ready, completed, or cancelled" }, + { status: 400 } + ) + } + logAudit({ action: AuditAction.INVENTORY_ORDER_CANCEL_USER, actorId: session.user.id, diff --git a/app/api/inventory/rentals/route.ts b/app/api/inventory/rentals/route.ts index d62ff2a7..1c70d2c4 100644 --- a/app/api/inventory/rentals/route.ts +++ b/app/api/inventory/rentals/route.ts @@ -95,11 +95,18 @@ export async function POST(request: Request) { throw new Error("You must be on a team to rent a tool") } - const tool = await tx.tool.findUnique({ where: { id: toolId } }) - if (!tool) { - throw new Error("Tool not found") + if (user.team.locked) { + throw new Error("Your team is locked and cannot rent tools") } - if (!tool.available) { + + // Conditionally mark tool as unavailable only if currently available (prevents double-rent race) + const updated = await tx.tool.updateMany({ + where: { id: toolId, available: true }, + data: { available: false }, + }) + if (updated.count === 0) { + const tool = await tx.tool.findUnique({ where: { id: toolId } }) + if (!tool) throw new Error("Tool not found") throw new Error("Tool is not available") } @@ -116,11 +123,6 @@ export async function POST(request: Request) { ) } - await tx.tool.update({ - where: { id: toolId }, - data: { available: false }, - }) - const dueAt = TOOL_RENTAL_TIME_LIMIT_MINUTES > 0 ? new Date(Date.now() + TOOL_RENTAL_TIME_LIMIT_MINUTES * 60 * 1000) diff --git a/app/components/inventory/ItemCard.tsx b/app/components/inventory/ItemCard.tsx index 59337b94..1f7fb5da 100644 --- a/app/components/inventory/ItemCard.tsx +++ b/app/components/inventory/ItemCard.tsx @@ -13,11 +13,12 @@ interface ItemCardProps { maxPerTeam: number; teamUsed?: number; }; + cartQuantity?: number; onAdd: (itemId: string, quantity: number) => void; } -export function ItemCard({ item, onAdd }: ItemCardProps) { - const remaining = item.maxPerTeam - (item.teamUsed ?? 0); +export function ItemCard({ item, cartQuantity = 0, onAdd }: ItemCardProps) { + const remaining = item.maxPerTeam - (item.teamUsed ?? 0) - cartQuantity; const canAdd = remaining > 0 && item.stock > 0; const maxQuantity = Math.min(remaining, item.stock); const [quantity, setQuantity] = useState(1); diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx index a52a1710..255d3cd0 100644 --- a/app/inventory/page.tsx +++ b/app/inventory/page.tsx @@ -219,8 +219,9 @@ export default function BrowsePage() { } // Create tool rentals + const failedRentals: string[] = []; if (cartTools.length > 0) { - const rentalResults = await Promise.all( + const rentalOutcomes = await Promise.allSettled( cartTools.map(async (tool) => { const res = await fetch('/api/inventory/rentals', { method: 'POST', @@ -231,16 +232,32 @@ export default function BrowsePage() { const data = await res.json(); throw new Error(data.error || `Failed to rent ${tool.name}`); } - return `${tool.name} rented`; + return { name: tool.name, toolId: tool.toolId }; }) ); - results.push(...rentalResults); + for (let i = 0; i < rentalOutcomes.length; i++) { + const outcome = rentalOutcomes[i]; + if (outcome.status === 'fulfilled') { + results.push(`${outcome.value.name} rented`); + } else { + failedRentals.push(cartTools[i].name); + } + } } setCart([]); - setCartTools([]); + setCartTools(prev => prev.filter(t => failedRentals.includes(t.name))); setCheckoutOpen(false); - setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); + + if (failedRentals.length > 0 && results.length > 0) { + setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); + setError(`Failed to rent: ${failedRentals.join(', ')}`); + } else if (failedRentals.length > 0) { + setError(`Failed to rent: ${failedRentals.join(', ')}`); + } else { + setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); + } + fetchItems(); fetchTools(); fetchRentals(); @@ -315,7 +332,7 @@ export default function BrowsePage() { ) : (
{filteredItems.map(item => ( - + c.itemId === item.id)?.quantity ?? 0} onAdd={addToCart} /> ))}
)} diff --git a/instrumentation.ts b/instrumentation.ts index 3a8620d4..3adcc2f8 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -6,15 +6,15 @@ export async function register() { const { startOverdueChecker } = await import("./lib/inventory/overdue-checker"); startOverdueChecker(); - // Backfill Slack group DMs for teams that don't have one yet + // Backfill Slack group DMs for teams that don't have one yet (batched to avoid rate limits) const { syncTeamChannel } = await import("./lib/inventory/team-channel"); const prisma = (await import("./lib/prisma")).default; prisma.team.findMany({ where: { slackChannelId: null }, select: { id: true }, - }).then((teams) => { + }).then(async (teams) => { for (const team of teams) { - syncTeamChannel(team.id).catch(() => {}); + await syncTeamChannel(team.id).catch(() => {}); } }).catch(() => {}); } diff --git a/lib/inventory/useInventorySSE.ts b/lib/inventory/useInventorySSE.ts index f22a9f30..930bd5e2 100644 --- a/lib/inventory/useInventorySSE.ts +++ b/lib/inventory/useInventorySSE.ts @@ -29,7 +29,9 @@ export function useInventorySSE(teamId: string | null) { es.onerror = () => { es.close() eventSourceRef.current = null - // Reconnect after 3 seconds + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } reconnectTimeoutRef.current = setTimeout(connect, 3000) } }, [teamId]) From 681ad9b042584a8b1c1bb071d7e27a8429a64f6a Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:17:42 -0700 Subject: [PATCH 09/14] Fix Copilot review round 3: lookup type mismatch, cart tracking, config NaN guard, import validation --- app/api/inventory/admin/items/import/route.ts | 9 ++++++-- app/api/inventory/admin/orders/[id]/route.ts | 5 +++++ app/inventory/admin/page.tsx | 4 ++-- app/inventory/page.tsx | 20 ++++++++++-------- lib/inventory/config.ts | 21 ++++++++++++------- 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts index 8f47d663..ee8612f4 100644 --- a/app/api/inventory/admin/items/import/route.ts +++ b/app/api/inventory/admin/items/import/route.ts @@ -40,12 +40,17 @@ export async function POST(request: Request) { if (maxPerTeam <= 0) { throw new Error(`maxPerTeam must be a positive integer for item "${sanitizeName(String(item.name ?? ""))}"`) } + const name = sanitizeName(String(item.name ?? "")) + const category = sanitizeName(String(item.category ?? "")) + if (!name || !category) { + throw new Error(`Name and category are required for all items`) + } return { - name: sanitizeName(String(item.name ?? "")), + name, description: item.description ? sanitizeDescription(String(item.description)) : null, imageUrl: validateImageUrl(item.imageUrl ?? item.image_url), stock, - category: sanitizeName(String(item.category ?? "")), + category, maxPerTeam, } }) diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts index f1ee679c..b7399071 100644 --- a/app/api/inventory/admin/orders/[id]/route.ts +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -79,6 +79,11 @@ export async function PATCH( return NextResponse.json(order) } + const existing = await prisma.order.findUnique({ where: { id }, select: { id: true } }) + if (!existing) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }) + } + const order = await prisma.order.update({ where: { id }, data: { status }, diff --git a/app/inventory/admin/page.tsx b/app/inventory/admin/page.tsx index 3329970a..817a905e 100644 --- a/app/inventory/admin/page.tsx +++ b/app/inventory/admin/page.tsx @@ -42,7 +42,7 @@ interface LookupResult { }; team?: { name: string }; activeOrder?: Order; - activeRentals?: { id: string; toolName: string; checkedOutAt: string }[]; + activeRentals?: { id: string; tool: { name: string }; createdAt: string }[]; } const STATUS_COLORS: Record = { @@ -269,7 +269,7 @@ export default function AdminActivityPage() {

Active Rentals

    - {lookupResult.activeRentals.map((r) =>
  • {r.toolName}
  • )} + {lookupResult.activeRentals.map((r) =>
  • {r.tool.name}
  • )}
)} diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx index 255d3cd0..5768f03e 100644 --- a/app/inventory/page.tsx +++ b/app/inventory/page.tsx @@ -219,7 +219,8 @@ export default function BrowsePage() { } // Create tool rentals - const failedRentals: string[] = []; + const failedRentalIds: Set = new Set(); + const failedRentalNames: string[] = []; if (cartTools.length > 0) { const rentalOutcomes = await Promise.allSettled( cartTools.map(async (tool) => { @@ -240,20 +241,21 @@ export default function BrowsePage() { if (outcome.status === 'fulfilled') { results.push(`${outcome.value.name} rented`); } else { - failedRentals.push(cartTools[i].name); + failedRentalIds.add(cartTools[i].toolId); + failedRentalNames.push(cartTools[i].name); } } } setCart([]); - setCartTools(prev => prev.filter(t => failedRentals.includes(t.name))); + setCartTools(prev => prev.filter(t => failedRentalIds.has(t.toolId))); setCheckoutOpen(false); - if (failedRentals.length > 0 && results.length > 0) { + if (failedRentalNames.length > 0 && results.length > 0) { setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); - setError(`Failed to rent: ${failedRentals.join(', ')}`); - } else if (failedRentals.length > 0) { - setError(`Failed to rent: ${failedRentals.join(', ')}`); + setError(`Failed to rent: ${failedRentalNames.join(', ')}`); + } else if (failedRentalNames.length > 0) { + setError(`Failed to rent: ${failedRentalNames.join(', ')}`); } else { setSuccessMessage(results.join('. ') + '. Check Team Home for status.'); } @@ -395,7 +397,7 @@ export default function BrowsePage() { onRemoveItem={removeFromCart} onRemoveTool={removeToolFromCart} onCheckout={() => { setError(null); setCheckoutOpen(true); }} - disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0 && cartTools.length === 0} + disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0} hasActiveOrder={!allowMultipleOrders && hasActiveOrder} />
@@ -426,7 +428,7 @@ export default function BrowsePage() { setCartOpen(false); setError(null); setCheckoutOpen(true); }} - disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0 && cartTools.length === 0} + disabled={!allowMultipleOrders && hasActiveOrder && cart.length > 0} hasActiveOrder={!allowMultipleOrders && hasActiveOrder} />
diff --git a/lib/inventory/config.ts b/lib/inventory/config.ts index 007d0aca..691c5b05 100644 --- a/lib/inventory/config.ts +++ b/lib/inventory/config.ts @@ -1,11 +1,16 @@ -export const MIN_BITS_FOR_INVENTORY = parseInt( - process.env.MIN_BITS_FOR_INVENTORY ?? "0" +function safeInt(value: string | undefined, fallback: number): number { + const parsed = parseInt(value ?? String(fallback)) + return Number.isFinite(parsed) ? parsed : fallback +} + +export const MIN_BITS_FOR_INVENTORY = safeInt( + process.env.MIN_BITS_FOR_INVENTORY, 0 ) -export const VENUE_FLOORS = parseInt(process.env.VENUE_FLOORS ?? "3") -export const TOOL_RENTAL_TIME_LIMIT_MINUTES = parseInt( - process.env.TOOL_RENTAL_TIME_LIMIT_MINUTES ?? "0" +export const VENUE_FLOORS = safeInt(process.env.VENUE_FLOORS, 3) +export const TOOL_RENTAL_TIME_LIMIT_MINUTES = safeInt( + process.env.TOOL_RENTAL_TIME_LIMIT_MINUTES, 0 ) -export const MAX_TEAM_SIZE = parseInt(process.env.MAX_TEAM_SIZE ?? "4") -export const MAX_CONCURRENT_RENTALS = parseInt( - process.env.MAX_CONCURRENT_RENTALS ?? "2" +export const MAX_TEAM_SIZE = safeInt(process.env.MAX_TEAM_SIZE, 4) +export const MAX_CONCURRENT_RENTALS = safeInt( + process.env.MAX_CONCURRENT_RENTALS, 2 ) From 399124258586e816af3f2565d1ff3eac9e57b133 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:30:11 -0700 Subject: [PATCH 10/14] Fix Copilot review round 4: SSE cleanup, import error handling, loading state, Slack escaping --- app/api/inventory/admin/items/import/route.ts | 54 +++++++++++-------- app/inventory/page.tsx | 2 +- lib/inventory/sse.ts | 1 + lib/inventory/team-channel.ts | 9 +++- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/app/api/inventory/admin/items/import/route.ts b/app/api/inventory/admin/items/import/route.ts index ee8612f4..c54621ac 100644 --- a/app/api/inventory/admin/items/import/route.ts +++ b/app/api/inventory/admin/items/import/route.ts @@ -31,29 +31,37 @@ export async function POST(request: Request) { ) } - const data = items.map((item: Record) => { - const stock = Math.max(0, Math.floor(Number(item.stock) || 0)) - const maxPerTeam = Math.max(0, Math.floor(Number(item.maxPerTeam ?? item.max_per_team) || 0)) - if (!Number.isFinite(stock) || !Number.isFinite(maxPerTeam)) { - throw new Error("Invalid numeric values in import data") - } - if (maxPerTeam <= 0) { - throw new Error(`maxPerTeam must be a positive integer for item "${sanitizeName(String(item.name ?? ""))}"`) - } - const name = sanitizeName(String(item.name ?? "")) - const category = sanitizeName(String(item.category ?? "")) - if (!name || !category) { - throw new Error(`Name and category are required for all items`) - } - return { - name, - description: item.description ? sanitizeDescription(String(item.description)) : null, - imageUrl: validateImageUrl(item.imageUrl ?? item.image_url), - stock, - category, - maxPerTeam, - } - }) + let data + try { + data = items.map((item: Record) => { + const stock = Math.max(0, Math.floor(Number(item.stock) || 0)) + const maxPerTeam = Math.max(0, Math.floor(Number(item.maxPerTeam ?? item.max_per_team) || 0)) + if (!Number.isFinite(stock) || !Number.isFinite(maxPerTeam)) { + throw new Error("Invalid numeric values in import data") + } + if (maxPerTeam <= 0) { + throw new Error(`maxPerTeam must be a positive integer for item "${sanitizeName(String(item.name ?? ""))}"`) + } + const name = sanitizeName(String(item.name ?? "")) + const category = sanitizeName(String(item.category ?? "")) + if (!name || !category) { + throw new Error("Name and category are required for all items") + } + return { + name, + description: item.description ? sanitizeDescription(String(item.description)) : null, + imageUrl: validateImageUrl(item.imageUrl ?? item.image_url), + stock, + category, + maxPerTeam, + } + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid import data" }, + { status: 400 } + ) + } await prisma.$transaction( data.map((item) => diff --git a/app/inventory/page.tsx b/app/inventory/page.tsx index 5768f03e..19636b61 100644 --- a/app/inventory/page.tsx +++ b/app/inventory/page.tsx @@ -274,7 +274,7 @@ export default function BrowsePage() { const canRentMore = activeRentals.length + cartTools.length < maxRentals; const cartTotal = cart.reduce((s, c) => s + c.quantity, 0) + cartTools.length; - if (loading && toolsLoading) { + if (loading || toolsLoading) { return (
diff --git a/lib/inventory/sse.ts b/lib/inventory/sse.ts index c8b9778e..d0285715 100644 --- a/lib/inventory/sse.ts +++ b/lib/inventory/sse.ts @@ -56,5 +56,6 @@ export function pushSSE( set.delete(ctrl) } } + if (set.size === 0) connections.delete(key) } } diff --git a/lib/inventory/team-channel.ts b/lib/inventory/team-channel.ts index 099505a6..a125c490 100644 --- a/lib/inventory/team-channel.ts +++ b/lib/inventory/team-channel.ts @@ -20,9 +20,14 @@ async function slackApi(method: string, body: Record) { return data } +function escapeSlackMrkdwn(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">") +} + function buildWelcomeBlocks(teamName: string, memberNames: string[]) { - const memberList = memberNames.length > 0 - ? memberNames.map((n) => `- ${n}`).join("\n") + const escapedNames = memberNames.map(escapeSlackMrkdwn) + const memberList = escapedNames.length > 0 + ? escapedNames.map((n) => `- ${n}`).join("\n") : "_No members yet_" return [ From fbc02d854e9657f29bc43257454e5eb994657d47 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:01:34 -0700 Subject: [PATCH 11/14] Fix Copilot review round 5: team race conditions, nullable names, lock check, state refresh --- app/api/inventory/admin/teams/route.ts | 2 +- app/api/inventory/teams/[id]/join/route.ts | 60 ++++++++-------- app/api/inventory/teams/[id]/members/route.ts | 68 +++++++++---------- app/api/inventory/teams/[id]/route.ts | 4 ++ app/api/inventory/teams/route.ts | 54 ++++++++------- app/components/inventory/TeamPanel.tsx | 9 +-- app/inventory/admin/teams/page.tsx | 5 +- 7 files changed, 107 insertions(+), 95 deletions(-) diff --git a/app/api/inventory/admin/teams/route.ts b/app/api/inventory/admin/teams/route.ts index b3b1ec68..fb2de8c5 100644 --- a/app/api/inventory/admin/teams/route.ts +++ b/app/api/inventory/admin/teams/route.ts @@ -8,7 +8,7 @@ export async function GET() { const teams = await prisma.team.findMany({ include: { - members: { select: { id: true, name: true } }, + members: { select: { id: true, name: true, slackDisplayName: true } }, }, orderBy: { name: "asc" }, }) diff --git a/app/api/inventory/teams/[id]/join/route.ts b/app/api/inventory/teams/[id]/join/route.ts index 90177bd8..1111a968 100644 --- a/app/api/inventory/teams/[id]/join/route.ts +++ b/app/api/inventory/teams/[id]/join/route.ts @@ -15,37 +15,39 @@ export async function POST( const { id } = await params - const team = await prisma.team.findUnique({ - where: { id }, - include: { _count: { select: { members: true } } }, - }) - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }) - } - - if (team.locked) { - return NextResponse.json({ error: "Team is locked" }, { status: 403 }) - } - - if (team._count.members >= MAX_TEAM_SIZE) { - return NextResponse.json({ error: "Team is full" }, { status: 400 }) + let team + try { + team = await prisma.$transaction(async (tx) => { + const txTeam = await tx.team.findUnique({ + where: { id }, + include: { _count: { select: { members: true } } }, + }) + if (!txTeam) throw new Error("TEAM_NOT_FOUND") + if (txTeam.locked) throw new Error("TEAM_LOCKED") + if (txTeam._count.members >= MAX_TEAM_SIZE) throw new Error("TEAM_FULL") + + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + if (user?.teamId) throw new Error("ALREADY_ON_TEAM") + + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: id }, + }) + return txTeam + }) + } catch (err) { + if (err instanceof Error) { + if (err.message === "TEAM_NOT_FOUND") return NextResponse.json({ error: "Team not found" }, { status: 404 }) + if (err.message === "TEAM_LOCKED") return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + if (err.message === "TEAM_FULL") return NextResponse.json({ error: "Team is full" }, { status: 400 }) + if (err.message === "ALREADY_ON_TEAM") return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) + } + throw err } - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { teamId: true }, - }) - - if (user?.teamId) { - return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) - } - - await prisma.user.update({ - where: { id: session.user.id }, - data: { teamId: id }, - }) - logAudit({ action: AuditAction.INVENTORY_TEAM_JOIN, actorId: session.user.id, diff --git a/app/api/inventory/teams/[id]/members/route.ts b/app/api/inventory/teams/[id]/members/route.ts index 4422938a..fff79c9a 100644 --- a/app/api/inventory/teams/[id]/members/route.ts +++ b/app/api/inventory/teams/[id]/members/route.ts @@ -36,48 +36,48 @@ export async function POST( return NextResponse.json({ error: "You must be a member of this team to add members" }, { status: 403 }) } - const team = await prisma.team.findUnique({ - where: { id }, - include: { _count: { select: { members: true } } }, - }) - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }) - } - - if (team.locked) { - return NextResponse.json({ error: "Team is locked" }, { status: 403 }) - } - - if (team._count.members >= MAX_TEAM_SIZE) { - return NextResponse.json({ error: "Team is full" }, { status: 400 }) - } - - const targetUser = await prisma.user.findUnique({ - where: { slackId }, - select: { id: true, teamId: true }, - }) - - if (!targetUser) { - return NextResponse.json({ error: "User not found" }, { status: 404 }) + let targetUserId: string + try { + targetUserId = await prisma.$transaction(async (tx) => { + const team = await tx.team.findUnique({ + where: { id }, + include: { _count: { select: { members: true } } }, + }) + if (!team) throw new Error("TEAM_NOT_FOUND") + if (team.locked) throw new Error("TEAM_LOCKED") + if (team._count.members >= MAX_TEAM_SIZE) throw new Error("TEAM_FULL") + + const target = await tx.user.findUnique({ + where: { slackId }, + select: { id: true, teamId: true }, + }) + if (!target) throw new Error("USER_NOT_FOUND") + if (target.teamId) throw new Error("ALREADY_ON_TEAM") + + await tx.user.update({ + where: { id: target.id }, + data: { teamId: id }, + }) + return target.id + }) + } catch (err) { + if (err instanceof Error) { + if (err.message === "TEAM_NOT_FOUND") return NextResponse.json({ error: "Team not found" }, { status: 404 }) + if (err.message === "TEAM_LOCKED") return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + if (err.message === "TEAM_FULL") return NextResponse.json({ error: "Team is full" }, { status: 400 }) + if (err.message === "USER_NOT_FOUND") return NextResponse.json({ error: "User not found" }, { status: 404 }) + if (err.message === "ALREADY_ON_TEAM") return NextResponse.json({ error: "User is already on a team" }, { status: 400 }) + } + throw err } - if (targetUser.teamId) { - return NextResponse.json({ error: "User is already on a team" }, { status: 400 }) - } - - await prisma.user.update({ - where: { id: targetUser.id }, - data: { teamId: id }, - }) - logAudit({ action: AuditAction.INVENTORY_TEAM_ADD_MEMBER, actorId: session.user.id, actorEmail: session.user.email, targetType: "Team", targetId: id, - metadata: { addedUserId: targetUser.id }, + metadata: { addedUserId: targetUserId }, }).catch(() => {}) syncTeamChannel(id).catch(() => {}) diff --git a/app/api/inventory/teams/[id]/route.ts b/app/api/inventory/teams/[id]/route.ts index 2b534d08..2aeff820 100644 --- a/app/api/inventory/teams/[id]/route.ts +++ b/app/api/inventory/teams/[id]/route.ts @@ -137,6 +137,10 @@ export async function DELETE( return NextResponse.json({ error: "You are not a member of this team" }, { status: 403 }) } + if (team.locked) { + return NextResponse.json({ error: "Team is locked" }, { status: 403 }) + } + if (team.members.length > 1) { return NextResponse.json( { error: "Cannot delete a team with other members. All other members must leave first." }, diff --git a/app/api/inventory/teams/route.ts b/app/api/inventory/teams/route.ts index b53e4edb..d0135de0 100644 --- a/app/api/inventory/teams/route.ts +++ b/app/api/inventory/teams/route.ts @@ -40,32 +40,36 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Team name is required" }, { status: 400 }) } - const existing = await prisma.team.findUnique({ where: { name: safeName } }) - if (existing) { - return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) - } - - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { teamId: true }, - }) - - if (user?.teamId) { - return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) - } - - const team = await prisma.$transaction(async (tx) => { - const created = await tx.team.create({ - data: { name: safeName }, + let team + try { + team = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.user.id }, + select: { teamId: true }, + }) + if (user?.teamId) throw new Error("ALREADY_ON_TEAM") + + const created = await tx.team.create({ + data: { name: safeName }, + }) + + await tx.user.update({ + where: { id: session.user.id }, + data: { teamId: created.id }, + }) + + return created }) - - await tx.user.update({ - where: { id: session.user.id }, - data: { teamId: created.id }, - }) - - return created - }) + } catch (err: unknown) { + if (err instanceof Error && err.message === "ALREADY_ON_TEAM") { + return NextResponse.json({ error: "You are already on a team" }, { status: 400 }) + } + // Prisma unique constraint violation (team name taken) + if (typeof err === "object" && err !== null && "code" in err && (err as { code: string }).code === "P2002") { + return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) + } + throw err + } logAudit({ action: AuditAction.INVENTORY_TEAM_CREATE, diff --git a/app/components/inventory/TeamPanel.tsx b/app/components/inventory/TeamPanel.tsx index 270f8797..5c039a4a 100644 --- a/app/components/inventory/TeamPanel.tsx +++ b/app/components/inventory/TeamPanel.tsx @@ -92,7 +92,7 @@ export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelPro } setNewTeamName(''); showSuccess('Team created!'); - onTeamChanged(); + window.location.reload(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create team'); } finally { @@ -109,7 +109,7 @@ export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelPro throw new Error(data.error || 'Failed to join team'); } showSuccess('Joined team!'); - onTeamChanged(); + window.location.reload(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to join team'); } @@ -129,9 +129,10 @@ export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelPro const data = await res.json(); throw new Error(data.error || 'Failed to update team name'); } + const updated = await res.json(); setEditingName(false); + setTeam(prev => prev ? { ...prev, name: updated.name } : prev); showSuccess('Team name updated!'); - onTeamChanged(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update team name'); } finally { @@ -344,7 +345,7 @@ export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelPro
- {member.name} + {member.name || member.slackDisplayName || 'Unknown'} {member.slackDisplayName && ( {member.slackDisplayName} )} diff --git a/app/inventory/admin/teams/page.tsx b/app/inventory/admin/teams/page.tsx index 85bf240e..eaa921a5 100644 --- a/app/inventory/admin/teams/page.tsx +++ b/app/inventory/admin/teams/page.tsx @@ -4,7 +4,8 @@ import React, { useState, useEffect, useCallback } from 'react'; interface TeamMember { id: string; - name: string; + name: string | null; + slackDisplayName?: string | null; } interface Team { @@ -154,7 +155,7 @@ export default function AdminTeamsPage() { key={member.id} className="text-brown-800/70 text-xs" > - {member.name} + {member.name || member.slackDisplayName || 'Unknown'} ))} From 2fab69466e4ee587a3583b8b94043e4fae414584 Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:11:13 -0700 Subject: [PATCH 12/14] Fix Copilot review round 6: rename race condition, tool input type guards --- app/api/inventory/admin/tools/[id]/route.ts | 10 ++++++++++ app/api/inventory/admin/tools/route.ts | 2 +- app/api/inventory/teams/[id]/route.ts | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/api/inventory/admin/tools/[id]/route.ts b/app/api/inventory/admin/tools/[id]/route.ts index 3b336d99..ca1075cb 100644 --- a/app/api/inventory/admin/tools/[id]/route.ts +++ b/app/api/inventory/admin/tools/[id]/route.ts @@ -19,6 +19,16 @@ export async function PATCH( const body = await request.json() const { name, description, imageUrl } = body + if (name !== undefined && typeof name !== "string") { + return NextResponse.json({ error: "Name must be a string" }, { status: 400 }) + } + if (description !== undefined && description !== null && typeof description !== "string") { + return NextResponse.json({ error: "Description must be a string" }, { status: 400 }) + } + if (imageUrl !== undefined && imageUrl !== null && typeof imageUrl !== "string") { + return NextResponse.json({ error: "Image URL must be a string" }, { status: 400 }) + } + const { sanitizeName, sanitizeDescription, validateImageUrl } = await import("@/lib/inventory/validation") const tool = await prisma.tool.update({ diff --git a/app/api/inventory/admin/tools/route.ts b/app/api/inventory/admin/tools/route.ts index c9dcc0f4..0c279c26 100644 --- a/app/api/inventory/admin/tools/route.ts +++ b/app/api/inventory/admin/tools/route.ts @@ -20,7 +20,7 @@ export async function POST(request: Request) { const body = await request.json() const { name, description, imageUrl } = body - if (!name) { + if (!name || typeof name !== "string") { return NextResponse.json( { error: "Name is required" }, { status: 400 } diff --git a/app/api/inventory/teams/[id]/route.ts b/app/api/inventory/teams/[id]/route.ts index 2aeff820..7d2f853f 100644 --- a/app/api/inventory/teams/[id]/route.ts +++ b/app/api/inventory/teams/[id]/route.ts @@ -95,10 +95,18 @@ export async function PATCH( return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) } - const updated = await prisma.team.update({ - where: { id }, - data: { name: safeName }, - }) + let updated + try { + updated = await prisma.team.update({ + where: { id }, + data: { name: safeName }, + }) + } catch (err: unknown) { + if (typeof err === "object" && err !== null && "code" in err && (err as { code: string }).code === "P2002") { + return NextResponse.json({ error: "A team with this name already exists" }, { status: 409 }) + } + throw err + } logAudit({ action: AuditAction.INVENTORY_TEAM_RENAME, From 63e827edfd43ad0a27c8b69c62e4d6532f1544ea Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:32:03 -0700 Subject: [PATCH 13/14] Fix Copilot review round 7: order state machine, cancel idempotency, serialization errors --- app/api/inventory/admin/orders/[id]/route.ts | 43 +++++++++++++++---- app/api/inventory/orders/[id]/cancel/route.ts | 13 +++--- app/api/inventory/orders/route.ts | 6 +++ app/components/inventory/TeamPanel.tsx | 3 +- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/app/api/inventory/admin/orders/[id]/route.ts b/app/api/inventory/admin/orders/[id]/route.ts index b7399071..7e1b5314 100644 --- a/app/api/inventory/admin/orders/[id]/route.ts +++ b/app/api/inventory/admin/orders/[id]/route.ts @@ -40,21 +40,31 @@ export async function PATCH( return { error: "Cannot cancel an order that is already ready, completed, or cancelled", status: 400 } as const } - const updated = await tx.order.update({ - where: { id }, + // Conditional update to prevent double-restock under concurrency + const updateResult = await tx.order.updateMany({ + where: { id, status: { notIn: ["READY", "COMPLETED", "CANCELLED"] } }, data: { status }, - include: { - team: { select: { id: true, name: true } }, - placedBy: { select: { id: true, name: true, email: true } }, - items: { include: { item: true } }, - }, }) + if (updateResult.count === 0) { + return { error: "Cannot cancel an order that is already ready, completed, or cancelled", status: 400 } as const + } + for (const item of existing.items) { await tx.item.update({ where: { id: item.itemId }, data: { stock: { increment: item.quantity } }, }) } + + const updated = await tx.order.findUnique({ + where: { id }, + include: { + team: { select: { id: true, name: true } }, + placedBy: { select: { id: true, name: true, email: true } }, + items: { include: { item: true } }, + }, + }) + if (!updated) return { error: "Order not found", status: 404 } as const return updated }) @@ -79,11 +89,28 @@ export async function PATCH( return NextResponse.json(order) } - const existing = await prisma.order.findUnique({ where: { id }, select: { id: true } }) + // Enforce valid state transitions + const VALID_TRANSITIONS: Record = { + PLACED: ["IN_PROGRESS", "CANCELLED"], + IN_PROGRESS: ["READY", "CANCELLED"], + READY: ["COMPLETED"], + COMPLETED: [], + CANCELLED: [], + } + + const existing = await prisma.order.findUnique({ where: { id }, select: { id: true, status: true } }) if (!existing) { return NextResponse.json({ error: "Order not found" }, { status: 404 }) } + const allowed = VALID_TRANSITIONS[existing.status] ?? [] + if (!allowed.includes(status)) { + return NextResponse.json( + { error: `Cannot transition from ${existing.status} to ${status}` }, + { status: 400 } + ) + } + const order = await prisma.order.update({ where: { id }, data: { status }, diff --git a/app/api/inventory/orders/[id]/cancel/route.ts b/app/api/inventory/orders/[id]/cancel/route.ts index f6d9d860..76f67e82 100644 --- a/app/api/inventory/orders/[id]/cancel/route.ts +++ b/app/api/inventory/orders/[id]/cancel/route.ts @@ -49,15 +49,14 @@ export async function POST( // Cancel and restore stock (re-check status inside transaction to prevent race) const cancelled = await prisma.$transaction(async (tx) => { - const current = await tx.order.findUnique({ where: { id }, select: { status: true } }) - if (!current || current.status === "READY" || current.status === "COMPLETED" || current.status === "CANCELLED") { - return false - } - - await tx.order.update({ - where: { id }, + // Conditional update to prevent double-restock under concurrency + const updateResult = await tx.order.updateMany({ + where: { id, status: { notIn: ["READY", "COMPLETED", "CANCELLED"] } }, data: { status: "CANCELLED" }, }) + if (updateResult.count === 0) { + return false + } for (const item of order.items) { await tx.item.update({ diff --git a/app/api/inventory/orders/route.ts b/app/api/inventory/orders/route.ts index 0993dccf..637c7f3a 100644 --- a/app/api/inventory/orders/route.ts +++ b/app/api/inventory/orders/route.ts @@ -175,6 +175,12 @@ export async function POST(request: Request) { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }) } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2034") { + return NextResponse.json( + { error: "Order could not be placed due to a concurrent update. Please retry." }, + { status: 409 } + ) + } const message = err instanceof Error ? err.message : "Failed to place order" return NextResponse.json({ error: message }, { status: 400 }) } diff --git a/app/components/inventory/TeamPanel.tsx b/app/components/inventory/TeamPanel.tsx index 5c039a4a..d69feaf0 100644 --- a/app/components/inventory/TeamPanel.tsx +++ b/app/components/inventory/TeamPanel.tsx @@ -194,8 +194,7 @@ export function TeamPanel({ teamId, currentUserId, onTeamChanged }: TeamPanelPro throw new Error(data.error || 'Failed to leave team'); } setConfirmLeave(false); - showSuccess('You left the team.'); - onTeamChanged(); + window.location.reload(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to leave team'); } finally { From e39aef9642ce29c549930e570b8f10de869079ce Mon Sep 17 00:00:00 2001 From: dropalltables <80474621+dropalltables@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:38:55 -0700 Subject: [PATCH 14/14] Fix maxPerTeam input min value to match server validation --- app/inventory/admin/items/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/inventory/admin/items/page.tsx b/app/inventory/admin/items/page.tsx index 132210f6..45cad819 100644 --- a/app/inventory/admin/items/page.tsx +++ b/app/inventory/admin/items/page.tsx @@ -231,7 +231,7 @@ export default function AdminInventoryPage() {
- setFormMaxPerTeam(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" min="0" required /> + setFormMaxPerTeam(e.target.value)} className="w-full border-2 border-brown-800 bg-cream-50 px-3 py-2 text-sm text-brown-800" min="1" required />
@@ -295,7 +295,7 @@ export default function AdminInventoryPage() { {categories.map((c) =>
- +
Order Date ItemsPlaced ByStatus
+ #{order.id.slice(-6).toUpperCase()} + {new Date(order.createdAt).toLocaleDateString()} {order.items.map(oi => `${oi.item.name} x${oi.quantity}`).join(', ')} {order.placedBy.name} + {order.status === 'CANCELLED' ? 'Cancelled' : 'Completed'} +
setEditItemStock(e.target.value)} className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" min="0" /> setEditItemMaxPerTeam(e.target.value)} className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" min="0" /> setEditItemMaxPerTeam(e.target.value)} className="w-24 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm" min="1" />
setEditItemImageUrl(e.target.value)} className="flex-1 border-2 border-brown-800 bg-cream-50 px-2 py-1 text-sm min-w-0" placeholder="URL" />