Tmavý režim
Support feedback
Vestavěný systém pro sběr zpětné vazby z koncových aplikací (bug reporty, nápady, dotazy). Uživatel vyplní formulář v aplikaci, optionálně přiloží screenshoty, a backend je centrálně doručí super-adminům do administrace.
Jak to funguje
┌──────────────┐ POST /support/feedback ┌─────────────┐
│ End-user app │ ───── multipart + JWT ─────────▶ │ user-api │
└──────────────┘ │ │
│ ┌───────┐ │
┌──────────────┐ GET /admin/support/feedback │ │ PG │ │
│ Admin panel │ ◀──── JSON + signed URLs ─────── │ └───────┘ │
└──────────────┘ └─────────────┘
│ ▲
│ <img src="...attachments/12?exp=...&sig=..."> │
└────────────────────────────────────────────────┘
bez Authorization headeruDatový model
Dvě tabulky:
| Tabulka | Účel |
|---|---|
support_feedback | Hlavička ticketu (text, status, autor, app metadata) |
support_attachment | Přílohy jako BYTEA v PostgreSQL (TOAST) — žádné soubory na disku |
ON DELETE CASCADE zajistí, že smazání ticketu odstraní i jeho přílohy.
Stavy a typy
| Pole | Hodnoty |
|---|---|
type | bug, idea, question, other |
status | new, in_progress, resolved, forwarded_to_redmine, rejected (default new) |
forwarded_to_redmine je terminální stav vznikající pouze úspěšnou akcí předání. Nelze jej nastavit obecným endpointem změny stavu.
Signed URL pro přílohy
Endpoint /admin/support/attachments/:id je namountovaný před JWT middleware. Autorizace probíhá přes HMAC-SHA256 podpis v query stringu (?exp=...&sig=...), aby admin panel mohl vkládat obrázky přímo do <img src> bez bearer headeru. TTL je 10 minut, sekret je SUPPORT_SIGNED_URL_SECRET.
Auto-cleanup (rolling cap)
Aby tabulky nerostly do nekonečna a BYTEA přílohy nezatěžovaly server, drží se maximálně 500 ticketů na jeden appCode. Po každém vložení nového ticketu se přebytečné nejstarší řádky odstraní (CASCADE smaže i jejich přílohy). Limit je v src/services/supportService.ts jako konstanta MAX_FEEDBACKS_PER_APP.
Cleanup je per aplikace, ne globální — každý
appCode(např.kgb) má svůj nezávislý limit 500.
Integrace z nové aplikace
Požadavky
- Aplikace má vlastní
appCoderegistrovaný v tabulceapplications(řeší admin panel). - Uživatel je přihlášený přes Zitadel (frontend posílá platný
Beareraccess token). - Frontend posílá
multipart/form-data— žádný JSON.
Endpoint
http
POST /api/support/feedback
Authorization: Bearer <jwt>
Content-Type: multipart/form-data| Pole | Typ | Povinné | Validace |
|---|---|---|---|
appCode | string | ✓ | Musí existovat v applications |
appVersion | string | ✓ | 1..64 znaků (verze frontend buildu) |
type | string | ✓ | bug | idea | question | other |
message | string | ✓ | 1..5000 znaků (po trim) |
attachments | file[] | ✗ | max 5 souborů, 10 MB každý, 60 MB total |
Povolené MIME typy příloh: image/png, image/jpeg, image/gif, image/webp, application/pdf.
E-mail a jméno autora se berou automaticky z JWT — nikdy je neposílej v body, server je ignoruje.
Odpověď
http
201 Created
{
"code": 201,
"response": {
"id": 42,
"createdAt": "2026-06-09T12:34:56.789Z"
}
}Chybové stavy
| Status | Příčina |
|---|---|
| 400 | Neznámý appCode, vadný type/status, příliš dlouhý text, nepovolený MIME |
| 401 | Chybí / vadný JWT |
| 413 | Multipart payload přes 60 MB |
Příklad — React/TypeScript (fetch)
typescript
async function reportBug(opts: {
message: string;
files: File[];
token: string;
}) {
const fd = new FormData();
fd.append('appCode', 'kgb');
fd.append('appVersion', __APP_VERSION__); // z build configu
fd.append('type', 'bug');
fd.append('message', opts.message);
for (const f of opts.files) fd.append('attachments', f);
const res = await fetch('https://api.user.kagb.cloud/api/support/feedback', {
method: 'POST',
headers: { Authorization: `Bearer ${opts.token}` },
body: fd,
});
if (!res.ok) throw new Error(`Support submit failed: ${res.status}`);
return res.json() as Promise<{ code: number; response: { id: number; createdAt: string } }>;
}Příklad — curl
bash
curl -X POST https://api.user.kagb.cloud/api/support/feedback \
-H "Authorization: Bearer $TOKEN" \
-F "appCode=kgb" \
-F "appVersion=2.3.1" \
-F "type=bug" \
-F "message=Aplikace se zasekne při otevření detailu jednotky." \
-F "attachments=@screenshot.png"Admin endpointy
Všechny vyžadují JWT a odpovídající oprávnění v aplikaci auth. Čtení používá support.view, změna stavu/poznámky support.manage, smazání support.delete a předání support.redmine. Superadmin má nadále nouzový bypass. Cesta je /api/admin/support/*.
| Metoda | Cesta | Účel |
|---|---|---|
GET | /admin/support/feedback | List s filtry (appCode, version, userEmail, status, type, limit, offset) |
GET | /admin/support/feedback/:id | Detail jednoho ticketu |
PATCH | /admin/support/feedback/:id/status | Změna stavu ({ "status": "in_progress" }) |
PATCH | /admin/support/feedback/:id/note | Interní poznámka ({ "internalNote": "..." }, "" = vyprázdnit) |
GET | /admin/support/redmine/status | Bezpečný stav konfigurace bez API klíče |
POST | /admin/support/feedback/:id/redmine | Idempotentní předání požadavku a příloh do Redmine |
DELETE | /admin/support/feedback/:id | Smazání ticketu i příloh (204) |
GET | /admin/support/attachments/:id?exp=...&sig=... | Streamuje přílohu (signed URL, bez JWT) |
URL příloh dostává admin panel už podepsané v každé odpovědi (attachments[].url), takže není potřeba je generovat ručně.
Konfigurace
V .env.production:
bash
# HMAC sekret pro signed URL. Vygeneruj: openssl rand -hex 32
SUPPORT_SIGNED_URL_SECRET=<32+ chars hex>
# Veřejná URL backendu, jak ji vidí browser admin panelu (+ /api)
SUPPORT_PUBLIC_BASE_URL=https://api.user.kagb.cloud/api
# Serverové napojení na Redmine
SUPPORT_REDMINE_URL=https://redmine.atrea.cz
SUPPORT_REDMINE_API_KEY=<secret>
SUPPORT_REDMINE_PROJECT_ID=<identifier-nebo-id>
SUPPORT_REDMINE_TRACKER_ID=<volitelné-id>V YAML konfiguraci jsou hodnoty prázdné — env mapping v config/custom-environment-variables.yaml je přepíše za běhu.
Redmine a CORS
Panel Redmine REST API nikdy nevolá. Po kliknutí zavolá pouze POST /api/admin/support/feedback/:id/redmine na user-api. Server nahraje přílohy do /uploads.json, vytvoří /issues.json, uloží ID a URL úkolu a nastaví stav forwarded_to_redmine. Proto není nutné povolovat CORS na Redmine a API klíč se nedostane do JavaScriptu, logů panelu ani odpovědi API.
Opakování akce po úspěchu pouze vrátí již propojený požadavek. Souběžné kliknutí chrání databázový zámek.
Notifikace správcům panelu
Seed sql/auth_panel_notifications.sql vytváří v aplikaci auth obecný typ support se šablonami new_feedback a status_changed v češtině a angličtině. E-mail i push jsou ve výchozím stavu zapnuté, userCanDisable=true, takže každý příjemce si kanály může vypnout.
Po uložení požadavku API obesílá pouze aktivní uživatele, kterým efektivní oprávnění support.view dovolí požadavek skutečně otevřít. Po změně stavu obdrží autor požadavku šablonu support/status_changed. Opakované uložení stejného stavu další notifikaci nevytvoří.
Limity a tuning
| Co | Default | Kde |
|---|---|---|
| Max příloh na ticket | 5 | MAX_ATTACHMENTS v supportService.ts |
| Max velikost jednoho souboru | 10 MB | MAX_FILE_BYTES |
| Max velikost multipart payloadu | 60 MB | MAX_TOTAL_BYTES v supportRouter.ts |
Max délka message | 5000 znaků | MESSAGE_MAX |
Max délka internalNote | 5000 znaků | INTERNAL_NOTE_MAX |
| Rolling cap ticketů per app | 500 | MAX_FEEDBACKS_PER_APP |
| Signed URL TTL | 600 s | TTL_SECONDS v supportStorage.ts |
Pokud bys chtěl jiný cap (např. 200 pro mobilní apky, 1000 pro web), uprav konstantu a deployni — žádná migrace.