Initial commit: TicketingSystem
Internal ticketing app with CTI routing, severity levels, and n8n integration. Stack: Express + TypeScript + Prisma + PostgreSQL / React + Vite + Tailwind - JWT auth for users, API key auth for service accounts (Goddard/n8n) - CTI hierarchy (Category > Type > Item) for ticket routing - Severity 1-5, auto-close resolved tickets after 14 days - Gitea Actions CI/CD building separate server/client images - Production docker-compose.yml with Traefik integration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# ── Registry ──────────────────────────────────────────────────────────────────
|
||||||
|
# Hostname of your container registry (no trailing slash)
|
||||||
|
REGISTRY=gitea.thewrightserver.net
|
||||||
|
|
||||||
|
# Image tag to deploy (default: latest)
|
||||||
|
TAG=latest
|
||||||
|
|
||||||
|
# ── PostgreSQL ────────────────────────────────────────────────────────────────
|
||||||
|
POSTGRES_PASSWORD=change-this-to-a-strong-password
|
||||||
|
|
||||||
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Generate with: openssl rand -hex 64
|
||||||
|
JWT_SECRET=change-this-to-a-long-random-string
|
||||||
|
|
||||||
|
# Fully-qualified domain — must match your Traefik routing rule
|
||||||
|
DOMAIN=tickets.thewrightserver.net
|
||||||
63
.gitea/workflows/build.yml
Normal file
63
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build & Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-server:
|
||||||
|
name: Build Server
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.OWNER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push server
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./server
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:${{ github.sha }}
|
||||||
|
|
||||||
|
build-client:
|
||||||
|
name: Build Client
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.OWNER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push client
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./client
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:${{ github.sha }}
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
213
README.md
Normal file
213
README.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# TicketingSystem
|
||||||
|
|
||||||
|
Internal ticketing system with CTI-based routing, severity levels, and n8n/automation integration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **CTI routing** — tickets are 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)
|
||||||
|
- **Admin panel** — manage users and the full CTI hierarchy via UI
|
||||||
|
- **n8n ready** — service accounts authenticate via `X-Api-Key` header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Traefik running with a `proxy` Docker network
|
||||||
|
- Access to your Gitea container registry
|
||||||
|
|
||||||
|
### 1. Copy files to your server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp docker-compose.yml .env.example user@your-server:~/ticketing/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/ticketing
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REGISTRY=gitea.thewrightserver.net
|
||||||
|
TAG=latest
|
||||||
|
POSTGRES_PASSWORD=<strong password>
|
||||||
|
JWT_SECRET=<output of: openssl rand -hex 64>
|
||||||
|
DOMAIN=tickets.thewrightserver.net
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the initial database migration
|
||||||
|
|
||||||
|
Run this **once** on your local dev machine before first deploy, then commit the result:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
# point at your local postgres or use DATABASE_URL from .env
|
||||||
|
npm run db:migrate # creates prisma/migrations/
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the generated `server/prisma/migrations/` folder — `prisma migrate deploy` in the container will apply it on startup.
|
||||||
|
|
||||||
|
### 4. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Seed (first deploy only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec server npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `admin` user (password: `admin123`) — **change this immediately**
|
||||||
|
- `goddard` service account — API key is printed to the console; copy it now
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- PostgreSQL (local or via Docker)
|
||||||
|
|
||||||
|
### Start Postgres
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name ticketing-pg \
|
||||||
|
-e POSTGRES_DB=ticketing \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:16-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:seed # seeds admin + Goddard + sample CTI
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 (proxies /api to :3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## n8n Integration (Goddard)
|
||||||
|
|
||||||
|
The `goddard` service account authenticates via API key — no login flow needed.
|
||||||
|
|
||||||
|
**Create a ticket from n8n:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://tickets.thewrightserver.net/api/tickets
|
||||||
|
X-Api-Key: sk_<goddard api key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "[Plex] Backup - 2026-03-30T02:00:00",
|
||||||
|
"overview": "Automated nightly Plex backup completed.",
|
||||||
|
"severity": 5,
|
||||||
|
"categoryId": "<TheWrightServer category ID>",
|
||||||
|
"typeId": "<Automation type ID>",
|
||||||
|
"itemId": "<Backup item ID>",
|
||||||
|
"assigneeId": "<Goddard user ID>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CTI IDs can be fetched from:
|
||||||
|
- `GET /api/cti/categories`
|
||||||
|
- `GET /api/cti/types?categoryId=<id>`
|
||||||
|
- `GET /api/cti/items?typeId=<id>`
|
||||||
|
|
||||||
|
To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | Yes | PostgreSQL connection string |
|
||||||
|
| `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` |
|
||||||
|
| `CLIENT_URL` | Yes | Allowed CORS origin (your domain) |
|
||||||
|
| `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`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket Severity
|
||||||
|
|
||||||
|
| Level | Label | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | SEV 1 | Critical — immediate action required |
|
||||||
|
| 2 | SEV 2 | High — significant impact |
|
||||||
|
| 3 | SEV 3 | Medium — standard priority |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket Status Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
|
||||||
|
↑
|
||||||
|
re-opens reset
|
||||||
|
the 14-day timer
|
||||||
|
```
|
||||||
4
client/.dockerignore
Normal file
4
client/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
12
client/Dockerfile
Normal file
12
client/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ticketing System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
client/nginx.conf
Normal file
18
client/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# React Router — serve index.html for all non-asset paths
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API calls to the backend
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://server:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "ticketing-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
32
client/src/App.tsx
Normal file
32
client/src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import PrivateRoute from './components/PrivateRoute'
|
||||||
|
import AdminRoute from './components/AdminRoute'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import TicketDetail from './pages/TicketDetail'
|
||||||
|
import NewTicket from './pages/NewTicket'
|
||||||
|
import AdminUsers from './pages/admin/Users'
|
||||||
|
import AdminCTI from './pages/admin/CTI'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<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 element={<AdminRoute />}>
|
||||||
|
<Route path="/admin/users" element={<AdminUsers />} />
|
||||||
|
<Route path="/admin/cti" element={<AdminCTI />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
client/src/api/client.ts
Normal file
7
client/src/api/client.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default api
|
||||||
7
client/src/components/AdminRoute.tsx
Normal file
7
client/src/components/AdminRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export default function AdminRoute() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />
|
||||||
|
}
|
||||||
110
client/src/components/CTISelect.tsx
Normal file
110
client/src/components/CTISelect.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import { Category, CTIType, Item } from '../types'
|
||||||
|
|
||||||
|
interface CTISelectProps {
|
||||||
|
value: { categoryId: string; typeId: string; itemId: string }
|
||||||
|
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [types, setTypes] = useState<CTIType[]>([])
|
||||||
|
const [items, setItems] = useState<Item[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value.categoryId) {
|
||||||
|
setTypes([])
|
||||||
|
setItems([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
|
||||||
|
.then((r) => setTypes(r.data))
|
||||||
|
}, [value.categoryId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value.typeId) {
|
||||||
|
setItems([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
|
||||||
|
.then((r) => setItems(r.data))
|
||||||
|
}, [value.typeId])
|
||||||
|
|
||||||
|
const handleCategory = (categoryId: string) => {
|
||||||
|
onChange({ categoryId, typeId: '', itemId: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleType = (typeId: string) => {
|
||||||
|
onChange({ ...value, typeId, itemId: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItem = (itemId: string) => {
|
||||||
|
onChange({ ...value, itemId })
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
value={value.categoryId}
|
||||||
|
onChange={(e) => handleCategory(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={value.typeId}
|
||||||
|
onChange={(e) => handleType(e.target.value)}
|
||||||
|
disabled={disabled || !value.categoryId}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{types.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Item</label>
|
||||||
|
<select
|
||||||
|
value={value.itemId}
|
||||||
|
onChange={(e) => handleItem(e.target.value)}
|
||||||
|
disabled={disabled || !value.typeId}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{items.map((i) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
{i.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
client/src/components/Layout.tsx
Normal file
88
client/src/components/Layout.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { LayoutDashboard, Users, Settings, LogOut, Plus } from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
title?: string
|
||||||
|
action?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children, title, action }: LayoutProps) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
...(user?.role === 'ADMIN'
|
||||||
|
? [
|
||||||
|
{ to: '/admin/users', icon: Users, label: 'Users' },
|
||||||
|
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-3 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
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<LogOut size={15} />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 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>}
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
client/src/components/Modal.tsx
Normal file
38
client/src/components/Modal.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
title: string
|
||||||
|
onClose: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ title, onClose, children }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
client/src/components/PrivateRoute.tsx
Normal file
16
client/src/components/PrivateRoute.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export default function PrivateRoute() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-gray-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <Outlet /> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
16
client/src/components/SeverityBadge.tsx
Normal file
16
client/src/components/SeverityBadge.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeverityBadge({ severity }: { severity: number }) {
|
||||||
|
const { label, className } = config[severity] ?? config[5]
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
client/src/components/StatusBadge.tsx
Normal file
17
client/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: { status: TicketStatus }) {
|
||||||
|
const { label, className } = config[status]
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
client/src/contexts/AuthContext.tsx
Normal file
59
client/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import { User } from '../types'
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>(null!)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||||
|
api
|
||||||
|
.get<User>('/auth/me')
|
||||||
|
.then((res) => setUser(res.data))
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
delete api.defaults.headers.common['Authorization']
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const res = await api.post<{ token: string; user: User }>('/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
const { token, user } = res.data
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||||
|
setUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
delete api.defaults.headers.common['Authorization']
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
3
client/src/index.css
Normal file
3
client/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
150
client/src/pages/Dashboard.tsx
Normal file
150
client/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Plus, Search } 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 { Ticket, TicketStatus } from '../types'
|
||||||
|
|
||||||
|
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'All Statuses' },
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [status, setStatus] = useState<TicketStatus | ''>('')
|
||||||
|
const [severity, setSeverity] = useState('')
|
||||||
|
|
||||||
|
const fetchTickets = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (status) params.status = status
|
||||||
|
if (severity) params.severity = severity
|
||||||
|
if (search) params.search = search
|
||||||
|
api
|
||||||
|
.get<Ticket[]>('/tickets', { params })
|
||||||
|
.then((r) => setTickets(r.data))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [status, severity, search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(fetchTickets, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [fetchTickets])
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 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} />
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<option value="">All Severities</option>
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
SEV {s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-400 text-sm">No tickets found</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<Link
|
||||||
|
key={ticket.id}
|
||||||
|
to={`/tickets/${ticket.id}`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* Severity stripe */}
|
||||||
|
<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-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<SeverityBadge severity={ticket.severity} />
|
||||||
|
<StatusBadge status={ticket.status} />
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{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">
|
||||||
|
{ticket.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate mt-0.5">{ticket.overview}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right text-xs text-gray-400 flex-shrink-0 space-y-0.5">
|
||||||
|
<div className="font-medium text-gray-600">
|
||||||
|
{ticket.assignee?.displayName ?? 'Unassigned'}
|
||||||
|
</div>
|
||||||
|
<div>{ticket._count?.comments ?? 0} comments</div>
|
||||||
|
<div>{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
client/src/pages/Login.tsx
Normal file
83
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
navigate('/')
|
||||||
|
} catch {
|
||||||
|
setError('Invalid username or password')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-white rounded-xl shadow-xl 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">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
client/src/pages/NewTicket.tsx
Normal file
161
client/src/pages/NewTicket.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import api from '../api/client'
|
||||||
|
import Layout from '../components/Layout'
|
||||||
|
import CTISelect from '../components/CTISelect'
|
||||||
|
import { User } from '../types'
|
||||||
|
|
||||||
|
export default function NewTicket() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '',
|
||||||
|
overview: '',
|
||||||
|
severity: 3,
|
||||||
|
assigneeId: '',
|
||||||
|
categoryId: '',
|
||||||
|
typeId: '',
|
||||||
|
itemId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<User[]>('/users').then((r) => setUsers(r.data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCTI = (cti: { categoryId: string; typeId: string; itemId: string }) => {
|
||||||
|
setForm((f) => ({ ...f, ...cti }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.categoryId || !form.typeId || !form.itemId) {
|
||||||
|
setError('Please select a Category, Type, and Item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title: form.title,
|
||||||
|
overview: form.overview,
|
||||||
|
severity: form.severity,
|
||||||
|
categoryId: form.categoryId,
|
||||||
|
typeId: form.typeId,
|
||||||
|
itemId: form.itemId,
|
||||||
|
}
|
||||||
|
if (form.assigneeId) payload.assigneeId = form.assigneeId
|
||||||
|
|
||||||
|
const res = await api.post('/tickets', payload)
|
||||||
|
navigate(`/tickets/${res.data.id}`)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to create ticket')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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..."
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
391
client/src/pages/TicketDetail.tsx
Normal file
391
client/src/pages/TicketDetail.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Pencil, Trash2, Send, X, Check } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import Layout from '../components/Layout'
|
||||||
|
import SeverityBadge from '../components/SeverityBadge'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import CTISelect from '../components/CTISelect'
|
||||||
|
import { Ticket, TicketStatus, User, Comment } from '../types'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: TicketStatus; label: string }[] = [
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'RESOLVED', label: 'Resolved' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TicketDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user: authUser } = useAuth()
|
||||||
|
|
||||||
|
const [ticket, setTicket] = useState<Ticket | null>(null)
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [commentBody, setCommentBody] = useState('')
|
||||||
|
const [submittingComment, setSubmittingComment] = useState(false)
|
||||||
|
const commentRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
title: '',
|
||||||
|
overview: '',
|
||||||
|
severity: 3,
|
||||||
|
assigneeId: '',
|
||||||
|
categoryId: '',
|
||||||
|
typeId: '',
|
||||||
|
itemId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<Ticket>(`/tickets/${id}`),
|
||||||
|
api.get<User[]>('/users'),
|
||||||
|
]).then(([tRes, uRes]) => {
|
||||||
|
setTicket(tRes.data)
|
||||||
|
setUsers(uRes.data)
|
||||||
|
}).finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
if (!ticket) return
|
||||||
|
setEditForm({
|
||||||
|
title: ticket.title,
|
||||||
|
overview: ticket.overview,
|
||||||
|
severity: ticket.severity,
|
||||||
|
assigneeId: ticket.assigneeId ?? '',
|
||||||
|
categoryId: ticket.categoryId,
|
||||||
|
typeId: ticket.typeId,
|
||||||
|
itemId: ticket.itemId,
|
||||||
|
})
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!ticket) return
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title: editForm.title,
|
||||||
|
overview: editForm.overview,
|
||||||
|
severity: editForm.severity,
|
||||||
|
categoryId: editForm.categoryId,
|
||||||
|
typeId: editForm.typeId,
|
||||||
|
itemId: editForm.itemId,
|
||||||
|
assigneeId: editForm.assigneeId || null,
|
||||||
|
}
|
||||||
|
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, payload)
|
||||||
|
setTicket(res.data)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus = async (status: TicketStatus) => {
|
||||||
|
if (!ticket) return
|
||||||
|
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, { status })
|
||||||
|
setTicket(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAssignee = async (assigneeId: string) => {
|
||||||
|
if (!ticket) return
|
||||||
|
const res = await api.patch<Ticket>(`/tickets/${ticket.id}`, {
|
||||||
|
assigneeId: assigneeId || null,
|
||||||
|
})
|
||||||
|
setTicket(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTicket = async () => {
|
||||||
|
if (!ticket || !confirm('Delete this ticket?')) return
|
||||||
|
await api.delete(`/tickets/${ticket.id}`)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!ticket || !commentBody.trim()) return
|
||||||
|
setSubmittingComment(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post<Comment>(`/tickets/${ticket.id}/comments`, {
|
||||||
|
body: commentBody.trim(),
|
||||||
|
})
|
||||||
|
setTicket((t) =>
|
||||||
|
t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t
|
||||||
|
)
|
||||||
|
setCommentBody('')
|
||||||
|
} finally {
|
||||||
|
setSubmittingComment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: string) => {
|
||||||
|
if (!ticket) return
|
||||||
|
await api.delete(`/tickets/${ticket.id}/comments/${commentId}`)
|
||||||
|
setTicket((t) =>
|
||||||
|
t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="text-center py-16 text-gray-400 text-sm">Loading...</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="text-center py-16 text-gray-400 text-sm">Ticket not found</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={ticket.title}
|
||||||
|
action={
|
||||||
|
authUser?.role === 'ADMIN' ? (
|
||||||
|
<button
|
||||||
|
onClick={deleteTicket}
|
||||||
|
className="flex items-center gap-2 text-sm text-red-600 hover:text-red-800 border border-red-200 hover:border-red-400 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl space-y-5">
|
||||||
|
{/* Ticket card */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl">
|
||||||
|
{/* Header bar */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SeverityBadge severity={ticket.severity} />
|
||||||
|
<StatusBadge status={ticket.status} />
|
||||||
|
{!editing && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{ticket.category.name} › {ticket.type.name} › {ticket.item.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!editing ? (
|
||||||
|
<button
|
||||||
|
onClick={startEdit}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-800 border border-gray-200 hover:border-gray-400 px-2.5 py-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2.5 py-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-blue-600 text-white px-2.5 py-1 rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.title}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Overview</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.overview}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Severity</label>
|
||||||
|
<select
|
||||||
|
value={editForm.severity}
|
||||||
|
onChange={(e) => setEditForm((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="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
|
||||||
|
<select
|
||||||
|
value={editForm.assigneeId}
|
||||||
|
onChange={(e) => setEditForm((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="block text-xs font-medium text-gray-500 mb-1">Routing (CTI)</label>
|
||||||
|
<CTISelect
|
||||||
|
value={{ categoryId: editForm.categoryId, typeId: editForm.typeId, itemId: editForm.itemId }}
|
||||||
|
onChange={(cti) => setEditForm((f) => ({ ...f, ...cti }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{ticket.overview}</p>
|
||||||
|
|
||||||
|
{/* Quick controls */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={ticket.status}
|
||||||
|
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Assignee</label>
|
||||||
|
<select
|
||||||
|
value={ticket.assigneeId ?? ''}
|
||||||
|
onChange={(e) => updateAssignee(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>
|
||||||
|
|
||||||
|
{/* Metadata footer */}
|
||||||
|
<div className="px-5 py-3 bg-gray-50 rounded-b-xl border-t border-gray-100 flex items-center gap-6 text-xs text-gray-400">
|
||||||
|
<span>Created by <strong className="text-gray-600">{ticket.createdBy.displayName}</strong></span>
|
||||||
|
<span>{format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')}</span>
|
||||||
|
{ticket.resolvedAt && (
|
||||||
|
<span>Resolved {format(new Date(ticket.resolvedAt), 'MMM d, yyyy')}</span>
|
||||||
|
)}
|
||||||
|
<span>Updated {format(new Date(ticket.updatedAt), 'MMM d, yyyy HH:mm')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl">
|
||||||
|
<div className="px-5 py-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
Comments ({ticket.comments?.length ?? 0})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.comments && ticket.comments.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{ticket.comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="px-5 py-4 group">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{comment.author.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{comment.body}</p>
|
||||||
|
</div>
|
||||||
|
{(comment.authorId === authUser?.id || authUser?.role === 'ADMIN') && (
|
||||||
|
<button
|
||||||
|
onClick={() => deleteComment(comment.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-500 transition-all flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-8 text-center text-xs text-gray-400">No comments yet</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment form */}
|
||||||
|
<form onSubmit={submitComment} className="px-5 py-4 border-t border-gray-100">
|
||||||
|
<textarea
|
||||||
|
ref={commentRef}
|
||||||
|
value={commentBody}
|
||||||
|
onChange={(e) => setCommentBody(e.target.value)}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitComment(e as unknown as React.FormEvent)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submittingComment || !commentBody.trim()}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={12} />
|
||||||
|
Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
320
client/src/pages/admin/CTI.tsx
Normal file
320
client/src/pages/admin/CTI.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react'
|
||||||
|
import api from '../../api/client'
|
||||||
|
import Layout from '../../components/Layout'
|
||||||
|
import Modal from '../../components/Modal'
|
||||||
|
import { Category, CTIType, Item } from '../../types'
|
||||||
|
|
||||||
|
type PanelItem = { id: string; name: string }
|
||||||
|
|
||||||
|
interface NameModalState {
|
||||||
|
open: boolean
|
||||||
|
mode: 'add' | 'edit'
|
||||||
|
panel: 'category' | 'type' | 'item'
|
||||||
|
item?: PanelItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCTI() {
|
||||||
|
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 [nameModal, setNameModal] = useState<NameModalState>({ open: false, mode: 'add', panel: 'category' })
|
||||||
|
const [nameValue, setNameValue] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const fetchCategories = () =>
|
||||||
|
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
||||||
|
|
||||||
|
const fetchTypes = (categoryId: string) =>
|
||||||
|
api
|
||||||
|
.get<CTIType[]>('/cti/types', { params: { categoryId } })
|
||||||
|
.then((r) => setTypes(r.data))
|
||||||
|
|
||||||
|
const fetchItems = (typeId: string) =>
|
||||||
|
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data))
|
||||||
|
|
||||||
|
useEffect(() => { fetchCategories() }, [])
|
||||||
|
|
||||||
|
const selectCategory = (cat: Category) => {
|
||||||
|
setSelectedCategory(cat)
|
||||||
|
setSelectedType(null)
|
||||||
|
setItems([])
|
||||||
|
fetchTypes(cat.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectType = (type: CTIType) => {
|
||||||
|
setSelectedType(type)
|
||||||
|
fetchItems(type.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAdd = (panel: 'category' | 'type' | 'item') => {
|
||||||
|
setNameValue('')
|
||||||
|
setNameModal({ open: true, mode: 'add', panel })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
|
||||||
|
setNameValue(item.name)
|
||||||
|
setNameModal({ open: true, mode: 'edit', panel, item })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => setNameModal((m) => ({ ...m, open: false }))
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!nameValue.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { mode, panel, item } = nameModal
|
||||||
|
if (mode === 'add') {
|
||||||
|
if (panel === 'category') {
|
||||||
|
await api.post('/cti/categories', { name: nameValue.trim() })
|
||||||
|
await fetchCategories()
|
||||||
|
} else if (panel === 'type' && selectedCategory) {
|
||||||
|
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id })
|
||||||
|
await fetchTypes(selectedCategory.id)
|
||||||
|
} else if (panel === 'item' && selectedType) {
|
||||||
|
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id })
|
||||||
|
await fetchItems(selectedType.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!item) return
|
||||||
|
if (panel === 'category') {
|
||||||
|
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() })
|
||||||
|
await fetchCategories()
|
||||||
|
if (selectedCategory?.id === item.id) setSelectedCategory((c) => c ? { ...c, name: nameValue.trim() } : c)
|
||||||
|
} else if (panel === 'type') {
|
||||||
|
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() })
|
||||||
|
if (selectedCategory) await fetchTypes(selectedCategory.id)
|
||||||
|
if (selectedType?.id === item.id) setSelectedType((t) => t ? { ...t, name: nameValue.trim() } : t)
|
||||||
|
} else if (panel === 'item') {
|
||||||
|
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() })
|
||||||
|
if (selectedType) await fetchItems(selectedType.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
|
||||||
|
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return
|
||||||
|
if (panel === 'category') {
|
||||||
|
await api.delete(`/cti/categories/${item.id}`)
|
||||||
|
if (selectedCategory?.id === item.id) {
|
||||||
|
setSelectedCategory(null)
|
||||||
|
setSelectedType(null)
|
||||||
|
setTypes([])
|
||||||
|
setItems([])
|
||||||
|
}
|
||||||
|
await fetchCategories()
|
||||||
|
} else if (panel === 'type') {
|
||||||
|
await api.delete(`/cti/types/${item.id}`)
|
||||||
|
if (selectedType?.id === item.id) {
|
||||||
|
setSelectedType(null)
|
||||||
|
setItems([])
|
||||||
|
}
|
||||||
|
if (selectedCategory) await fetchTypes(selectedCategory.id)
|
||||||
|
} else {
|
||||||
|
await api.delete(`/cti/items/${item.id}`)
|
||||||
|
if (selectedType) await fetchItems(selectedType.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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'
|
||||||
|
}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="CTI Configuration">
|
||||||
|
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]">
|
||||||
|
{/* Categories */}
|
||||||
|
<div className={panelClass}>
|
||||||
|
<div className={panelHeaderClass}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => openAdd('category')}
|
||||||
|
className="text-blue-600 hover:text-blue-800 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>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className={itemClass(selectedCategory?.id === cat.id)}
|
||||||
|
onClick={() => selectCategory(cat)}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-800">{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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Types */}
|
||||||
|
<div className={panelClass}>
|
||||||
|
<div className={panelHeaderClass}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
Types
|
||||||
|
{selectedCategory && (
|
||||||
|
<span className="ml-1 font-normal text-gray-400">— {selectedCategory.name}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{selectedCategory && (
|
||||||
|
<button
|
||||||
|
onClick={() => openAdd('type')}
|
||||||
|
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{!selectedCategory ? (
|
||||||
|
<p className="text-xs text-gray-400 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>
|
||||||
|
) : (
|
||||||
|
types.map((type) => (
|
||||||
|
<div
|
||||||
|
key={type.id}
|
||||||
|
className={itemClass(selectedType?.id === type.id)}
|
||||||
|
onClick={() => selectType(type)}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-800">{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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={14} className="text-gray-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className={panelClass}>
|
||||||
|
<div className={panelHeaderClass}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
Items
|
||||||
|
{selectedType && (
|
||||||
|
<span className="ml-1 font-normal text-gray-400">— {selectedType.name}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{selectedType && (
|
||||||
|
<button
|
||||||
|
onClick={() => openAdd('item')}
|
||||||
|
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{!selectedType ? (
|
||||||
|
<p className="text-xs text-gray-400 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>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
|
||||||
|
<span className="text-sm text-gray-800">{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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name modal */}
|
||||||
|
{nameModal.open && (
|
||||||
|
<Modal
|
||||||
|
title={`${nameModal.mode === 'add' ? 'Add' : 'Rename'} ${nameModal.panel.charAt(0).toUpperCase() + nameModal.panel.slice(1)}`}
|
||||||
|
onClose={closeModal}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
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 ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
346
client/src/pages/admin/Users.tsx
Normal file
346
client/src/pages/admin/Users.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react'
|
||||||
|
import api from '../../api/client'
|
||||||
|
import Layout from '../../components/Layout'
|
||||||
|
import Modal from '../../components/Modal'
|
||||||
|
import { User, Role } from '../../types'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
interface UserForm {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
password: string
|
||||||
|
role: Role
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK_FORM: UserForm = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
displayName: '',
|
||||||
|
password: '',
|
||||||
|
role: 'AGENT',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
ADMIN: 'Admin',
|
||||||
|
AGENT: 'Agent',
|
||||||
|
SERVICE: 'Service',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUsers() {
|
||||||
|
const { user: authUser } = useAuth()
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [modal, setModal] = useState<'add' | 'edit' | null>(null)
|
||||||
|
const [selected, setSelected] = useState<User | null>(null)
|
||||||
|
const [form, setForm] = useState<UserForm>(BLANK_FORM)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
|
const [newApiKey, setNewApiKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchUsers = () => {
|
||||||
|
api.get<User[]>('/users').then((r) => setUsers(r.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchUsers() }, [])
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setForm(BLANK_FORM)
|
||||||
|
setError('')
|
||||||
|
setNewApiKey(null)
|
||||||
|
setModal('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (u: User) => {
|
||||||
|
setSelected(u)
|
||||||
|
setForm({ username: u.username, email: u.email, displayName: u.displayName, password: '', role: u.role })
|
||||||
|
setError('')
|
||||||
|
setNewApiKey(null)
|
||||||
|
setModal('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModal(null)
|
||||||
|
setSelected(null)
|
||||||
|
setNewApiKey(null)
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
username: form.username,
|
||||||
|
email: form.email,
|
||||||
|
displayName: form.displayName,
|
||||||
|
role: form.role,
|
||||||
|
}
|
||||||
|
if (form.password) payload.password = form.password
|
||||||
|
const res = await api.post<User>('/users', payload)
|
||||||
|
if (res.data.apiKey) setNewApiKey(res.data.apiKey)
|
||||||
|
else closeModal()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { response?: { data?: { error?: string } } }
|
||||||
|
setError(e.response?.data?.error ?? 'Failed to create user')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selected) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
email: form.email,
|
||||||
|
displayName: form.displayName,
|
||||||
|
role: form.role,
|
||||||
|
}
|
||||||
|
if (form.password) payload.password = form.password
|
||||||
|
await api.patch(`/users/${selected.id}`, payload)
|
||||||
|
closeModal()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { response?: { data?: { error?: string } } }
|
||||||
|
setError(e.response?.data?.error ?? 'Failed to update user')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (u: User) => {
|
||||||
|
if (!confirm(`Delete user "${u.displayName}"?`)) return
|
||||||
|
await api.delete(`/users/${u.id}`)
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegenerateKey = async (u: User) => {
|
||||||
|
if (!confirm(`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`)) return
|
||||||
|
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true })
|
||||||
|
setNewApiKey(res.data.apiKey ?? null)
|
||||||
|
setSelected(u)
|
||||||
|
setModal('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (key: string) => {
|
||||||
|
navigator.clipboard.writeText(key)
|
||||||
|
setCopiedKey(key)
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Users"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
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={14} />
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{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>
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ROLE_LABELS[u.role]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-gray-500">{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"
|
||||||
|
title="Regenerate API key"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(u)}
|
||||||
|
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
{u.id !== authUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
className="text-gray-400 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add / Edit Modal */}
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={modal === 'add' ? 'Add User' : `Edit ${selected?.displayName}`}
|
||||||
|
onClose={closeModal}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
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">
|
||||||
|
{newApiKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(newApiKey)}
|
||||||
|
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
|
||||||
|
>
|
||||||
|
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === 'add' && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>
|
||||||
|
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
required={modal === 'add' && form.role !== 'SERVICE'}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={modal === 'edit' ? '••••••••' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Role</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as Role }))}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="AGENT">Agent</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
<option value="SERVICE">Service (API key auth)</option>
|
||||||
|
</select>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
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 ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
client/src/types/index.ts
Normal file
63
client/src/types/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type Role = 'ADMIN' | 'AGENT' | 'SERVICE'
|
||||||
|
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
role: Role
|
||||||
|
apiKey?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CTIType {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
categoryId: string
|
||||||
|
category?: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
typeId: string
|
||||||
|
type?: CTIType & { category?: Category }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string
|
||||||
|
body: string
|
||||||
|
ticketId: string
|
||||||
|
authorId: string
|
||||||
|
author: Pick<User, 'id' | 'username' | 'displayName'>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
overview: string
|
||||||
|
severity: number
|
||||||
|
status: TicketStatus
|
||||||
|
categoryId: string
|
||||||
|
typeId: string
|
||||||
|
itemId: string
|
||||||
|
assigneeId: string | null
|
||||||
|
createdById: string
|
||||||
|
resolvedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
category: Category
|
||||||
|
type: CTIType
|
||||||
|
item: Item
|
||||||
|
assignee: Pick<User, 'id' | 'username' | 'displayName'> | null
|
||||||
|
createdBy: Pick<User, 'id' | 'username' | 'displayName'>
|
||||||
|
comments?: Comment[]
|
||||||
|
_count?: { comments: number }
|
||||||
|
}
|
||||||
8
client/tailwind.config.js
Normal file
8
client/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
20
client/tsconfig.json
Normal file
20
client/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
11
client/vite.config.ts
Normal file
11
client/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# TicketingSystem — Production
|
||||||
|
# Copy this file + .env to your server, then:
|
||||||
|
# docker compose pull && docker compose up -d
|
||||||
|
# First deploy only:
|
||||||
|
# docker compose exec server npm run db:seed
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ticketing
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
server:
|
||||||
|
image: ${REGISTRY}/josh/ticketing-server:${TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/ticketing
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
CLIENT_URL: https://${DOMAIN}
|
||||||
|
PORT: 3000
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
client:
|
||||||
|
image: ${REGISTRY}/josh/ticketing-client:${TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- proxy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.tickets.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.tickets.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.tickets.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.tickets.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
proxy:
|
||||||
|
external: true # Traefik's proxy network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
4
server/.dockerignore
Normal file
4
server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
4
server/.env.example
Normal file
4
server/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ticketing"
|
||||||
|
JWT_SECRET="change-this-to-a-long-random-secret"
|
||||||
|
CLIENT_URL="http://localhost:5173"
|
||||||
|
PORT=3000
|
||||||
15
server/Dockerfile
Normal file
15
server/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
COPY package*.json ./
|
||||||
|
CMD ["npm", "run", "start:prod"]
|
||||||
35
server/package.json
Normal file
35
server/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "ticketing-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"start:prod": "prisma migrate deploy && node dist/index.js",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-async-errors": "^3.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
100
server/prisma/schema.prisma
Normal file
100
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
AGENT
|
||||||
|
SERVICE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TicketStatus {
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
RESOLVED
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
displayName String
|
||||||
|
role Role @default(AGENT)
|
||||||
|
apiKey String? @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignedTickets Ticket[] @relation("AssignedTickets")
|
||||||
|
createdTickets Ticket[] @relation("CreatedTickets")
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
types Type[]
|
||||||
|
tickets Ticket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Type {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
categoryId String
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
items Item[]
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([categoryId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Item {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
typeId String
|
||||||
|
type Type @relation(fields: [typeId], references: [id], onDelete: Cascade)
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([typeId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Ticket {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
overview String
|
||||||
|
severity Int
|
||||||
|
status TicketStatus @default(OPEN)
|
||||||
|
categoryId String
|
||||||
|
typeId String
|
||||||
|
itemId String
|
||||||
|
assigneeId String?
|
||||||
|
createdById String
|
||||||
|
|
||||||
|
resolvedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
type Type @relation(fields: [typeId], references: [id])
|
||||||
|
item Item @relation(fields: [itemId], references: [id])
|
||||||
|
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
|
||||||
|
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
ticketId String
|
||||||
|
authorId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
}
|
||||||
102
server/prisma/seed.ts
Normal file
102
server/prisma/seed.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding database...')
|
||||||
|
|
||||||
|
// Admin user
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { username: 'admin' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@internal',
|
||||||
|
displayName: 'Admin',
|
||||||
|
passwordHash: await bcrypt.hash('admin123', 12),
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Goddard — n8n service account
|
||||||
|
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
|
||||||
|
const goddard = await prisma.user.upsert({
|
||||||
|
where: { username: 'goddard' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
username: 'goddard',
|
||||||
|
email: 'goddard@internal',
|
||||||
|
displayName: 'Goddard',
|
||||||
|
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
|
||||||
|
role: 'SERVICE',
|
||||||
|
apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } })
|
||||||
|
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`)
|
||||||
|
console.log('(This key is only displayed once on first seed — copy it now)\n')
|
||||||
|
|
||||||
|
// Sample CTI structure
|
||||||
|
const theWrightServer = await prisma.category.upsert({
|
||||||
|
where: { name: 'TheWrightServer' },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'TheWrightServer' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const homelab = await prisma.category.upsert({
|
||||||
|
where: { name: 'Homelab' },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Homelab' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const automation = await prisma.type.upsert({
|
||||||
|
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Automation' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Automation', categoryId: theWrightServer.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const media = await prisma.type.upsert({
|
||||||
|
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Media' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Media', categoryId: theWrightServer.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const infrastructure = await prisma.type.upsert({
|
||||||
|
where: { categoryId_name: { categoryId: homelab.id, name: 'Infrastructure' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Infrastructure', categoryId: homelab.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.item.upsert({
|
||||||
|
where: { typeId_name: { typeId: automation.id, name: 'Backup' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Backup', typeId: automation.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.item.upsert({
|
||||||
|
where: { typeId_name: { typeId: automation.id, name: 'Sync' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Sync', typeId: automation.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.item.upsert({
|
||||||
|
where: { typeId_name: { typeId: media.id, name: 'Plex' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Plex', typeId: media.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.item.upsert({
|
||||||
|
where: { typeId_name: { typeId: infrastructure.id, name: 'Proxmox' } },
|
||||||
|
update: {},
|
||||||
|
create: { name: 'Proxmox', typeId: infrastructure.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Seed complete.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => { console.error(e); process.exit(1) })
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
41
server/src/index.ts
Normal file
41
server/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'express-async-errors'
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth'
|
||||||
|
import ticketRoutes from './routes/tickets'
|
||||||
|
import ctiRoutes from './routes/cti'
|
||||||
|
import userRoutes from './routes/users'
|
||||||
|
import { authenticate } from './middleware/auth'
|
||||||
|
import { errorHandler } from './middleware/errorHandler'
|
||||||
|
import { startAutoCloseJob } from './jobs/autoClose'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.error('FATAL: JWT_SECRET is not set')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }))
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Public
|
||||||
|
app.use('/api/auth', authRoutes)
|
||||||
|
|
||||||
|
// Protected
|
||||||
|
app.use('/api/tickets', authenticate, ticketRoutes)
|
||||||
|
app.use('/api/cti', authenticate, ctiRoutes)
|
||||||
|
app.use('/api/users', authenticate, userRoutes)
|
||||||
|
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
startAutoCloseJob()
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 3000
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`)
|
||||||
|
})
|
||||||
24
server/src/jobs/autoClose.ts
Normal file
24
server/src/jobs/autoClose.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import cron from 'node-cron'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
|
||||||
|
export function startAutoCloseJob() {
|
||||||
|
// Run every hour — closes RESOLVED tickets that have been resolved for 14+ days
|
||||||
|
cron.schedule('0 * * * *', async () => {
|
||||||
|
const twoWeeksAgo = new Date()
|
||||||
|
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
|
||||||
|
|
||||||
|
const result = await prisma.ticket.updateMany({
|
||||||
|
where: {
|
||||||
|
status: 'RESOLVED',
|
||||||
|
resolvedAt: { lte: twoWeeksAgo },
|
||||||
|
},
|
||||||
|
data: { status: 'CLOSED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.count > 0) {
|
||||||
|
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[AutoClose] Job scheduled — runs every hour')
|
||||||
|
}
|
||||||
5
server/src/lib/prisma.ts
Normal file
5
server/src/lib/prisma.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
export default prisma
|
||||||
57
server/src/middleware/auth.ts
Normal file
57
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticate = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
const apiKey = req.headers['x-api-key'] as string | undefined
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { apiKey } })
|
||||||
|
if (!user || user.role !== 'SERVICE') {
|
||||||
|
return res.status(401).json({ error: 'Invalid API key' })
|
||||||
|
}
|
||||||
|
req.user = { id: user.id, role: user.role, username: user.username }
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1]
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
req.user = payload
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired token' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAdmin = (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
if (req.user?.role !== 'ADMIN') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
29
server/src/middleware/errorHandler.ts
Normal file
29
server/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
err: any,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
console.error(err)
|
||||||
|
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return res.status(400).json({ error: 'Validation error', details: err.flatten() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma unique constraint violation
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
return res.status(409).json({ error: 'A record with that value already exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma record not found
|
||||||
|
if (err.code === 'P2025') {
|
||||||
|
return res.status(404).json({ error: 'Record not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = err.status || err.statusCode || 500
|
||||||
|
const message = err.message || 'Internal server error'
|
||||||
|
res.status(status).json({ error: message })
|
||||||
|
}
|
||||||
54
server/src/routes/auth.ts
Normal file
54
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = loginSchema.parse(req.body)
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'SERVICE') {
|
||||||
|
return res.status(401).json({ error: 'Service accounts must authenticate via API key' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, role: user.role, username: user.username },
|
||||||
|
process.env.JWT_SECRET!,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/me', authenticate, async (req: AuthRequest, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
select: { id: true, username: true, displayName: true, email: true, role: true },
|
||||||
|
})
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' })
|
||||||
|
res.json(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
49
server/src/routes/comments.ts
Normal file
49
server/src/routes/comments.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
// mergeParams: true so req.params.ticketId is accessible from the parent router
|
||||||
|
const router = Router({ mergeParams: true })
|
||||||
|
|
||||||
|
const commentSchema = z.object({
|
||||||
|
body: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req: AuthRequest, res) => {
|
||||||
|
const { body } = commentSchema.parse(req.body)
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.findUnique({
|
||||||
|
where: { id: (req.params as any).ticketId },
|
||||||
|
})
|
||||||
|
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
|
||||||
|
|
||||||
|
const comment = await prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
body,
|
||||||
|
ticketId: (req.params as any).ticketId,
|
||||||
|
authorId: req.user!.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, username: true, displayName: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(201).json(comment)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:commentId', async (req: AuthRequest, res) => {
|
||||||
|
const comment = await prisma.comment.findUnique({
|
||||||
|
where: { id: req.params.commentId },
|
||||||
|
})
|
||||||
|
if (!comment) return res.status(404).json({ error: 'Comment not found' })
|
||||||
|
|
||||||
|
if (comment.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
|
||||||
|
return res.status(403).json({ error: 'Not allowed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.comment.delete({ where: { id: req.params.commentId } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
113
server/src/routes/cti.ts
Normal file
113
server/src/routes/cti.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const nameSchema = z.object({ name: z.string().min(1).max(100) })
|
||||||
|
|
||||||
|
// ── Categories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/categories', async (_req, res) => {
|
||||||
|
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } })
|
||||||
|
res.json(categories)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/categories', requireAdmin, async (req, res) => {
|
||||||
|
const { name } = nameSchema.parse(req.body)
|
||||||
|
const category = await prisma.category.create({ data: { name } })
|
||||||
|
res.status(201).json(category)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/categories/:id', requireAdmin, async (req, res) => {
|
||||||
|
const { name } = nameSchema.parse(req.body)
|
||||||
|
const category = await prisma.category.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { name },
|
||||||
|
})
|
||||||
|
res.json(category)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/categories/:id', requireAdmin, async (req, res) => {
|
||||||
|
await prisma.category.delete({ where: { id: req.params.id } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/types', async (req, res) => {
|
||||||
|
const { categoryId } = req.query
|
||||||
|
const types = await prisma.type.findMany({
|
||||||
|
where: categoryId ? { categoryId: categoryId as string } : undefined,
|
||||||
|
include: { category: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
res.json(types)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/types', requireAdmin, async (req, res) => {
|
||||||
|
const { name, categoryId } = z
|
||||||
|
.object({ name: z.string().min(1).max(100), categoryId: z.string().min(1) })
|
||||||
|
.parse(req.body)
|
||||||
|
const type = await prisma.type.create({
|
||||||
|
data: { name, categoryId },
|
||||||
|
include: { category: true },
|
||||||
|
})
|
||||||
|
res.status(201).json(type)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/types/:id', requireAdmin, async (req, res) => {
|
||||||
|
const { name } = nameSchema.parse(req.body)
|
||||||
|
const type = await prisma.type.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { name },
|
||||||
|
include: { category: true },
|
||||||
|
})
|
||||||
|
res.json(type)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/types/:id', requireAdmin, async (req, res) => {
|
||||||
|
await prisma.type.delete({ where: { id: req.params.id } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/items', async (req, res) => {
|
||||||
|
const { typeId } = req.query
|
||||||
|
const items = await prisma.item.findMany({
|
||||||
|
where: typeId ? { typeId: typeId as string } : undefined,
|
||||||
|
include: { type: { include: { category: true } } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
res.json(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/items', requireAdmin, async (req, res) => {
|
||||||
|
const { name, typeId } = z
|
||||||
|
.object({ name: z.string().min(1).max(100), typeId: z.string().min(1) })
|
||||||
|
.parse(req.body)
|
||||||
|
const item = await prisma.item.create({
|
||||||
|
data: { name, typeId },
|
||||||
|
include: { type: { include: { category: true } } },
|
||||||
|
})
|
||||||
|
res.status(201).json(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/items/:id', requireAdmin, async (req, res) => {
|
||||||
|
const { name } = nameSchema.parse(req.body)
|
||||||
|
const item = await prisma.item.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { name },
|
||||||
|
include: { type: { include: { category: true } } },
|
||||||
|
})
|
||||||
|
res.json(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/items/:id', requireAdmin, async (req, res) => {
|
||||||
|
await prisma.item.delete({ where: { id: req.params.id } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
130
server/src/routes/tickets.ts
Normal file
130
server/src/routes/tickets.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { requireAdmin, AuthRequest } from '../middleware/auth'
|
||||||
|
import commentRouter from './comments'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const ticketInclude = {
|
||||||
|
category: true,
|
||||||
|
type: true,
|
||||||
|
item: true,
|
||||||
|
assignee: { select: { id: true, username: true, displayName: true } },
|
||||||
|
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||||
|
comments: {
|
||||||
|
include: { author: { select: { id: true, username: true, displayName: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' as const },
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
overview: z.string().min(1),
|
||||||
|
severity: z.number().int().min(1).max(5),
|
||||||
|
categoryId: z.string().min(1),
|
||||||
|
typeId: z.string().min(1),
|
||||||
|
itemId: z.string().min(1),
|
||||||
|
assigneeId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
title: z.string().min(1).max(255).optional(),
|
||||||
|
overview: z.string().min(1).optional(),
|
||||||
|
severity: z.number().int().min(1).max(5).optional(),
|
||||||
|
status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(),
|
||||||
|
categoryId: z.string().min(1).optional(),
|
||||||
|
typeId: z.string().min(1).optional(),
|
||||||
|
itemId: z.string().min(1).optional(),
|
||||||
|
assigneeId: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mount comment sub-router
|
||||||
|
router.use('/:ticketId/comments', commentRouter)
|
||||||
|
|
||||||
|
// GET /api/tickets
|
||||||
|
router.get('/', async (req: AuthRequest, res) => {
|
||||||
|
const { status, severity, assigneeId, categoryId, search } = req.query
|
||||||
|
|
||||||
|
const where: Record<string, any> = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (severity) where.severity = Number(severity)
|
||||||
|
if (assigneeId) where.assigneeId = assigneeId
|
||||||
|
if (categoryId) where.categoryId = categoryId
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
{ overview: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
type: true,
|
||||||
|
item: true,
|
||||||
|
assignee: { select: { id: true, username: true, displayName: true } },
|
||||||
|
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(tickets)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/tickets/:id
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const ticket = await prisma.ticket.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: ticketInclude,
|
||||||
|
})
|
||||||
|
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
|
||||||
|
res.json(ticket)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/tickets
|
||||||
|
router.post('/', async (req: AuthRequest, res) => {
|
||||||
|
const data = createSchema.parse(req.body)
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.create({
|
||||||
|
data: { ...data, createdById: req.user!.id },
|
||||||
|
include: ticketInclude,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(201).json(ticket)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/tickets/:id
|
||||||
|
router.patch('/:id', async (req: AuthRequest, res) => {
|
||||||
|
const data = updateSchema.parse(req.body)
|
||||||
|
|
||||||
|
const existing = await prisma.ticket.findUnique({ where: { id: req.params.id } })
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Ticket not found' })
|
||||||
|
|
||||||
|
const update: Record<string, any> = { ...data }
|
||||||
|
|
||||||
|
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
|
||||||
|
update.resolvedAt = new Date()
|
||||||
|
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
|
||||||
|
// Re-opening a resolved ticket resets the 2-week auto-close timer
|
||||||
|
update.resolvedAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: update,
|
||||||
|
include: ticketInclude,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(ticket)
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/tickets/:id — admin only
|
||||||
|
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||||
|
await prisma.ticket.delete({ where: { id: req.params.id } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
103
server/src/routes/users.ts
Normal file
103
server/src/routes/users.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { requireAdmin, AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
apiKey: true,
|
||||||
|
createdAt: true,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
router.get('/', async (_req, res) => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: { id: true, username: true, displayName: true, email: true, role: true, createdAt: true },
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
})
|
||||||
|
res.json(users)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', requireAdmin, async (req, res) => {
|
||||||
|
const data = z
|
||||||
|
.object({
|
||||||
|
username: z.string().min(1).max(50),
|
||||||
|
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'),
|
||||||
|
})
|
||||||
|
.parse(req.body)
|
||||||
|
|
||||||
|
const passwordHash = data.password
|
||||||
|
? await bcrypt.hash(data.password, 12)
|
||||||
|
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12)
|
||||||
|
|
||||||
|
const apiKey =
|
||||||
|
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: data.username,
|
||||||
|
email: data.email,
|
||||||
|
displayName: data.displayName,
|
||||||
|
passwordHash,
|
||||||
|
role: data.role,
|
||||||
|
apiKey,
|
||||||
|
},
|
||||||
|
select: userSelect,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(201).json(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.patch('/:id', requireAdmin, async (req, res) => {
|
||||||
|
const data = z
|
||||||
|
.object({
|
||||||
|
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(),
|
||||||
|
regenerateApiKey: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body)
|
||||||
|
|
||||||
|
const update: Record<string, any> = {}
|
||||||
|
if (data.displayName) update.displayName = data.displayName
|
||||||
|
if (data.email) update.email = data.email
|
||||||
|
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12)
|
||||||
|
if (data.role) {
|
||||||
|
update.role = data.role
|
||||||
|
if (data.role === 'SERVICE' && !update.apiKey) {
|
||||||
|
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.regenerateApiKey) {
|
||||||
|
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: update,
|
||||||
|
select: userSelect,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => {
|
||||||
|
if (req.params.id === req.user!.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete your own account' })
|
||||||
|
}
|
||||||
|
await prisma.user.delete({ where: { id: req.params.id } })
|
||||||
|
res.status(204).send()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user