Uloziste pro vsechna data, oprava .gitignore, dodelany navrh rozsireni

.gitignore mel vzorec `data/`, ktery se shodl i se `src/data/`. Sestnact
zdrojovych souboru tim tise chybelo v gitu vcetne cele slozky
`src/data/store/`. Opraveno na `/data/`, stejne v .dockerignore.

Tickety vcetne logu, automatizace, incidenty a rozlozeni dashboardu se po
kazde zmene ukladaji. Pomocnik `withMirror` je opak `withCache`: data se meni
v pameti a zapisuji cela, misto aby se po zapisu znovu nacitala. Citace ID se
pri startu dopocitaji z ulozenych zaznamu, takze novy ticket neprepise stary.

Detail ticketu umi typ, tagy, vlastni pole typu a prehozeni na skupinu.
Nastaveni ma prepnuti spravce na jiny ucet, vychozi jen pro cteni.
Skupiny resitelu chodi spolu s lidmi jednim requestem.

Dokumentace: rejstrik znovupouzitelnych funkci (15), navrh monetizace
a ceny za krok (16), popis nastaveni a prav (17). Doplneny endpointy
do openapi.ts, petice CRUD rout se generuje jednou funkci.

Overeno v rezimu souboru: zmeny prezily tvrde ukonceni procesu a po restartu
byly zpatky vcetne logu ticketu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 07:46:22 +02:00
co-authored by Claude Opus 5
parent e7cf499a0b
commit afbe948da3
37 changed files with 5594 additions and 106 deletions
+480
View File
@@ -1,5 +1,110 @@
import { config } from './config.js';
/**
* Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na
* serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne
* by znamenalo devet mist, ktere se casem rozejdou.
*/
function crudPaths(entity: {
/** Cast cesty, napr. `roles`. */
path: string;
/** Jak se o tom mluvi v popisu, napr. `roli`. */
label: string;
/** Pravo, ktere je na zapis potreba. */
permission: string;
}) {
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const body = {
required: true,
content: { 'application/json': { schema: { type: 'object' } } },
};
const record = {
description: 'Zaznam',
content: { 'application/json': { schema: { type: 'object' } } },
};
const denied = { '403': { description: `Chybi pravo ${entity.permission}` } };
const base = `/api/dashboard/settings/${entity.path}`;
return {
[base]: {
get: {
tags: ['Nastaveni'],
summary: `Seznam - ${entity.label}`,
description: 'Vraci jen zaznamy firem, do kterych volajici patri.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Seznam',
content: {
'application/json': {
schema: {
type: 'object',
properties: { items: { type: 'array', items: { type: 'object' } } },
},
},
},
},
...denied,
},
},
post: {
tags: ['Nastaveni'],
summary: `Vytvorit - ${entity.label}`,
security: [{ bearerAuth: [] }],
requestBody: body,
responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied },
},
},
[`${base}/{id}`]: {
get: {
tags: ['Nastaveni'],
summary: `Detail - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied },
},
patch: {
tags: ['Nastaveni'],
summary: `Upravit - ${entity.label}`,
description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.',
security: [{ bearerAuth: [] }],
parameters: [id],
requestBody: body,
responses: {
'200': record,
'400': { description: 'Neplatny vstup' },
'404': { description: 'Neexistuje' },
...denied,
},
},
delete: {
tags: ['Nastaveni'],
summary: `Smazat - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: {
'204': { description: 'Smazano' },
'404': { description: 'Neexistuje' },
...denied,
},
},
},
};
}
/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */
const settingsEntities = [
{ path: 'tenants', label: 'firmy', permission: 'tenant.manage' },
{ path: 'users', label: 'uzivatele', permission: 'user.manage' },
{ path: 'roles', label: 'role a prava', permission: 'role.manage' },
{ path: 'people', label: 'resitele', permission: 'people.manage' },
{ path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' },
{ path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' },
{ path: 'actions', label: 'akce na ticketu', permission: 'action.manage' },
{ path: 'widgets', label: 'vlastni widgety', permission: 'widget.manage' },
{ path: 'features', label: 'zalozky firmy', permission: 'tenant.manage' },
];
/**
* OpenAPI popis API.
*
@@ -29,6 +134,8 @@ export function buildOpenApiDocument() {
{ name: 'Sluzby', description: 'Katalog toho, co umime napojit' },
{ name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' },
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
{ name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' },
{ name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
@@ -556,6 +663,11 @@ export function buildOpenApiDocument() {
},
},
paths: {
// Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths` vyse.
...settingsEntities.reduce(
(all, entity) => ({ ...all, ...crudPaths(entity) }),
{} as Record<string, unknown>,
),
'/health': {
get: {
tags: ['Provoz'],
@@ -954,6 +1066,374 @@ export function buildOpenApiDocument() {
},
},
},
'/api/dashboard/tickets/{id}/type': {
post: {
tags: ['Tickety'],
summary: 'Nastavit typ ticketu',
description:
'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' +
'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['typeId'],
properties: {
typeId: { type: 'string', nullable: true, example: 'tt_order' },
fields: {
type: 'object',
description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.',
additionalProperties: true,
},
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.type.change' },
'404': { description: 'Ticket nebo typ neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/tags': {
post: {
tags: ['Tickety'],
summary: 'Nastavit tagy',
description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['tags'],
properties: {
tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } },
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.tag' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/group': {
post: {
tags: ['Tickety'],
summary: 'Prehodit na skupinu resitelu',
description:
'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' +
've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['groupId'],
properties: { groupId: { type: 'string', nullable: true } },
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.assign.group' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
'/api/dashboard/widget-data': {
post: {
tags: ['Dashboard'],
summary: 'Data vlastnich widgetu',
description:
'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['widgetIds'],
properties: { widgetIds: { type: 'array', items: { type: 'string' } } },
},
},
},
},
responses: {
'200': {
description: 'Data po widgetech',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/widget-data/options': {
get: {
tags: ['Dashboard'],
summary: 'Co jde ve vlastnim widgetu nastavit',
description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Moznosti',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/settings/catalog': {
get: {
tags: ['Nastaveni'],
summary: 'Katalog prav a modulu',
description:
'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Katalog',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/settings/users/{id}/password': {
patch: {
tags: ['Nastaveni'],
summary: 'Zmenit heslo',
description:
'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 8 } },
},
},
},
},
responses: {
'204': { description: 'Zmeneno' },
'400': { description: 'Kratke heslo' },
'403': { description: 'Cizi heslo bez prava' },
},
},
},
'/api/admin/impersonate': {
post: {
tags: ['Sprava platformy'],
summary: 'Prepnout se na jiny ucet',
description:
'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' +
'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
allowWrites: {
type: 'boolean',
default: false,
description: 'true = i zapis. Musi se zapnout vedome.',
},
},
},
},
},
},
responses: {
'200': {
description: 'Token na cizi ucet',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
'404': { description: 'Ucet neexistuje' },
},
},
},
'/api/admin/impersonate/stop': {
post: {
tags: ['Sprava platformy'],
summary: 'Ukoncit prepnuti',
description:
'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.',
security: [{ bearerAuth: [] }],
responses: { '204': { description: 'Zapsano' } },
},
},
'/api/admin/impersonate/candidates': {
get: {
tags: ['Sprava platformy'],
summary: 'Koho lze prepnout',
description: 'Spravci platformy se nenabizeji.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Ucty',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/admin/audit': {
get: {
tags: ['Sprava platformy'],
summary: 'Audit',
description:
'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' +
'Nejnovejsi nahore.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'action', in: 'query', schema: { type: 'string' } },
{ name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } },
],
responses: {
'200': {
description: 'Zaznamy',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/dashboard/incidents': {
get: {
tags: ['Dashboard'],