Architecture
A technical overview. For setup see Installation, for usage see Usage.
Overall structure
Internet/VPN → NPM (SSL + FQDN) → frontend:1111 ─┬─ / → SPA (nginx)
├─ /api/* → backend:4000
└─ /uploads/* → backend:4000
│
┌─────────────┴─────────────┐
│ │
postgres:5432 redis:6379
(Prisma) (cache, queue,
refresh token)Only frontend is exposed to the host. backend, postgres, and redis stay on the internal app-network.
API prefix: The frontend calls /api/* → nginx strips the prefix → the backend sees the raw path (/auth/staff/login). The paths shown in /docs are the raw paths the backend sees.
Repository layout
ticket-system/
├── docker-compose.yml # Production
├── docker-compose.dev.yml # Dev override (hot reload, NODE_ENV=development)
├── docker-compose.local.yml # Exposes DB/Redis on 127.0.0.1 (for local tools)
├── nginx/ # Optional internal proxy (--profile proxy)
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma # 24 models
│ │ ├── migrations/ # Versioned migrations
│ │ └── seed.ts # Demo data
│ └── src/
│ ├── app.ts # Fastify instance, plugin and route registration
│ ├── server.ts # Entry point, worker startup, SLA scheduler
│ ├── config/ # Zod env validation, constants
│ ├── plugins/ # prisma, redis, auth
│ ├── modules/<name>/ # <name>.routes.ts — each module in its own file
│ ├── services/ # email, sms, sse, storage
│ ├── jobs/ # BullMQ queue + workers
│ ├── middleware/ # audit log
│ └── utils/ # crypto, staff-scope, ticket-number, pagination, format
└── frontend/
└── src/
├── api/client.ts # Axios + JWT interceptor + auto-refresh
├── stores/auth.store.ts
├── hooks/useSSE.ts
├── components/
└── pages/{public,staff}/Backend
Stack: Fastify 5, TypeScript (ESM), Prisma 6, Zod, BullMQ, nodemailer.
Startup sequence
server.ts:
buildApp()→ Fastify instance (app.ts).- Warms the ticket counter (
utils/ticket-number.ts). - Idempotently seeds the task email templates.
- BullMQ scheduler: SLA check every 5 minutes.
- Workers start via side-effect import (
email.worker,sms.worker,sla-check.worker). - Listens on
0.0.0.0:${PORT}.
Inside the container, prisma migrate deploy runs before this (see backend/Dockerfile CMD).
app.ts order
trustProxy: true → rate limit (global 100/min) → CORS (config.APP_ORIGINS) → helmet (including CSP) → cookie (signed with JWT_SECRET) → multipart → prisma/redis/auth plugins → static /uploads/ → swagger /docs → /health → module routes → global error handler (Turkish; hides 500 details in production).
About CSP: The backend's CSP only affects its own responses. Since the SPA is served by nginx inside the frontend container, the interface's actual policy lives in frontend/nginx.conf (script-src 'self', no unsafe-inline/eval — the Vite build produces no inline scripts). On the backend the policy is the strictest form under which Swagger UI can still work; the real win is the default-src 'none'; sandbox applied to /uploads.
Modules
| Prefix | Responsibility |
|---|---|
/auth | Staff JWT login/refresh/logout (login 5/min), public email lookup (10/5min) |
/companies | Company CRUD, branding (/branding/by-host), company SMTP, logo |
/locations /categories /custom-fields | Company-scoped definitions; categories are hierarchical + reorderable |
/tickets | CRUD, bulk, search, attachments; /notes under the same prefix (public + internal) |
/public | Token-based ticket viewing, replies, attachments, tracking |
/staff | Staff CRUD + company scope assignment |
/dashboard | Statistics, SLA, my-tickets |
/onsite-support | Appointment CRUD + calendar |
/notifications | Notification list, retry, stats |
/events | SSE — staff, ticket, stats |
/templates | Email / SMS templates, canned responses |
/reports | Ticket, staff performance, category, SLA trend, CSV export |
/tasks | Task CRUD + comments |
/credentials | Credential vault — admin + it_manager (company-scoped; global records admin-only) |
API contract
All responses: { success: boolean, data?: T, error?: string }
Input validation is done inside the handler with Zod (schema.parse(request.body)); fastify's schema: field is not used. The cost of this: /docs cannot document request/response bodies. See Roadmap.
Auth
Staff — dual token:
- Access token: 15 min. Accepted via the
Authorization: Bearerheader or theaccess_tokencookie. - Refresh token: 7 days, httpOnly cookie. Also held in Redis under
refresh:<staffId>and compared on refresh — this is what makes logout genuinely revoke server-side.
The frontend keeps the access token in memory (Zustand partialize persists only user). On startup initializeAuth() restores the session from the refresh cookie. The Axios interceptor auto-refreshes once on 401 (_retry guard), and on failure redirects to /staff/login.
Public — passwordless: The requester provides an email; the ticket gets a nanoid accessToken. Anyone with the link has access.
Decorators (plugins/auth.ts): authenticate, authenticateOptional, requireRole(...roles).
Roles: admin, it_manager, it_staff. There is no hierarchy — requireRole performs a flat list check and grants no implicit pass to admin.
Company scope (utils/staff-scope.ts) — the sole basis of multi-company isolation:
| Role | getStaffCompanyScope returns |
|---|---|
admin | null — no restriction |
it_manager, it_staff | the assigned company ids (StaffCompany M:N) |
| no assignment | empty array — sees nothing (fail-closed) |
Scope is not carried in the JWT; it is read from the DB on every request — assignment changes take effect immediately, with no need to refresh the token.
There are four helpers, and role checking must not be spread outside this file:
getStaffCompanyScope(prisma, staffId, role)— resolves the scope.companyWhereClause(scope)— thewherefragment ({}or{ companyId: { in } }).isCompanyInScope(scope, companyId)— a single check.companyId = null(a "global" record) is open only to admin, and this is enforced explicitly; Postgres'sINsemantics excluding NULLs cannot be relied upon.resolveCompanyFilter(scope, requested)— intersects the client-suppliedcompanyIdfilter with the scope. If the scope is written intowhereand then overwritten by a parameter, a single-parameter privilege escalation appears (?companyId=<another-company>); this is why the client filter must always go through here.
Task has no companyId — the scope is established in two steps via location → company, and locationId may be null; the assignee/creator's access is additionally protected so a person is not locked out of their own task.
Company assignment (PUT /staff/:id/companies) is admin-only. This is an authorization decision: if opened up to it_manager, they could assign all companies to themselves and render the scope meaningless.
Data model
24 models, 9 enums. The status, priority, role, and type fields are Prisma enums — the valid set is enforced at the database level (previously they were plain String and only Zod protected them).
config/constants.ts is derived from these enums; no manual list is kept. Label dictionaries are typed Record<Enum, string>, so if you add a value to the schema and forget to write a label, tsc errors. Zod schemas are bound with z.nativeEnum(...).
Two fields are deliberately String: Company.groupType (existing data may contain unexpected values) and Notification.channel (matches template slugs, managed from the DB). See Roadmap.
Multi-company: Company → Location, Category (self-referencing hierarchy + category-based SLA minutes + auto-assignment), CustomField, CompanySmtp (1:1), StaffCompany (Staff ↔ Company M:N).
People: User (requesters, passwordless) and Staff (passwordHash, role) are separate models.
Core: Ticket — unique ticketNumber, unique accessToken (public access), SLA fields; indexed on status/companyId/assignedToId/createdByEmail/createdAt. Related: TicketCustomValue, TicketNote (isInternal), TicketHistory, Attachment, OnsiteSupport, Notification.
Templates: TicketTemplate, CannedResponse, EmailTemplate, SmsTemplate.
Tasks: Task → TaskAssignee (M:N), TaskComment.
Other: AuditLog, CredentialEntry (passwordEnc, notesEnc).
Migrations are versioned under prisma/migrations/; migrate deploy runs at startup. db push is not used.
Queue and background jobs
BullMQ + Redis. Email and SMS delivery are asynchronous: 3 attempts, exponential backoff. Failed attempts are written to the Notification table and can be retried from the panel.
email.worker— nodemailer. Company-based SMTP: if the company has aCompanySmtprecord it sends with that, otherwise with global SMTP. Transporters are cached for 10 min.sms.worker— SMS gateway adapter. Disabled if no gateway is configured.sla-check.worker— every 5 min; produces SLA warnings based on category-based response/resolution times.
Realtime — SSE
One-way Server-Sent Events. hooks/useSSE.ts reconnects with exponential backoff.
Channels: /events/staff (panel), /events/ticket/:accessToken (the requester's status page), /events/stats.
Because the browser's EventSource API cannot send custom headers, the staff channel takes the JWT in a query parameter (?token=). This drops the token into proxy logs — see Security.
If you use NPM, Websockets Support must be enabled on the proxy host, otherwise SSE will not work.
Frontend
Stack: React 18, Vite 6, TypeScript, TailwindCSS 3, TanStack Query 5, Zustand 5, axios, recharts, lucide-react, react-hot-toast. No component library — hand-written Tailwind.
State management: Server state is TanStack Query (staleTime: 30s, retry: 1). Client state is Zustand, for auth only.
No build-time env: The SPA never uses import.meta.env. Only in dev does the vite proxy read VITE_API_PROXY_TARGET. That is, a single frontend image runs in every environment.
Branding: On startup BrandingProvider calls /companies/branding/by-host; it applies the company's logo and theme color based on the host. ThemeProvider manages dark mode.
Notable architectural decisions
| Decision | Rationale |
|---|---|
No password on the public portal, a nanoid accessToken link | Requesters should not have to create an account / remember a password. The system is on the internal network. |
| AES-256-GCM in the credential vault, not a hash | Passwords must be viewable again; a hash is irreversible. The key lives only in env. |
Only password/notes encrypted in the vault | title/category/url/username stay plaintext so they can be searched. |
| Custom fields in a separate table (not JSON) | Ease of reporting and validation. |
A single routes.ts per module | Small surface, easy to navigate. No controller/service layer. |
| Frontend without build-time env | One image, every environment. Configuration lives entirely on the runtime/backend side. |
trustProxy: true | Real client IP behind NPM/Coolify — so rate-limit and audit log work correctly. |
| No conflict check on appointments | Appointments are not assigned to staff; parallel appointments are normal. |
Coding conventions
- Backend ESM (
"type": "module") — relative imports require the.jsextension (./foo.js, even when the source is.ts). - All input is validated with Zod.
- API response:
{ success, data?, error? }. - Turkish error messages and UI labels.
- Status/priority/role constants live in
config/constants.ts. createAuditLog()is called on admin/staff CRUD operations.- Frontend: one component per file, data fetching via TanStack Query.