Compare commits

...

14 Commits

Author SHA1 Message Date
Josh Wright
2a6090e473 Redesign comments to GitHub/Gitea style
All checks were successful
Build & Push / TypeScript Check (client) (push) Successful in 14s
Build & Push / Build Server (push) Successful in 36s
Build & Push / Build Client (push) Successful in 33s
- Each comment is a bordered card with a distinct header bar (author name,
  clickable relative timestamp, hover-to-reveal delete) and a body section
- Subtle spine line connects comments in the avatar column
- Composer matches card style: same header bar for Write/Preview tabs,
  transparent textarea inside, submit row with border-top
- Comment timestamps default to relative, click to toggle absolute
  (mirrors sidebar date toggle pattern)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:38:36 -04:00
Josh Wright
8bea999b93 Remove /tickets prefix from ticket detail URLs
All checks were successful
Build & Push / TypeScript Check (client) (push) Successful in 15s
Build & Push / Build Server (push) Successful in 42s
Build & Push / Build Client (push) Successful in 35s
Routes and links now use /:id (e.g. /V675409888) instead of /tickets/:id.
API calls are unaffected as they go through /api/tickets/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:30:10 -04:00
Josh Wright
8c86ad7bb8 Move edit pencil to title card top-right; hide Actions card for non-admins
All checks were successful
Build & Push / TypeScript Check (client) (push) Successful in 14s
Build & Push / Build Server (push) Successful in 34s
Build & Push / Build Client (push) Successful in 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:24:53 -04:00
Josh Wright
b976f9a3a1 Remove npm cache from setup-node to avoid cache timeout in CI
All checks were successful
Build & Push / TypeScript Check (client) (push) Successful in 16s
Build & Push / Build Server (push) Successful in 45s
Build & Push / Build Client (push) Successful in 41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:20:36 -04:00
Josh Wright
e6c2298ac2 Remove unused SidebarField; add CI typecheck job before client Docker build
Some checks failed
Build & Push / Build Server (push) Has been cancelled
Build & Push / Build Client (push) Has been cancelled
Build & Push / TypeScript Check (client) (push) Has been cancelled
- Delete the now-unused SidebarField component from TicketDetail.tsx
- Add typecheck-client CI job that runs tsc --noEmit on the client before
  the Docker build, so TypeScript errors surface fast with a clear message
