From 725f91578d29402685d79347019230b19625ad8c Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 30 Mar 2026 23:17:14 -0400 Subject: [PATCH] Dark theme, roles overhaul, modal New Ticket, My Tickets page, and more - 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 --- README.md | 306 +++++++++++++++++++++--- client/src/App.tsx | 4 +- client/src/components/CTISelect.tsx | 8 +- client/src/components/Layout.tsx | 56 +++-- client/src/components/Modal.tsx | 15 +- client/src/components/SeverityBadge.tsx | 10 +- client/src/components/StatusBadge.tsx | 8 +- client/src/index.css | 24 +- client/src/pages/Dashboard.tsx | 83 ++++--- client/src/pages/Login.tsx | 16 +- client/src/pages/MyTickets.tsx | 85 +++++++ client/src/pages/NewTicket.tsx | 184 +++++++------- client/src/pages/TicketDetail.tsx | 243 +++++++++++-------- client/src/pages/admin/CTI.tsx | 60 ++--- client/src/pages/admin/Users.tsx | 68 +++--- client/src/types/index.ts | 2 +- server/prisma/schema.prisma | 1 + server/src/middleware/auth.ts | 12 + server/src/routes/comments.ts | 9 +- server/src/routes/tickets.ts | 11 +- server/src/routes/users.ts | 4 +- 21 files changed, 821 insertions(+), 388 deletions(-) create mode 100644 client/src/pages/MyTickets.tsx diff --git a/README.md b/README.md index 1848b9e..8bc6603 100644 --- a/README.md +++ b/README.md @@ -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 1–5** — 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 ` (obtained from `POST /api/auth/login`) +- **API Key**: `X-Api-Key: sk_` (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=` +#### `GET /api/cti/items?typeId=` + +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_ 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`, `` | -| `$REGISTRY/josh/ticketing-client` | `latest`, `` | - -**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= docker compose up -d -``` - ---- - ## Environment Variables | Variable | Required | Description | @@ -174,7 +409,6 @@ TAG= 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= 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. diff --git a/client/src/App.tsx b/client/src/App.tsx index abf032b..9786b31 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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,7 +17,7 @@ export default function App() { } /> }> } /> - } /> + } /> } /> }> } /> diff --git a/client/src/components/CTISelect.tsx b/client/src/components/CTISelect.tsx index 6198632..a029919 100644 --- a/client/src/components/CTISelect.tsx +++ b/client/src/components/CTISelect.tsx @@ -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 (
- + handleType(e.target.value)} @@ -90,7 +90,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
- + 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" />
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} > {[1, 2, 3, 4, 5].map((s) => ( @@ -91,20 +90,33 @@ export default function Dashboard() { ))} + +
{/* Ticket list */} {loading ? ( -
Loading...
+
Loading...
) : tickets.length === 0 ? ( -
No tickets found
+
No tickets found
) : ( -
+
{tickets.map((ticket) => ( {/* Severity stripe */}
-
- +
+ {ticket.displayId} - + {ticket.category.name} › {ticket.type.name} › {ticket.item.name}
-

+

{ticket.title}

- {ticket.assignee && ( + {ticket.assignee ? (
{ticket.assignee.displayName}
+ ) : ( + Unassigned )} - {!ticket.assignee && ( - Unassigned - )} - + {ticket._count?.comments ?? 0} comments - + {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index b22c5db..51cb564 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -25,25 +25,25 @@ export default function Login() { } return ( -
+

Ticketing System

-

Sign in to your account

+

Sign in to your account

{error && ( -
+
{error}
)}
-
-
diff --git a/client/src/pages/MyTickets.tsx b/client/src/pages/MyTickets.tsx new file mode 100644 index 0000000..a24daeb --- /dev/null +++ b/client/src/pages/MyTickets.tsx @@ -0,0 +1,85 @@ +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([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!user) return + api + .get('/tickets', { params: { assigneeId: user.id } }) + .then((r) => setTickets(r.data)) + .finally(() => setLoading(false)) + }, [user]) + + return ( + + {loading ? ( +
Loading...
+ ) : tickets.length === 0 ? ( +
+ No tickets assigned to you +
+ ) : ( +
+ {tickets.map((ticket) => ( + + {/* Severity stripe */} +
+ +
+
+ + {ticket.displayId} + + + + + {ticket.category.name} › {ticket.type.name} › {ticket.item.name} + +
+

+ {ticket.title} +

+
+ +
+ + {ticket._count?.comments ?? 0} comments + + + {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })} + +
+ + ))} +
+ )} + + ) +} diff --git a/client/src/pages/NewTicket.tsx b/client/src/pages/NewTicket.tsx index ed607bc..d5c1ec0 100644 --- a/client/src/pages/NewTicket.tsx +++ b/client/src/pages/NewTicket.tsx @@ -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([]) const [error, setError] = useState('') @@ -49,6 +53,7 @@ export default function NewTicket() { if (form.assigneeId) payload.assigneeId = form.assigneeId const res = await api.post('/tickets', payload) + onClose() navigate(`/tickets/${res.data.displayId}`) } catch { setError('Failed to create ticket') @@ -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 ( - -
- - {error && ( -
- {error} -
- )} + + + {error && ( +
+ {error} +
+ )} +
+ + setForm((f) => ({ ...f, title: e.target.value }))} + required + className={inputClass} + placeholder="Brief description of the issue" + autoFocus + /> +
+ +
+ +