Architecture & Ops

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:

  1. buildApp() → Fastify instance (app.ts).
  2. Warms the ticket counter (utils/ticket-number.ts).
  3. Idempotently seeds the task email templates.
  4. BullMQ scheduler: SLA check every 5 minutes.
  5. Workers start via side-effect import (email.worker, sms.worker, sla-check.worker).
  6. 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

PrefixResponsibility
/authStaff JWT login/refresh/logout (login 5/min), public email lookup (10/5min)
/companiesCompany CRUD, branding (/branding/by-host), company SMTP, logo
/locations /categories /custom-fieldsCompany-scoped definitions; categories are hierarchical + reorderable
/ticketsCRUD, bulk, search, attachments; /notes under the same prefix (public + internal)
/publicToken-based ticket viewing, replies, attachments, tracking
/staffStaff CRUD + company scope assignment
/dashboardStatistics, SLA, my-tickets
/onsite-supportAppointment CRUD + calendar
/notificationsNotification list, retry, stats
/eventsSSE — staff, ticket, stats
/templatesEmail / SMS templates, canned responses
/reportsTicket, staff performance, category, SLA trend, CSV export
/tasksTask CRUD + comments
/credentialsCredential 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: Bearer header or the access_token cookie.
  • 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:

RolegetStaffCompanyScope returns
adminnull — no restriction
it_manager, it_staffthe assigned company ids (StaffCompany M:N)
no assignmentempty 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) — the where fragment ({} 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's IN semantics excluding NULLs cannot be relied upon.
  • resolveCompanyFilter(scope, requested)intersects the client-supplied companyId filter with the scope. If the scope is written into where and 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: CompanyLocation, 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: TaskTaskAssignee (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 a CompanySmtp record 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

DecisionRationale
No password on the public portal, a nanoid accessToken linkRequesters 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 hashPasswords must be viewable again; a hash is irreversible. The key lives only in env.
Only password/notes encrypted in the vaulttitle/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 moduleSmall surface, easy to navigate. No controller/service layer.
Frontend without build-time envOne image, every environment. Configuration lives entirely on the runtime/backend side.
trustProxy: trueReal client IP behind NPM/Coolify — so rate-limit and audit log work correctly.
No conflict check on appointmentsAppointments are not assigned to staff; parallel appointments are normal.

Coding conventions

  • Backend ESM ("type": "module") — relative imports require the .js extension (./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.