- build-client now depends on typecheck-client passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:14:48 -04:00
Josh Wright
618a9136f6 Toggle date fields between relative and absolute on click
Some checks failed
Build & Push / Build Server (push) Successful in 34s
Build & Push / Build Client (push) Failing after 26s
Defaults to relative time (e.g. '5 hours ago'); clicking switches to
absolute timestamp and back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:10:12 -04:00
Josh Wright
2638eb226b Remove unused selectClass constant
All checks were successful
Build & Push / Build Server (push) Successful in 43s
Build & Push / Build Client (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:06:41 -04:00
Josh Wright
402de82750 Unify sidebar fields: all editable fields use full-width clickable blocks
Some checks failed
Build & Push / Build Server (push) Successful in 51s
Build & Push / Build Client (push) Failing after 26s
- Status and Severity now match CTI style (full-width button, hover bg, no chevron)
- Remove 'Change routing' hint text from CTI block
- Replace Assignee dropdown with clickable block that opens a modal picker with avatars
- Add Assignee modal consistent with Status/Severity modal pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:03:39 -04:00
Josh Wright
44e5e2d373 Redesign ticket sidebar: modal pickers for status/severity, CTI as clickable unit
All checks were successful
Build & Push / Build Server (push) Successful in 44s
Build & Push / Build Client (push) Successful in 37s
- Rename 'Details' card to 'Ticket Summary'
- Replace status/severity dropdowns with badge displays that open small modal pickers on click
- Show Category, Type, Issue as separate labeled rows that together act as one clickable unit opening the routing modal
- Reorganize into sections: status/severity, CTI routing, dates (created/modified/resolved), people (assignee + requester)
- Add Requester field showing the ticket creator with avatar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:56:53 -04:00
2b76ad27b1 Reroute ticket via modal instead of inline sidebar expansion
All checks were successful
Build & Push / Build Client (push) Successful in 37s
Build & Push / Build Server (push) Successful in 51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:40:28 -04:00
d751e36ae8 Fix My Tickets and queue filter
All checks were successful
Build & Push / Build Server (push) Successful in 58s
Build & Push / Build Client (push) Successful in 41s
- My Tickets: exclude RESOLVED and CLOSED, show active tickets only
- Queue filter: cascading Category > Type > Item picker — each leaf is
  a distinct queue (e.g. TheWrightServer > Automation > Backup vs Sync)
- Server: support typeId and itemId as ticket list filter params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:35:33 -04:00
725f91578d Dark theme, roles overhaul, modal New Ticket, My Tickets page, and more
All checks were successful
Build & Push / Build Server (push) Successful in 2m5s
Build & Push / Build Client (push) Successful in 41s
- Dark UI across all pages and components (gray-950/900/800 palette)
- New Ticket is now a centered modal (triggered from sidebar), not a separate page
- Add USER role: view and comment only; AGENT and SERVICE can create/edit tickets
- Only admins can set ticket status to CLOSED (enforced server + UI)
- Add My Tickets page (/my-tickets) showing tickets assigned to current user
- Add queue (category) filter to Dashboard
- Audit log entries are clickable to expand detail; comment body shown as markdown
- Resolved date now includes time (HH:mm) in ticket sidebar
- Store comment body in audit log detail for COMMENT_ADDED and COMMENT_DELETED
- Clarify role descriptions in Admin Users modal
- Remove CI/CD section from README; add full API reference documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:17:14 -04:00
d8dc5b3ded Full-page ticket layout: two-column with sticky sidebar
All checks were successful
Build & Push / Build Server (push) Successful in 1m26s
Build & Push / Build Client (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:21:46 -04:00
64477529ea Add dbgenerated default for displayId
All checks were successful
Build & Push / Build Server (push) Successful in 57s
Build & Push / Build Client (push) Successful in 41s
prisma db push cannot add a non-nullable column to an existing table
without a database-level default. Using a PostgreSQL expression to
generate V + 9 random digits as the fallback default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:58:44 -04:00
22 changed files with 1457 additions and 693 deletions

View File

@@ -10,6 +10,26 @@ env:
OWNER: ${{ github.repository_owner }}
jobs:
typecheck-client:
name: TypeScript Check (client)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: ./client
- name: Type check
run: npx tsc --noEmit
working-directory: ./client
build-server:
name: Build Server
runs-on: ubuntu-latest
@@ -38,6 +58,7 @@ jobs:
build-client:
name: Build Client
needs: typecheck-client
runs-on: ubuntu-latest
steps:
- name: Checkout

306
README.md
View File

@@ -1,19 +1,35 @@
# TicketingSystem
Internal ticketing system with CTI-based routing, severity levels, and automation integration.
Internal ticketing system with CTI-based routing, severity levels, role-based access, and automation integration.
## Features
- **CTI routing** — tickets are categorised by Category → Type → Item, reroutable at any time
- **CTI routing** — tickets categorised by Category → Type → Item, reroutable at any time
- **Severity 15** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity
- **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- **Comments** — threaded comments per ticket with author attribution
- **Roles** — Admin, Agent, Service (API key auth for automation accounts)
- **Queue filter** — filter dashboard by category (queue)
- **My Tickets** — dedicated view of tickets assigned to you
- **Comments** — threaded markdown comments per ticket with author avatars
- **Roles** — Admin, Agent, User, Service (API key auth for automation)
- **Audit log** — every action tracked with actor, timestamp, and expandable detail
- **Admin panel** — manage users and the full CTI hierarchy via UI
- **n8n ready** — service accounts authenticate via `X-Api-Key` header
---
## Roles
| Role | Access |
|---|---|
| **Admin** | Full access — manage users, CTI config, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) |
| **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login |
> Only **Admins** can manually set a ticket status to **Closed**.
---
## Production Deployment
### Prerequisites
@@ -55,7 +71,7 @@ docker compose pull
docker compose up -d
```
### 5. Seed (first deploy only)
### 4. Seed (first deploy only)
```bash
docker compose exec server npm run db:seed
@@ -92,7 +108,7 @@ docker run -d \
cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install
npm run db:migrate # creates tables + migration files
npm run db:migrate # creates tables
npm run db:seed # seeds admin + Goddard + sample CTI
npm run dev # http://localhost:3000
```
@@ -107,6 +123,251 @@ npm run dev # http://localhost:5173 (proxies /api to :3000)
---
## API Reference
All endpoints (except `/api/auth/*`) require authentication via one of:
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
Base URL: `https://tickets.thewrightserver.net/api`
---
### Authentication
#### `POST /api/auth/login`
Authenticate and receive a JWT.
**Body:**
```json
{ "username": "string", "password": "string" }
```
**Response:**
```json
{
"token": "eyJ...",
"user": { "id": "...", "username": "admin", "displayName": "Admin", "email": "...", "role": "ADMIN" }
}
```
#### `GET /api/auth/me`
Returns the currently authenticated user.
---
### Tickets
#### `GET /api/tickets`
List all tickets, sorted by severity (ASC) then created date (DESC).
**Query parameters:**
| Parameter | Type | Description |
|---|---|---|
| `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` |
| `severity` | number | Filter by severity: `1``5` |
| `categoryId` | string | Filter by category (queue) |
| `assigneeId` | string | Filter by assignee user ID |
| `search` | string | Full-text search on title, overview, and display ID |
**Response:** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`.
---
#### `GET /api/tickets/:id`
Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes full `comments` array.
---
#### `POST /api/tickets`
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
**Body:**
```json
{
"title": "string",
"overview": "string (markdown)",
"severity": 1,
"categoryId": "string",
"typeId": "string",
"itemId": "string",
"assigneeId": "string (optional)"
}
```
**Response:** Created ticket object (201).
---
#### `PATCH /api/tickets/:id`
Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin**, or **Service** role.
> Setting `status` to `CLOSED` requires **Admin** role.
**Body (all fields optional):**
```json
{
"title": "string",
"overview": "string (markdown)",
"severity": 3,
"status": "IN_PROGRESS",
"assigneeId": "string | null",
"categoryId": "string",
"typeId": "string",
"itemId": "string"
}
```
All changes are recorded in the audit log automatically.
**Response:** Updated ticket object.
---
#### `DELETE /api/tickets/:id`
Delete a ticket and all associated comments and audit logs. **Admin only.**
**Response:** 204 No Content.
---
### Comments
#### `POST /api/tickets/:id/comments`
Add a comment to a ticket. Supports markdown. All authenticated roles may comment.
**Body:**
```json
{ "body": "string (markdown)" }
```
**Response:** Created comment object (201).
---
#### `DELETE /api/tickets/:id/comments/:commentId`
Delete a comment. Authors may delete their own comments; Admins may delete any.
**Response:** 204 No Content.
---
### Audit Log
#### `GET /api/tickets/:id/audit`
Retrieve the full audit log for a ticket, ordered newest first.
**Response:** Array of audit log entries:
```json
[
{
"id": "...",
"action": "COMMENT_ADDED",
"detail": "Comment body text here",
"createdAt": "2026-03-30T10:00:00Z",
"user": { "id": "...", "username": "admin", "displayName": "Admin" }
}
]
```
**Action types:**
| Action | Detail |
|---|---|
| `CREATED` | — |
| `STATUS_CHANGED` | e.g. `Open → In Progress` |
| `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` |
| `ASSIGNEE_CHANGED` | e.g. `Unassigned → Josh` |
| `REROUTED` | e.g. `OldCat OldType OldItem → NewCat NewType NewItem` |
| `TITLE_CHANGED` | New title |
| `OVERVIEW_CHANGED` | — |
| `COMMENT_ADDED` | Comment body |
| `COMMENT_DELETED` | Deleted comment body |
---
### CTI (Category / Type / Item)
#### `GET /api/cti/categories`
#### `GET /api/cti/types?categoryId=<id>`
#### `GET /api/cti/items?typeId=<id>`
Read the CTI hierarchy. Used to resolve IDs when creating/rerouting tickets.
**Admin-only write operations:**
| Method | Endpoint | Body |
|---|---|---|
| `POST` | `/api/cti/categories` | `{ "name": "string" }` |
| `PUT` | `/api/cti/categories/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/categories/:id` | — |
| `POST` | `/api/cti/types` | `{ "name": "string", "categoryId": "string" }` |
| `PUT` | `/api/cti/types/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/types/:id` | — |
| `POST` | `/api/cti/items` | `{ "name": "string", "typeId": "string" }` |
| `PUT` | `/api/cti/items/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/items/:id` | — |
Deleting a category cascades to all child types and items.
---
### Users
#### `GET /api/users`
List all users (id, username, displayName, email, role). Used to populate assignee dropdowns.
**Admin-only operations:**
#### `POST /api/users`
Create a user.
```json
{
"username": "string",
"email": "string",
"displayName": "string",
"password": "string (not required for SERVICE role)",
"role": "ADMIN | AGENT | USER | SERVICE"
}
```
Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again.
#### `PATCH /api/users/:id`
Update a user.
```json
{
"displayName": "string",
"email": "string",
"password": "string",
"role": "ADMIN | AGENT | USER | SERVICE",
"regenerateApiKey": true
}
```
#### `DELETE /api/users/:id`
Delete a user. Cannot delete your own account.
---
## n8n Integration (Goddard)
The `goddard` service account authenticates via API key — no login flow needed.
@@ -114,7 +375,7 @@ The `goddard` service account authenticates via API key — no login flow needed
**Create a ticket from n8n:**
```
POST https://tickets.thewrightserver.net/api/tickets
POST /api/tickets
X-Api-Key: sk_<goddard api key>
Content-Type: application/json
@@ -138,32 +399,6 @@ To regenerate the Goddard API key: Admin → Users → refresh icon next to Godd
---
## CI/CD
Push to `main` triggers `.gitea/workflows/build.yml`, which builds and pushes two images in parallel:
| Image | Tag |
|---|---|
| `$REGISTRY/josh/ticketing-server` | `latest`, `<git sha>` |
| `$REGISTRY/josh/ticketing-client` | `latest`, `<git sha>` |
**Gitea repository secrets/variables required:**
| Name | Type | Value |
|---|---|---|
| `REGISTRY` | Variable | `gitea.thewrightserver.net` |
| `REGISTRY_TOKEN` | Secret | Gitea personal access token with `write:packages` |
Set these under **Repository → Settings → Actions → Variables / Secrets**.
To deploy a specific commit SHA instead of latest:
```bash
TAG=<sha> docker compose up -d
```
---
## Environment Variables
| Variable | Required | Description |
@@ -174,7 +409,6 @@ TAG=<sha> docker compose up -d
| `PORT` | No | Server port (default: `3000`) |
| `REGISTRY` | Deploy | Container registry hostname |
| `POSTGRES_PASSWORD` | Deploy | Postgres password |
| `DOMAIN` | Deploy | Public domain for Traefik routing |
| `TAG` | Deploy | Image tag to deploy (default: `latest`) |
---
@@ -189,7 +423,7 @@ TAG=<sha> docker compose up -d
| 4 | SEV 4 | Low — minor issue |
| 5 | SEV 5 | Minimal — informational / automated |
Tickets are sorted SEV 1 → SEV 5 on the dashboard. Paging by severity is planned for a future release.
Tickets are sorted SEV 1 → SEV 5 on the dashboard.
---
@@ -201,3 +435,5 @@ OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
re-opens reset
the 14-day timer
```
> CLOSED status can only be set manually by an **Admin**. The auto-close job runs hourly.

View File

@@ -4,8 +4,8 @@ import PrivateRoute from './components/PrivateRoute'
import AdminRoute from './components/AdminRoute'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import MyTickets from './pages/MyTickets'
import TicketDetail from './pages/TicketDetail'
import NewTicket from './pages/NewTicket'
import AdminUsers from './pages/admin/Users'
import AdminCTI from './pages/admin/CTI'
@@ -17,8 +17,8 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/tickets/new" element={<NewTicket />} />
<Route path="/tickets/:id" element={<TicketDetail />} />
<Route path="/my-tickets" element={<MyTickets />} />
<Route path="/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} />

View File

@@ -51,12 +51,12 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
}
const selectClass =
'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400'
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
return (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
@@ -73,7 +73,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
@@ -90,7 +90,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}

View File

@@ -1,7 +1,8 @@
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react'
import { LayoutDashboard, Users, Settings, LogOut, Plus, Ticket } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import NewTicketModal from '../pages/NewTicket'
interface LayoutProps {
children: ReactNode
@@ -13,9 +14,13 @@ export default function Layout({ children, title, action }: LayoutProps) {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [showNewTicket, setShowNewTicket] = useState(false)
const canCreateTicket = user?.role !== 'USER'
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/', icon: LayoutDashboard, label: 'All Tickets' },
{ to: '/my-tickets', icon: Ticket, label: 'My Tickets' },
...(user?.role === 'ADMIN'
? [
{ to: '/admin/users', icon: Users, label: 'Users' },
@@ -29,24 +34,27 @@ export default function Layout({ children, title, action }: LayoutProps) {
navigate('/login')
}
const isActive = (to: string) =>
to === '/' ? location.pathname === '/' : location.pathname.startsWith(to)
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
<div className="flex h-screen bg-gray-950 overflow-hidden">
{/* Sidebar */}
<aside className="w-60 bg-gray-900 text-white flex flex-col flex-shrink-0">
<div className="p-5 border-b border-gray-700">
<h1 className="text-base font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-400 mt-0.5">{user?.displayName}</p>
<aside className="w-60 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0">
<div className="px-5 py-4 border-b border-gray-800">
<h1 className="text-sm font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-500 mt-0.5">{user?.displayName}</p>
</div>
<nav className="flex-1 p-3 space-y-0.5">
<nav className="flex-1 p-2 space-y-0.5">
{navItems.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
location.pathname === to
isActive(to)
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
}`}
>
<Icon size={15} />
@@ -55,17 +63,19 @@ export default function Layout({ children, title, action }: LayoutProps) {
))}
</nav>
<div className="p-3 border-t border-gray-700 space-y-0.5">
<Link
to="/tickets/new"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
<div className="p-2 border-t border-gray-800 space-y-0.5">
{canCreateTicket && (
<button
onClick={() => setShowNewTicket(true)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
>
<Plus size={15} />
New Ticket
</button>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-800 hover:text-white w-full transition-colors"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
>
<LogOut size={15} />
Logout
@@ -76,13 +86,15 @@ export default function Layout({ children, title, action }: LayoutProps) {
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{(title || action) && (
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>}
<header className="bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-base font-semibold text-gray-100">{title}</h2>}
{action && <div>{action}</div>}
</header>
)}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
</div>
)
}

View File

@@ -5,9 +5,10 @@ interface ModalProps {
title: string
onClose: () => void
children: ReactNode
size?: 'md' | 'lg'
}
export default function Modal({ title, onClose, children }: ModalProps) {
export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -18,20 +19,20 @@ export default function Modal({ title, onClose, children }: ModalProps) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
<div className={`bg-gray-900 border border-gray-800 rounded-xl shadow-2xl w-full mx-4 ${size === 'lg' ? 'max-w-2xl' : 'max-w-md'}`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-gray-100">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-500 hover:text-gray-300 transition-colors"
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4">{children}</div>
<div className="px-6 py-5">{children}</div>
</div>
</div>
)

View File

@@ -1,9 +1,9 @@
const config: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-100 text-red-800 border-red-200' },
2: { label: 'SEV 2', className: 'bg-orange-100 text-orange-800 border-orange-200' },
3: { label: 'SEV 3', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
4: { label: 'SEV 4', className: 'bg-blue-100 text-blue-800 border-blue-200' },
5: { label: 'SEV 5', className: 'bg-gray-100 text-gray-600 border-gray-200' },
1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
}
export default function SeverityBadge({ severity }: { severity: number }) {

View File

@@ -1,10 +1,10 @@
import { TicketStatus } from '../types'
const config: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: 'Open', className: 'bg-blue-100 text-blue-800 border-blue-200' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
RESOLVED: { label: 'Resolved', className: 'bg-green-100 text-green-800 border-green-200' },
CLOSED: { label: 'Closed', className: 'bg-gray-100 text-gray-500 border-gray-200' },
OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
}
export default function StatusBadge({ status }: { status: TicketStatus }) {

View File

@@ -2,7 +2,13 @@
@tailwind components;
@tailwind utilities;
/* Markdown prose styles */
/* Native select dark option styling */
select option {
background-color: #1f2937;
color: #f3f4f6;
}
/* Markdown prose styles (dark) */
.prose p { @apply mb-3 last:mb-0 leading-relaxed; }
.prose h1 { @apply text-xl font-bold mb-3 mt-5; }
.prose h2 { @apply text-lg font-semibold mb-2 mt-4; }
@@ -11,14 +17,14 @@
.prose ol { @apply list-decimal pl-5 mb-3 space-y-1; }
.prose li > ul,
.prose li > ol { @apply mt-1 mb-0; }
.prose a { @apply text-blue-600 underline hover:text-blue-800; }
.prose a { @apply text-blue-400 underline hover:text-blue-300; }
.prose strong { @apply font-semibold; }
.prose em { @apply italic; }
.prose blockquote { @apply border-l-4 border-gray-200 pl-4 text-gray-500 my-3 italic; }
.prose code { @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono; }
.prose pre { @apply bg-gray-900 text-gray-100 p-4 rounded-lg my-3 overflow-x-auto text-sm; }
.prose pre code { @apply bg-transparent text-gray-100 p-0; }
.prose hr { @apply border-gray-200 my-4; }
.prose blockquote { @apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic; }
.prose code { @apply bg-gray-800 text-gray-300 px-1.5 py-0.5 rounded text-xs font-mono; }
.prose pre { @apply bg-gray-950 text-gray-300 p-4 rounded-lg my-3 overflow-x-auto text-sm; }
.prose pre code { @apply bg-transparent text-gray-300 p-0; }
.prose hr { @apply border-gray-700 my-4; }
.prose table { @apply w-full border-collapse text-sm my-3; }
.prose th { @apply bg-gray-50 border border-gray-200 px-3 py-2 text-left font-semibold; }
.prose td { @apply border border-gray-200 px-3 py-2; }
.prose th { @apply bg-gray-800 border border-gray-700 px-3 py-2 text-left font-semibold text-gray-300; }
.prose td { @apply border border-gray-700 px-3 py-2 text-gray-400; }

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Search } from 'lucide-react'
import { Search, ChevronRight, X } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import api from '../api/client'
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import Avatar from '../components/Avatar'
import { Ticket, TicketStatus } from '../types'
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types'
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
@@ -17,6 +17,21 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: 'CLOSED', label: 'Closed' },
]
const selectClass =
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
// Queue label built from whatever CTI level is selected
function queueLabel(
category: Category | null,
type: CTIType | null,
item: Item | null,
): string {
if (item && type && category) return `${category.name} ${type.name} ${item.name}`
if (type && category) return `${category.name} ${type.name}`
if (category) return category.name
return ''
}
export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
@@ -24,9 +39,57 @@ export default function Dashboard() {
const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('')
// CTI queue filter state
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
const [showQueueFilter, setShowQueueFilter] = useState(false)
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
const handleCategorySelect = (cat: Category) => {
setSelectedCategory(cat)
setSelectedType(null)
setSelectedItem(null)
setTypes([])
setItems([])
api.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } }).then((r) => setTypes(r.data))
}
const handleTypeSelect = (type: CTIType) => {
setSelectedType(type)
setSelectedItem(null)
setItems([])
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data))
}
const handleItemSelect = (item: Item) => {
setSelectedItem(item)
setShowQueueFilter(false)
}
const clearQueue = () => {
setSelectedCategory(null)
setSelectedType(null)
setSelectedItem(null)
setTypes([])
setItems([])
}
// Derive the most specific filter param
const queueParams: Record<string, string> = {}
if (selectedItem) queueParams.itemId = selectedItem.id
else if (selectedType) queueParams.typeId = selectedType.id
else if (selectedCategory) queueParams.categoryId = selectedCategory.id
const fetchTickets = useCallback(() => {
setLoading(true)
const params: Record<string, string> = {}
const params: Record<string, string> = { ...queueParams }
if (status) params.status = status
if (severity) params.severity = severity
if (search) params.search = search
@@ -34,43 +97,35 @@ export default function Dashboard() {
.get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data))
.finally(() => setLoading(false))
}, [status, severity, search])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, severity, search, selectedCategory, selectedType, selectedItem])
useEffect(() => {
const t = setTimeout(fetchTickets, 300)
return () => clearTimeout(t)
}, [fetchTickets])
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem)
return (
<Layout
title="Tickets"
action={
<Link
to="/tickets/new"
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
<Plus size={15} />
New Ticket
</Link>
}
>
<Layout title="All Tickets">
{/* Filters */}
<div className="flex gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
<div className="flex gap-3 mb-5 flex-wrap items-start">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
<input
type="text"
placeholder="Search tickets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus | '')}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={selectClass}
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
@@ -82,7 +137,7 @@ export default function Dashboard() {
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={selectClass}
>
<option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => (
@@ -91,22 +146,133 @@ export default function Dashboard() {
</option>
))}
</select>
{/* Queue picker */}
<div className="relative">
<button
onClick={() => setShowQueueFilter((v) => !v)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border transition-colors ${
activeQueue
? 'bg-blue-600/20 border-blue-500/40 text-blue-400'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{activeQueue ? (
<>
<span className="max-w-48 truncate">{activeQueue}</span>
<span
onClick={(e) => { e.stopPropagation(); clearQueue() }}
className="text-blue-400 hover:text-white transition-colors cursor-pointer"
>
<X size={13} />
</span>
</>
) : (
'All Queues'
)}
</button>
{showQueueFilter && (
<div className="absolute z-20 top-full mt-1 left-0 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex"
style={{ minWidth: '520px' }}
>
{/* Categories */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Category
</p>
<div className="overflow-auto max-h-64">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategorySelect(cat)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedCategory?.id === cat.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{cat.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))}
</div>
</div>
{/* Types */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Type
</p>
<div className="overflow-auto max-h-64">
{!selectedCategory ? (
<p className="text-xs text-gray-600 px-3 py-4">Select category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No types</p>
) : (
types.map((type) => (
<button
key={type.id}
onClick={() => handleTypeSelect(type)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedType?.id === type.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{type.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))
)}
</div>
</div>
{/* Items */}
<div className="w-44">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Item
</p>
<div className="overflow-auto max-h-64">
{!selectedType ? (
<p className="text-xs text-gray-600 px-3 py-4">Select type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No items</p>
) : (
items.map((item) => (
<button
key={item.id}
onClick={() => handleItemSelect(item)}
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
selectedItem?.id === item.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{item.name}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Ticket list */}
{loading ? (
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-400 text-sm">No tickets found</div>
<div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
) : (
<div className="space-y-2">
<div className="space-y-1.5">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${ticket.displayId}`}
className="flex items-center gap-4 bg-white border border-gray-200 rounded-lg px-4 py-3 hover:border-blue-400 hover:shadow-sm transition-all group"
to={`/${ticket.displayId}`}
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 hover:bg-gray-900/80 transition-all group"
>
{/* Severity stripe */}
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
@@ -117,40 +283,39 @@ export default function Dashboard() {
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-300'
: 'bg-gray-600'
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-mono font-medium text-gray-400">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-400">
<span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-700">
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
{ticket.title}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{ticket.assignee && (
{ticket.assignee ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span>{ticket.assignee.displayName}</span>
</div>
) : (
<span className="text-xs text-gray-600">Unassigned</span>
)}
{!ticket.assignee && (
<span className="text-xs text-gray-400">Unassigned</span>
)}
<span className="text-xs text-gray-400">
<span className="text-xs text-gray-600">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-400">
<span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>

View File

@@ -25,25 +25,25 @@ export default function Login() {
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white">Ticketing System</h1>
<p className="text-gray-400 text-sm mt-1">Sign in to your account</p>
<p className="text-gray-500 text-sm mt-1">Sign in to your account</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-xl p-8 space-y-4"
className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl p-8 space-y-4"
>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-1">
Username
</label>
<input
@@ -52,12 +52,12 @@ export default function Login() {
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-1">
Password
</label>
<input
@@ -65,7 +65,7 @@ export default function Login() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>

View File

@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { formatDistanceToNow } from 'date-fns'
import api from '../api/client'
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import { Ticket } from '../types'
import { useAuth } from '../contexts/AuthContext'
export default function MyTickets() {
const { user } = useAuth()
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!user) return
// Only show active tickets — OPEN and IN_PROGRESS
Promise.all([
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }),
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }),
])
.then(([openRes, inProgressRes]) => {
const combined = [...openRes.data, ...inProgressRes.data]
combined.sort((a, b) => a.severity - b.severity || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
setTickets(combined)
})
.finally(() => setLoading(false))
}, [user])
return (
<Layout title="My Tickets">
{loading ? (
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-600 text-sm">
No active tickets assigned to you
</div>
) : (
<div className="space-y-1.5">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/${ticket.displayId}`}
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 transition-all group"
>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-600'
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
{ticket.title}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="text-xs text-gray-600">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>
</Link>
))}
</div>
)}
</Layout>
)
}

View File

@@ -1,11 +1,15 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import Layout from '../components/Layout'
import Modal from '../components/Modal'
import CTISelect from '../components/CTISelect'
import { User } from '../types'
export default function NewTicket() {
interface NewTicketModalProps {
onClose: () => void
}
export default function NewTicketModal({ onClose }: NewTicketModalProps) {
const navigate = useNavigate()
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
@@ -49,7 +53,8 @@ export default function NewTicket() {
if (form.assigneeId) payload.assigneeId = form.assigneeId
const res = await api.post('/tickets', payload)
navigate(`/tickets/${res.data.displayId}`)
onClose()
navigate(`/${res.data.displayId}`)
} catch {
setError('Failed to create ticket')
} finally {
@@ -58,104 +63,103 @@ export default function NewTicket() {
}
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
return (
<Layout title="New Ticket">
<div className="max-w-2xl">
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<Modal title="New Ticket" onClose={onClose} size="lg">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className={labelClass}>Title</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass}
placeholder="Brief description of the issue"
autoFocus
/>
</div>
<div>
<label className={labelClass}>Overview</label>
<textarea
value={form.overview}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
required
rows={4}
className={inputClass}
placeholder="Detailed description... Markdown supported"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Title</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
<label className={labelClass}>Severity</label>
<select
value={form.severity}
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
placeholder="Brief description of the issue"
/>
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className={labelClass}>Overview</label>
<textarea
value={form.overview}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
required
rows={4}
<label className={labelClass}>Assignee</label>
<select
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
placeholder="Detailed description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Severity</label>
<select
value={form.severity}
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass}
>
<option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option>
<option value={3}>SEV 3 Medium</option>
<option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option>
</select>
</div>
<div>
<label className={labelClass}>Assignee</label>
<select
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
<div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate(-1)}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Creating...' : 'Create Ticket'}
</button>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</form>
</div>
</Layout>
</div>
<div>
<label className={labelClass}>Routing (CTI)</label>
<CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }}
onChange={handleCTI}
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Creating...' : 'Create Ticket'}
</button>
</div>
</form>
</Modal>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -125,11 +125,11 @@ export default function AdminCTI() {
}
}
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col'
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50'
const panelClass = 'bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col'
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800'
const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
active ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50 border-l-2 border-transparent'
active ? 'bg-blue-600/20 border-l-2 border-blue-500' : 'hover:bg-gray-800 border-l-2 border-transparent'
}`
return (
@@ -138,17 +138,17 @@ export default function AdminCTI() {
{/* Categories */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
<button
onClick={() => openAdd('category')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-auto">
{categories.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No categories</p>
<p className="text-xs text-gray-600 text-center py-8">No categories</p>
) : (
categories.map((cat) => (
<div
@@ -156,21 +156,21 @@ export default function AdminCTI() {
className={itemClass(selectedCategory?.id === cat.id)}
onClick={() => selectCategory(cat)}
>
<span className="text-sm text-gray-800">{cat.name}</span>
<span className="text-sm text-gray-300">{cat.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
<ChevronRight size={14} className="text-gray-700" />
</div>
</div>
))
@@ -181,16 +181,16 @@ export default function AdminCTI() {
{/* Types */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
<h3 className="text-sm font-semibold text-gray-300">
Types
{selectedCategory && (
<span className="ml-1 font-normal text-gray-400"> {selectedCategory.name}</span>
<span className="ml-1 font-normal text-gray-500"> {selectedCategory.name}</span>
)}
</h3>
{selectedCategory && (
<button
onClick={() => openAdd('type')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -198,9 +198,9 @@ export default function AdminCTI() {
</div>
<div className="flex-1 overflow-auto">
{!selectedCategory ? (
<p className="text-xs text-gray-400 text-center py-8">Select a category</p>
<p className="text-xs text-gray-600 text-center py-8">Select a category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No types</p>
<p className="text-xs text-gray-600 text-center py-8">No types</p>
) : (
types.map((type) => (
<div
@@ -208,21 +208,21 @@ export default function AdminCTI() {
className={itemClass(selectedType?.id === type.id)}
onClick={() => selectType(type)}
>
<span className="text-sm text-gray-800">{type.name}</span>
<span className="text-sm text-gray-300">{type.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
<ChevronRight size={14} className="text-gray-700" />
</div>
</div>
))
@@ -233,16 +233,16 @@ export default function AdminCTI() {
{/* Items */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
<h3 className="text-sm font-semibold text-gray-300">
Items
{selectedType && (
<span className="ml-1 font-normal text-gray-400"> {selectedType.name}</span>
<span className="ml-1 font-normal text-gray-500"> {selectedType.name}</span>
)}
</h3>
{selectedType && (
<button
onClick={() => openAdd('item')}
className="text-blue-600 hover:text-blue-800 transition-colors"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -250,23 +250,23 @@ export default function AdminCTI() {
</div>
<div className="flex-1 overflow-auto">
{!selectedType ? (
<p className="text-xs text-gray-400 text-center py-8">Select a type</p>
<p className="text-xs text-gray-600 text-center py-8">Select a type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No items</p>
<p className="text-xs text-gray-600 text-center py-8">No items</p>
) : (
items.map((item) => (
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
<span className="text-sm text-gray-800">{item.name}</span>
<span className="text-sm text-gray-300">{item.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
@@ -286,21 +286,21 @@ export default function AdminCTI() {
>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<label className="block text-sm font-medium text-gray-300 mb-1">Name</label>
<input
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>

View File

@@ -25,9 +25,24 @@ const BLANK_FORM: UserForm = {
const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
USER: 'User',
SERVICE: 'Service',
}
const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
}
const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
AGENT: 'Manage tickets — create, update, assign, comment, change status',
USER: 'Basic access — view tickets and add comments only',
SERVICE: 'Automation account — authenticates via API key, no password login',
}
export default function AdminUsers() {
const { user: authUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
@@ -133,8 +148,8 @@ export default function AdminUsers() {
}
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
const labelClass = 'block text-sm font-medium text-gray-300 mb-1'
return (
<Layout
@@ -149,9 +164,9 @@ export default function AdminUsers() {
</button>
}
>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<thead className="border-b border-gray-800">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User
@@ -168,31 +183,23 @@ export default function AdminUsers() {
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-800">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td>
<tr key={u.id} className="hover:bg-gray-800/50">
<td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
u.role === 'ADMIN'
? 'bg-purple-100 text-purple-700'
: u.role === 'SERVICE'
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-600'
}`}
>
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}>
{ROLE_LABELS[u.role]}
</span>
</td>
<td className="px-5 py-3 text-gray-500">{u.email}</td>
<td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key"
>
<RefreshCw size={14} />
@@ -200,14 +207,14 @@ export default function AdminUsers() {
)}
<button
onClick={() => openEdit(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
className="text-gray-600 hover:text-gray-300 transition-colors"
>
<Pencil size={14} />
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-600 transition-colors"
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
@@ -228,17 +235,17 @@ export default function AdminUsers() {
>
{newApiKey ? (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2">
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
<p className="text-sm font-medium text-amber-400 mb-2">
API Key copy it now, it won't be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-white border border-amber-200 rounded px-3 py-2 font-mono break-all">
<code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all">
{newApiKey}
</code>
<button
onClick={() => copyToClipboard(newApiKey)}
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
className="flex-shrink-0 text-amber-400 hover:text-amber-300 transition-colors"
>
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button>
@@ -254,7 +261,7 @@ export default function AdminUsers() {
) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2 rounded-lg">
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
@@ -296,7 +303,10 @@ export default function AdminUsers() {
<div>
<label className={labelClass}>
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
Password{' '}
{modal === 'edit' && (
<span className="text-gray-500 font-normal">(leave blank to keep current)</span>
)}
</label>
<input
type="password"
@@ -316,16 +326,18 @@ export default function AdminUsers() {
className={inputClass}
>
<option value="AGENT">Agent</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
<option value="SERVICE">Service (API key auth)</option>
<option value="SERVICE">Service</option>
</select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>

View File

@@ -1,4 +1,4 @@
export type Role = 'ADMIN' | 'AGENT' | 'SERVICE'
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE'
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'
export interface User {

View File

@@ -10,6 +10,7 @@ datasource db {
enum Role {
ADMIN
AGENT
USER
SERVICE
}
@@ -67,7 +68,7 @@ model Item {
model Ticket {
id String @id @default(cuid())
displayId String @unique
displayId String @unique @default(dbgenerated("concat('V', (floor(random() * 900000000) + 100000000)::bigint::text)"))
title String
overview String
severity Int

View File

@@ -55,3 +55,15 @@ export const requireAdmin = (
}
next()
}
// Blocks USER role — allows ADMIN, AGENT, SERVICE
export const requireAgent = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}

View File

@@ -24,7 +24,7 @@ router.post('/', async (req: AuthRequest, res) => {
include: { author: { select: { id: true, username: true, displayName: true } } },
}),
prisma.auditLog.create({
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED' },
data: { ticketId: ticket.id, userId: req.user!.id, action: 'COMMENT_ADDED', detail: body },
}),
])
@@ -44,7 +44,12 @@ router.delete('/:commentId', async (req: AuthRequest, res) => {
await prisma.$transaction([
prisma.comment.delete({ where: { id: req.params.commentId } }),
prisma.auditLog.create({
data: { ticketId: comment.ticketId, userId: req.user!.id, action: 'COMMENT_DELETED' },
data: {
ticketId: comment.ticketId,
userId: req.user!.id,
action: 'COMMENT_DELETED',
detail: comment.body,
},
}),
])

View File

@@ -1,7 +1,7 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin, AuthRequest } from '../middleware/auth'
import { requireAdmin, requireAgent, AuthRequest } from '../middleware/auth'
import commentRouter from './comments'
const router = Router()
@@ -67,13 +67,15 @@ router.use('/:ticketId/comments', commentRouter)
// GET /api/tickets
router.get('/', async (req: AuthRequest, res) => {
const { status, severity, assigneeId, categoryId, search } = req.query
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query
const where: Record<string, unknown> = {}
if (status) where.status = status
if (severity) where.severity = Number(severity)
if (assigneeId) where.assigneeId = assigneeId
if (categoryId) where.categoryId = categoryId
if (itemId) where.itemId = itemId
else if (typeId) where.typeId = typeId
else if (categoryId) where.categoryId = categoryId
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
@@ -123,7 +125,7 @@ router.get('/:id/audit', async (req, res) => {
})
// POST /api/tickets
router.post('/', async (req: AuthRequest, res) => {
router.post('/', requireAgent, async (req: AuthRequest, res) => {
const data = createSchema.parse(req.body)
const displayId = await generateDisplayId()
@@ -141,9 +143,14 @@ router.post('/', async (req: AuthRequest, res) => {
})
// PATCH /api/tickets/:id
router.patch('/:id', async (req: AuthRequest, res) => {
router.patch('/:id', requireAgent, async (req: AuthRequest, res) => {
const data = updateSchema.parse(req.body)
// Only admins can set status to CLOSED
if (data.status === 'CLOSED' && req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Only admins can close tickets' })
}
const existing = await prisma.ticket.findFirst({
where: { OR: [{ id: req.params.id }, { displayId: req.params.id }] },
include: {

View File

@@ -32,7 +32,7 @@ router.post('/', requireAdmin, async (req, res) => {
email: z.string().email(),
displayName: z.string().min(1).max(100),
password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).default('AGENT'),
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).default('AGENT'),
})
.parse(req.body)
@@ -64,7 +64,7 @@ router.patch('/:id', requireAdmin, async (req, res) => {
displayName: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).optional(),
role: z.enum(['ADMIN', 'AGENT', 'USER', 'SERVICE']).optional(),
regenerateApiKey: z.boolean().optional(),
})
.parse(req.body)