From e7cf499a0b85554d6abe84ba70f9826e65f2983d Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:06:38 +0200 Subject: [PATCH] Cely navrh rozsireni: role, firmy, typy ticketu, akce, widgety, audit Implementace vsech bodu z documentation/09-navrh-rozsireni.md. Vsechno lezi v obecnem ulozisti, ktere umi Postgres i JSON soubor. Zaklad, aby se nepsalo osmkrat totez: - src/data/store/: jedno rozhrani EntityStore, dve implementace (local se souborem nebo pameti, postgres nad tabulkou records). Vyber je na jednom miste v store/index.ts - src/data/store/cached.ts: synchronni kopie v pameti pro entity ctene pri kazdem requestu (uzivatel v autorizaci, firmy pri vypoctu prav). Bez toho by se autorizacni middleware musel predelat na async - src/routes/crud.ts: fabrika na CRUD routy. Kazda entita by jinak znamenala stejnych sto radku a sedmkrat by se opravila spatne - src/data/bootstrap.ts: jedno misto, kde je seznam entit a jejich vychozi sady - migrace 002_records.sql: jedna tabulka s JSONB. Tvary se jeste hybou a nikdo se nad nimi nedotazuje po polich. Az se to usadi, entita se povysi na vlastni tabulku, presne jako uz maji konektory Prava a role (bod 5): - role jsou zaznamy, pravo je retezec, katalog prav je zdroj pravdy. Union 'admin' | 'agent' na ucetni a skladnika nestacil a pridavat hodnoty je slepa ulicka, kazdy klient chce jine - Membership.roleIds misto role. Systemove role admin a agent zustavaji, takze se zadny ucet nemusel predelavat - accessFor vraci prava i zalozky. Klient si nic nedovozuje Zalozky a limity za firmu (bod 6): - TenantFeatures: moduly, limity, zpristupnene sluzby. Dve vrstvy s jinym vlastnikem, ktere se nesmi michat: co ma firma zaplacene nastavujeme my, kdo z jejich lidi to smi nastavuje jejich admin. Efektivni viditelnost je prunik, takze vypnuty modul neexistuje ani pro admina te firmy - navigace ze serveru, ne konstanta na klientovi Firmy, uzivatele, resitele a skupiny: CRUD vcetne clenstvi a hesel. Heslo se z API nikdy nevraci, ani jako hash. Skupiny resitelu kvuli tomu, ze prehazovat praci na jmeno nestaci - clovek chce rict "tohle je pro ucetni". Typy ticketu a akce (body 1, 2, 3): - typ ticketu s vlastnimi polemi, plus tagy. Akce se vazou na typ nebo tag, ale za tagem nestoji zadna pole, takze akce na tagu umi jen vestavena pole - Ticket dostal typeId, fields, tags a assigneeGroupId - definice akce s telem jako operace, strom nebo skript. Pravo vznika spolu s akci jako action:, admin pak zaskrtava akce, ne prava - CTA na ticketu filtruje server podle typu, tagu, podminek a prav. Kdyby to pocital klient, pocitalo by se to na dvou mistech - vestavene akce (typ, tagy, skupina) jdou pres tutez fabriku, takze maji svoje pravo a projdou auditem - spusteni zapise do logu ticketu hned, jeste nez se neco stane Vlastni widgety (bod 4): - rozdeleni na render a source, groupBy, filtr je tentyz, ktery umi seznam ticketu. Uzivatel nepise dotazy - jeden batch endpoint na cely prehled. Widget, ktery selze, vraci chybu na sve pozici a nezhasne prehled Audit a impersonace (bod 6c): - audit zapisuje i odepreni, jinak by pokusy o cizi firmu nikde nezustaly - impersonace: jen spravce platformy, nikdy na jineho spravce platformy, 30 minut bez obnoveni, vychozi jen cteni. Zapis pod rezimem cteni vraci 403 na urovni middleware, ne az v handleru Overeno bez databaze: vsech devet ulozist se nacte, role se zaloz1 a prezije restart, agent dostane 403 na spravu roli a uzsi navigaci, neznama prava se odmitnou, heslo se nevraci, typ a tagy ticketu se ulozi, CTA se objevi, widget data pocitaji vcetne seskupeni, impersonace odmitne zapis i prepnuti na admina, audit obsahuje actedBy. Degradace pri nedostupne databazi taky overena: migrace selzou, jede se do souboru a rekne se proc. Neovereno: migrace 002_records.sql proti zive databazi. Kontejner uz nebyl k dispozici, generickou vrstvu drzi tentyz pool a migrator jako konektory. Co-Authored-By: Claude Opus 5 (1M context) --- src/data/access.ts | 56 ++- src/data/people.ts | 163 +++++--- src/data/services.ts | 52 ++- src/data/tenants.ts | 52 ++- src/data/ticketStore.ts | 118 +++++- src/data/users.ts | 150 +++++-- src/db/migrations/002_records.sql | 33 ++ src/index.ts | 12 +- src/middleware/auth.ts | 29 ++ src/routes/admin.ts | 145 +++++++ src/routes/crud.ts | 210 ++++++++++ src/routes/dashboard.ts | 51 ++- src/routes/settings.ts | 658 ++++++++++++++++++++++++++++++ src/routes/ticketActions.ts | 415 +++++++++++++++++++ src/routes/widgetData.ts | 276 +++++++++++++ src/scripts/lookup.ts | 14 + src/types.ts | 29 +- 17 files changed, 2307 insertions(+), 156 deletions(-) create mode 100644 src/db/migrations/002_records.sql create mode 100644 src/routes/admin.ts create mode 100644 src/routes/crud.ts create mode 100644 src/routes/settings.ts create mode 100644 src/routes/ticketActions.ts create mode 100644 src/routes/widgetData.ts create mode 100644 src/scripts/lookup.ts diff --git a/src/data/access.ts b/src/data/access.ts index ef042f7..6088625 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -9,10 +9,16 @@ * * Uzivatel muze patrit do vic firem, takze `tenant` a `mine` vzdy potrebuji * vedet, o kterou firmu jde. + * + * Prava se sem dostavaji z roli (`permissions.ts`) a zalozky z nastaveni firmy + * (`tenantFeatures.ts`). Klient si **nesmi nic dovozovat sam** - jinak by se to + * pocitalo na dvou mistech a jednou se rozejde. */ import { findPersonByEmail } from './people.js'; -import { findTenant, listTenants, type Tenant } from './tenants.js'; +import { hasPermission, permissionsOf } from './permissions.js'; +import { listActiveTenants, findTenant, type Tenant } from './tenants.js'; +import { navFor, type NavItem } from './tenantFeatures.js'; import type { User } from '../types.js'; export type TicketScope = 'all' | 'tenant' | 'mine'; @@ -21,25 +27,38 @@ export interface Access { /** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */ scopes: TicketScope[]; /** Firmy, mezi kterymi muze prepinat. */ - tenants: Tenant[]; + tenants: Array<{ id: string; name: string }>; /** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */ defaultTenantId: string | null; /** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */ canAssignOthers: boolean; /** ID resitele odpovidajiciho uzivateli, nebo null. */ personId: string | null; + /** Efektivni prava. Klient podle nich kresli tlacitka. */ + permissions: string[]; + /** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */ + nav: NavItem[]; + /** true = vidi napric firmami a smi platformni nastaveni. */ + platformAdmin: boolean; } -export function accessFor(user: User): Access { +/** + * Co uzivatel smi. + * + * `tenantId` je nepovinny: bez nej se vezme prvni firma. Prava jsou spojeni + * pres vsechna clenstvi (viz `permissionsOf`), zalozky se ale pocitaji za + * konkretni firmu - modul si kupuje firma, ne clovek. + */ +export function accessFor(user: User, tenantId?: string | null): Access { const person = findPersonByEmail(user.email); - const tenants = user.platformAdmin - ? listTenants() + const tenants: Tenant[] = user.platformAdmin + ? listActiveTenants() : user.memberships .map((membership) => findTenant(membership.tenantId)) .filter((tenant): tenant is Tenant => { if (!tenant) console.warn(`[access] ${user.email}: clenstvi v nezname firme`); - return tenant !== undefined; + return tenant !== undefined && tenant.enabled; }) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); @@ -49,16 +68,19 @@ export function accessFor(user: User): Access { // Bez navazaneho resitele nema "moje tickety" co ukazat. if (person) scopes.push('mine'); - // Admin aspon v jedne firme, nebo platformni admin, smi prehazovat praci. - const isAdminSomewhere = - user.platformAdmin || user.memberships.some((membership) => membership.role === 'admin'); + const defaultTenantId = tenants[0]?.id ?? null; + const activeTenant = tenantId ?? defaultTenantId; return { scopes, - tenants, - defaultTenantId: tenants[0]?.id ?? null, - canAssignOthers: isAdminSomewhere, + tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })), + defaultTenantId, + // Zustava kvuli klientovi, ale uz se pocita z prava, ne z role. + canAssignOthers: hasPermission(user, 'ticket.assign.others'), personId: person?.id ?? null, + permissions: [...permissionsOf(user)].sort(), + nav: navFor(user, activeTenant), + platformAdmin: user.platformAdmin, }; } @@ -89,14 +111,14 @@ export function resolveScope( user: User, requested: { scope?: string; tenantId?: string }, ): ResolvedScope | ScopeDenied { - const access = accessFor(user); + const access = accessFor(user, requested.tenantId); const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as | TicketScope | string; if (scope !== 'all' && scope !== 'tenant' && scope !== 'mine') { - return { status: 403, error: 'unknown_scope', message: `Neznámý pohled „${scope}".` }; + return { status: 403, error: 'unknown_scope', message: `Neznámý pohled "${scope}".` }; } if (!access.scopes.includes(scope)) { @@ -122,11 +144,7 @@ export function resolveScope( const tenantId = requested.tenantId ?? access.defaultTenantId; if (!tenantId) { - return { - status: 403, - error: 'no_tenant', - message: 'Účet nepatří do žádné firmy.', - }; + return { status: 403, error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }; } if (!access.tenants.some((tenant) => tenant.id === tenantId)) { diff --git a/src/data/people.ts b/src/data/people.ts index 2ed618d..efa7f12 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -6,12 +6,15 @@ * technik muze mit tickety a do portalu se nikdy neprihlasit. * Spojka mezi obojim je e-mail. * - * POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu. + * Resitel se hleda pri kazdem requestu (filtr "moje tickety"), proto se drzi + * kopie v pameti. Vzor je v `store/cached.ts`. */ -export interface Person { - id: string; - /** Firma, jejiz je clenem tymu. Hranice viditelnosti. */ +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; + +export interface Person extends TenantEntity { + /** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */ tenantId: string; name: string; email: string; @@ -19,82 +22,108 @@ export interface Person { role: string; /** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */ capacity: number; + /** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */ + enabled: boolean; } -export const people: Person[] = [ - { - id: 'ppl_vomacka', - tenantId: 'tnt_automia', - name: 'Karel Vomáčka', - email: 'karel.vomacka@automia.cz', - role: 'Servicedesk', - capacity: 8, - }, - { - id: 'ppl_uhlir', - tenantId: 'tnt_automia', - name: 'Jiří Uhlíř', - email: 'admin@automia.cz', - role: 'Vedoucí týmu', - capacity: 5, - }, - { - id: 'ppl_kriz', - tenantId: 'tnt_automia', - name: 'Martin Kříž', - email: 'martin.kriz@automia.cz', - role: 'Integrace a API', - capacity: 6, - }, - { - id: 'ppl_novakova', - tenantId: 'tnt_automia', - name: 'Eva Nováková', - email: 'eva.novakova@automia.cz', - role: 'Voiceboti', - capacity: 6, - }, - // Nordis ma vlastni tym. Karel Vomacka je jeho spravcem, ale resitele - // ma Nordis svoje - proto je videt, ze tenant neni jen stitek. - { - id: 'ppl_bartos', - tenantId: 'tnt_nordis', - name: 'Lukáš Bartoš', - email: 'lukas.bartos@nordis.cz', - role: 'Podpora', - capacity: 7, - }, - { - id: 'ppl_horakova', - tenantId: 'tnt_nordis', - name: 'Simona Horáková', - email: 'simona.horakova@nordis.cz', - role: 'Fakturace', - capacity: 5, - }, - { - id: 'ppl_kadlec', - tenantId: 'tnt_logitrans', - name: 'Ondřej Kadlec', - email: 'ondrej.kadlec@logitrans.cz', - role: 'Dispečink', - capacity: 6, - }, -]; +export const personStore = defineStore('person'); +const cache = withCache(personStore); + +export function seedPeople(): Person[] { + const timestamp = nowIso(); + const base = { enabled: true, createdAt: timestamp, updatedAt: timestamp }; + + return [ + { ...base, id: 'ppl_vomacka', tenantId: 'tnt_automia', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Servicedesk', capacity: 8 }, + { ...base, id: 'ppl_uhlir', tenantId: 'tnt_automia', name: 'Jiří Uhlíř', email: 'admin@automia.cz', role: 'Vedoucí týmu', capacity: 5 }, + { ...base, id: 'ppl_kriz', tenantId: 'tnt_automia', name: 'Martin Kříž', email: 'martin.kriz@automia.cz', role: 'Integrace a API', capacity: 6 }, + { ...base, id: 'ppl_novakova', tenantId: 'tnt_automia', name: 'Eva Nováková', email: 'eva.novakova@automia.cz', role: 'Voiceboti', capacity: 6 }, + // Nordis ma vlastni tym. Karel Vomacka je jeho spravcem, ale resitele + // ma Nordis svoje - proto je videt, ze tenant neni jen stitek. + { ...base, id: 'ppl_bartos', tenantId: 'tnt_nordis', name: 'Lukáš Bartoš', email: 'lukas.bartos@nordis.cz', role: 'Podpora', capacity: 7 }, + { ...base, id: 'ppl_horakova', tenantId: 'tnt_nordis', name: 'Simona Horáková', email: 'simona.horakova@nordis.cz', role: 'Fakturace', capacity: 5 }, + { ...base, id: 'ppl_kadlec', tenantId: 'tnt_logitrans', name: 'Ondřej Kadlec', email: 'ondrej.kadlec@logitrans.cz', role: 'Dispečink', capacity: 6 }, + ]; +} + +export async function refreshPeople(): Promise { + await cache.refresh(); +} /** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ export function listPeople(tenantIds: string[]): Person[] { - return people + return cache + .all() + .filter((person) => tenantIds.includes(person.tenantId) && person.enabled) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +/** Vcetne vypnutych. Pro spravu tymu. */ +export function listAllPeople(tenantIds: string[]): Person[] { + return cache + .all() .filter((person) => tenantIds.includes(person.tenantId)) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); } export function findPerson(id: string): Person | undefined { - return people.find((p) => p.id === id); + return cache.byId(id); } /** Spojka na prihlaseneho uzivatele - podle ni funguje filtr "moje tickety". */ export function findPersonByEmail(email: string): Person | undefined { const normalized = email.trim().toLowerCase(); - return people.find((p) => p.email.toLowerCase() === normalized); + return cache.find((person) => person.email.toLowerCase() === normalized && person.enabled); +} + +/** Vsichni resitele bez ohledu na firmu. Jen pro nabidky v katalogu. */ +export function allPeople(): Person[] { + return cache.all(); +} + +// ------------------------------------------------------------------- skupiny + +/** + * Skupina resitelu, napr. Sklad nebo Ucetni. + * + * Duvod, proc to neni jen popisek u cloveka: prehazovat praci na jmeno nestaci. + * Clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich ma + * dovolenou. Fronta bez resitele se tim rozpadne na fronty skupin. + */ +export interface PersonGroup extends TenantEntity { + tenantId: string; + name: string; + personIds: string[]; +} + +export const groupStore = defineStore('personGroup'); +const groupCache = withCache(groupStore); + +export function seedGroups(): PersonGroup[] { + const timestamp = nowIso(); + return [ + { + id: 'grp_servicedesk', + tenantId: 'tnt_automia', + name: 'Servicedesk', + personIds: ['ppl_vomacka', 'ppl_kriz'], + createdAt: timestamp, + updatedAt: timestamp, + }, + ]; +} + +export async function refreshGroups(): Promise { + await groupCache.refresh(); +} + +export function listGroups(tenantIds: string[]): PersonGroup[] { + return groupCache + .all() + .filter((group) => tenantIds.includes(group.tenantId)) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +export function findGroup(id: string): PersonGroup | undefined { + return groupCache.byId(id); } diff --git a/src/data/services.ts b/src/data/services.ts index 47f1398..f713bf8 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -19,7 +19,6 @@ */ import type { FieldType } from './conditions.js'; -import { people } from './people.js'; import type { User } from '../types.js'; export type ServiceCategory = @@ -120,6 +119,11 @@ export interface OperationField { kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object'; required: boolean; options?: Array<{ value: string; label: string }>; + /** + * Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu, + * ale resitele a skupiny se nacitaji z uloziste az pozdeji. + */ + optionsFrom?: 'people' | 'groups'; hint?: string; } @@ -201,10 +205,16 @@ export interface Service { actions: ServiceOperation[]; } -/** Nabidka resitelu do vyberu u akci. Bere se ze seznamu lidi, aby se nerozesla. */ -const assigneeOptions = [ +/** + * Nabidka resitelu do vyberu u akci. + * + * Seznam se **nezapisuje do katalogu**, protoze resitele se nacitaji az za behu + * z uloziste, kdezto katalog vznika pri importu modulu. Misto hodnot je tu + * priznak `optionsFrom` a doplni je `serviceCatalog()` pri kazdem volani. + * Diky tomu novy clovek v tymu neni potreba nikde registrovat. + */ +const assigneeOptions: Array<{ value: string; label: string }> = [ { value: '', label: 'Nechat ve frontě' }, - ...people.map((person) => ({ value: person.id, label: person.name })), ]; const priorityOptions = [ @@ -437,6 +447,7 @@ export const services: Service[] = [ kind: 'choice', required: false, options: assigneeOptions, + optionsFrom: 'people', }, ], outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }], @@ -459,6 +470,7 @@ export const services: Service[] = [ kind: 'choice', required: true, options: assigneeOptions, + optionsFrom: 'people', }, ], }, @@ -1481,6 +1493,38 @@ export function actionsFor(serviceId: string): ServiceOperation[] { ].sort((a, b) => a.name.localeCompare(b.name, 'cs')); } +/** + * Doplni nabidky, ktere se nedaji zapsat do katalogu. + * + * Katalog vznika pri importu modulu, ale resitele a skupiny se nacitaji + * z uloziste az pozdeji. Pole s `optionsFrom` proto dostane hodnoty az tady. + */ +export function withRuntimeOptions( + items: Service[], + options: { people: Array<{ id: string; name: string }>; groups: Array<{ id: string; name: string }> }, +): Service[] { + const fill = (field: OperationField): OperationField => { + if (!field.optionsFrom) return field; + const source = field.optionsFrom === 'people' ? options.people : options.groups; + return { + ...field, + options: [ + { value: '', label: field.optionsFrom === 'people' ? 'Nechat ve frontě' : 'Bez skupiny' }, + ...source.map((item) => ({ value: item.id, label: item.name })), + ], + }; + }; + + const fillOperation = (operation: ServiceOperation): ServiceOperation => + operation.inputs ? { ...operation, inputs: operation.inputs.map(fill) } : operation; + + return items.map((service) => ({ + ...service, + triggers: service.triggers.map(fillOperation), + actions: service.actions.map(fillOperation), + })); +} + /** Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. */ export function serviceCatalog(): Service[] { return services.map((service) => diff --git a/src/data/tenants.ts b/src/data/tenants.ts index 26a1771..1a68b9b 100644 --- a/src/data/tenants.ts +++ b/src/data/tenants.ts @@ -5,27 +5,55 @@ * - tenant je nas zakaznik, ten, kdo portal pouziva a ma v nem svuj tym, * - `ticket.customer` je zakaznik toho tenanta, tedy kdo pozadavek poslal. * - * Tenant je hranice viditelnosti. Nic se nesmi vratit napric tenanty, - * pokud to vyslovne nepovoli role `platform`. + * Tenant je hranice viditelnosti. Nic se nesmi vratit napric tenanty, pokud to + * vyslovne nepovoli `platformAdmin`. * - * POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu. + * Firmy se ctou pri kazdem requestu (vypocet prav), proto se drzi kopie + * v pameti a cte se z ni synchronne. Zapis jde do uloziste a kopii obnovi. + * Vzor je popsany v `store/cached.ts`. */ -export interface Tenant { - id: string; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; + +export interface Tenant extends TenantEntity { name: string; + /** Kratka poznamka pro nas, klient ji nevidi. */ + note: string; + enabled: boolean; } -export const tenants: Tenant[] = [ - { id: 'tnt_automia', name: 'Automia' }, - { id: 'tnt_nordis', name: 'Nordis a.s.' }, - { id: 'tnt_logitrans', name: 'LogiTrans' }, -]; +/** + * Firma je platformni zaznam: `tenantId` je vzdy null, protoze firmu nevlastni + * jina firma. Filtrovani na firmu tady tedy nema co delat, spravu ma + * `tenant.manage`, coz je nase pravo. + */ +export const tenantStore = defineStore('tenant'); +const cache = withCache(tenantStore); + +export function seedTenants(): Tenant[] { + const timestamp = nowIso(); + const base = { tenantId: null, note: '', enabled: true, createdAt: timestamp, updatedAt: timestamp }; + return [ + { ...base, id: 'tnt_automia', name: 'Automia' }, + { ...base, id: 'tnt_nordis', name: 'Nordis a.s.' }, + { ...base, id: 'tnt_logitrans', name: 'LogiTrans' }, + ]; +} + +export async function refreshTenants(): Promise { + await cache.refresh(); +} export function listTenants(): Tenant[] { - return [...tenants].sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return [...cache.all()].sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +/** Jen zapnute firmy. Vypnuta firma se chova jako by neexistovala. */ +export function listActiveTenants(): Tenant[] { + return listTenants().filter((tenant) => tenant.enabled); } export function findTenant(id: string): Tenant | undefined { - return tenants.find((t) => t.id === id); + return cache.byId(id); } diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 4431e71..417fadb 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -88,6 +88,25 @@ export interface Ticket { status: TicketStatus; priority: TicketPriority; assignee: TicketAssignee | null; + /** + * Skupina, ktera ma ticket u sebe. Prehazovat praci na jmeno nestaci - + * clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich + * ma dovolenou. Viz data/people.ts, sekce skupiny. + */ + assigneeGroupId: string | null; + /** + * Typ ticketu. Za nim stoji vlastni pole, proto se na nej vazou akce. + * null = ticket bez typu, jako driv. + */ + typeId: string | null; + /** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */ + fields: Record; + /** + * Volne oznaceni. Na rozdil od typu jich muze byt vic a nestoji za nimi + * zadna pole - proto se hodi na filtry a widgety, ne na akce, ktere + * potrebuji data. + */ + tags: string[]; /** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */ automationId: string | null; createdAt: string; @@ -98,8 +117,20 @@ export interface TicketDetail extends Ticket { trace: TicketTraceEntry[]; } -interface StoredTicket extends Omit { +/** + * Tvar v ulozisti. + * + * Nova pole jsou nepovinna zamerne: vychozi sada ticketu je psana jako literaly + * a doplnovat do kazdeho `tags: []` by byl sum. Chybejici hodnotu dosadi + * `toTicket`, takze navenek je `Ticket` uplny. + */ +interface StoredTicket + extends Omit { assigneeId: string | null; + assigneeGroupId?: string | null; + typeId?: string | null; + fields?: Record; + tags?: string[]; } export const channelLabels: Record = { @@ -600,15 +631,24 @@ seed( function toTicket(stored: StoredTicket): Ticket { const { assigneeId, ...rest } = stored; - if (!assigneeId) return { ...rest, assignee: null }; + // Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny. + const base = { + ...rest, + assigneeGroupId: stored.assigneeGroupId ?? null, + typeId: stored.typeId ?? null, + fields: stored.fields ?? {}, + tags: stored.tags ?? [], + }; + + if (!assigneeId) return { ...base, assignee: null }; const person = findPerson(assigneeId); if (!person) { // Resitel zmizel ze seznamu - ticket nesmi spadnout, ale chceme o tom vedet. console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz neexistuje`); - return { ...rest, assignee: null }; + return { ...base, assignee: null }; } - return { ...rest, assignee: { id: person.id, name: person.name } }; + return { ...base, assignee: { id: person.id, name: person.name } }; } // -------------------------------------------------------------------- dotazy @@ -729,6 +769,10 @@ export interface CreateTicketInput { customer: TicketCustomer; priority: TicketPriority; assigneeId?: string | null; + assigneeGroupId?: string | null; + typeId?: string | null; + fields?: Record; + tags?: string[]; automationId?: string | null; /** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */ trace?: TraceInput[]; @@ -865,6 +909,72 @@ export function assignTicket( } /** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */ +/** + * Zmena typu ticketu. + * + * Vlastni pole se **nezahazuji**, jen prestanou byt videt. Kdyby se mazala, + * omylem prepnuty typ by znamenal ztratu dat bez cesty zpatky. + */ +export function setTicketType( + id: string, + typeId: string | null, + fields: Record | undefined, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + ticket.typeId = typeId; + if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields }; + ticket.updatedAt = new Date().toISOString(); + + appendTrace(id, [ + { kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` }, + ]); + publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id }); + return toTicket(ticket); +} + +/** Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala. */ +export function setTicketTags(id: string, tags: string[], tenantIds: string[]): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; + ticket.updatedAt = new Date().toISOString(); + publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id }); + return toTicket(ticket); +} + +/** + * Prehozeni na skupinu. + * + * Prirazeni cloveku se **zrusi**: kdyby zustalo, ticket by byl ve fronte skupiny + * i u konkretniho cloveka a nikdo by nevedel, kdo to ma resit. + */ +export function assignTicketGroup( + id: string, + groupId: string | null, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + ticket.assigneeGroupId = groupId; + if (groupId) ticket.assigneeId = null; + ticket.updatedAt = new Date().toISOString(); + + appendTrace(id, [ + { + kind: 'note', + status: 'info', + label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny', + }, + ]); + publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id }); + return toTicket(ticket); +} + export function addComment( id: string, author: string, diff --git a/src/data/users.ts b/src/data/users.ts index 1238100..28030d0 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -1,53 +1,127 @@ +/** + * Uzivatele portalu. + * + * Uzivatel se ctu pri kazdem requestu v autorizaci, proto se drzi kopie + * v pameti a cte se z ni synchronne. Vzor je v `store/cached.ts`. + * + * Heslo je vzdy jen hash. Do uloziste ani do odpovedi API nesmi plaintext. + */ + import bcrypt from 'bcryptjs'; -import type { User } from '../types.js'; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; +import type { Membership, User } from '../types.js'; + +/** Tvar v ulozisti. `User` je pouzivany typ, tohle je zaznam. */ +export interface StoredUser extends TenantEntity { + email: string; + passwordHash: string; + name: string; + platformAdmin: boolean; + memberships: Membership[]; + enabled: boolean; +} + +export const userStore = defineStore('user'); +const cache = withCache(userStore); + +const DEMO_PASSWORD = 'demo1234'; /** - * PROTOTYP: uzivatele jsou v pameti. Az prijde databaze, tohle ji nahradi. - * Hesla se hashuji pri startu, aby v kodu nebyl plaintext. - * - * Demo ucty schvalne pokryvaji vsechny tri situace, ktere se lisi pravy: + * Demo ucty schvalne pokryvaji tri situace, ktere se lisi pravy: * - platformni admin, ktery vidi napric firmami, * - clovek ve dvou firmach, v kazde s jinou roli, * - bezny resitel jedne firmy. */ -const DEMO_PASSWORD = 'demo1234'; +export function seedUsers(): StoredUser[] { + const timestamp = nowIso(); + const base = { + tenantId: null, + enabled: true, + passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), + createdAt: timestamp, + updatedAt: timestamp, + }; -export const users: User[] = [ - { - id: 'usr_1', - email: 'admin@automia.cz', - passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), - name: 'Jiří Uhlíř', - platformAdmin: true, - memberships: [{ tenantId: 'tnt_automia', role: 'admin' }], - }, - { - id: 'usr_2', - email: 'karel.vomacka@automia.cz', - passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), - name: 'Karel Vomáčka', - platformAdmin: false, - // Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk. - memberships: [ - { tenantId: 'tnt_automia', role: 'agent' }, - { tenantId: 'tnt_nordis', role: 'admin' }, - ], - }, - { - id: 'usr_3', - email: 'martin.kriz@automia.cz', - passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), - name: 'Martin Kříž', - platformAdmin: false, - memberships: [{ tenantId: 'tnt_automia', role: 'agent' }], - }, -]; + return [ + { + ...base, + id: 'usr_1', + email: 'admin@automia.cz', + name: 'Jiří Uhlíř', + platformAdmin: true, + memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'] }], + }, + { + ...base, + id: 'usr_2', + email: 'karel.vomacka@automia.cz', + name: 'Karel Vomáčka', + platformAdmin: false, + // Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk. + memberships: [ + { tenantId: 'tnt_automia', roleIds: ['agent'] }, + { tenantId: 'tnt_nordis', roleIds: ['admin'] }, + ], + }, + { + ...base, + id: 'usr_3', + email: 'martin.kriz@automia.cz', + name: 'Martin Kříž', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'] }], + }, + ]; +} + +export async function refreshUsers(): Promise { + await cache.refresh(); +} + +function toUser(stored: StoredUser): User { + return { + id: stored.id, + email: stored.email, + passwordHash: stored.passwordHash, + name: stored.name, + platformAdmin: stored.platformAdmin, + memberships: stored.memberships, + }; +} + +export function listUsers(): User[] { + return cache + .all() + .filter((user) => user.enabled) + .map(toUser) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +/** Vcetne vypnutych. Pro spravu uzivatelu. */ +export function listAllUsers(): StoredUser[] { + return [...cache.all()].sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} export function findUserByEmail(email: string): User | undefined { const normalized = email.trim().toLowerCase(); - return users.find((u) => u.email.toLowerCase() === normalized); + const found = cache.find((user) => user.email.toLowerCase() === normalized && user.enabled); + return found ? toUser(found) : undefined; } export function findUserById(id: string): User | undefined { - return users.find((u) => u.id === id); + const found = cache.byId(id); + // Vypnuty ucet se chova jako neexistujici, jinak by platny token dal fungoval. + return found && found.enabled ? toUser(found) : undefined; +} + +export function hashPassword(plain: string): string { + return bcrypt.hashSync(plain, 10); +} + +/** Uzivatele, kteri patri do dane firmy. */ +export function usersOfTenant(tenantId: string): StoredUser[] { + return cache + .all() + .filter((user) => user.memberships.some((membership) => membership.tenantId === tenantId)); } diff --git a/src/db/migrations/002_records.sql b/src/db/migrations/002_records.sql new file mode 100644 index 0000000..d31492e --- /dev/null +++ b/src/db/migrations/002_records.sql @@ -0,0 +1,33 @@ +-- Obecna tabulka zaznamu. +-- +-- Jedna tabulka pro vsechny entity, ktere jsou zatim jen konfigurace: typy +-- ticketu, definice akci, role, zalozky za firmu, vlastni widgety, audit. +-- +-- Proc jedna tabulka a JSONB, a ne osm typovanych: +-- - tvary se jeste hybou, kazda zmena by znamenala migraci, +-- - nikdo se nad nimi nedotazuje po jednotlivych polich, cte se cely zaznam, +-- - osm typovanych tabulek znamena osm mapovacich vrstev, ze kterych se jedna +-- casem opravi a ostatni ne. +-- +-- Az se tvar usadi a bude potreba dotazovat se dovnitr nebo hlidat vazby, +-- entita se povysi na vlastni tabulku. Presne to uz maji konektory: nesou +-- sifrovana tajemstvi a potrebuji castecny unikatni index. + +CREATE TABLE IF NOT EXISTS records ( + -- Druh entity, napr. 'ticketType', 'ticketAction', 'role'. + kind text NOT NULL, + id text NOT NULL, + -- NULL = platformni zaznam (nas, ne zakaznikuv). Napr. systemova role. + tenant_id text, + data jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (kind, id) +); + +-- Nejcastejsi dotaz je "vsechny zaznamy druhu X pro firmu Y", proto index +-- v tomhle poradi. `tenant_id` je v nem druhy, ne prvni: druh entity zuzuje +-- vic, protoze firem je radove desitky a druhu jednotky. +CREATE INDEX IF NOT EXISTS records_kind_tenant_idx + ON records (kind, tenant_id); diff --git a/src/index.ts b/src/index.ts index 7dbc9d7..6d010e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,10 @@ import { contactRouter } from './routes/contact.js'; import { dashboardRouter } from './routes/dashboard.js'; import { simulateRouter } from './routes/simulate.js'; import { webhookRouter } from './routes/webhook.js'; +import { bootstrapData } from './data/bootstrap.js'; import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js'; +import { flushStores } from './data/store/index.js'; +import { adminRouter } from './routes/admin.js'; import { runMigrations } from './db/migrate.js'; import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js'; import { ensureLoaded, scriptsDir } from './scripts/registry.js'; @@ -110,6 +113,7 @@ api.use( api.use('/api/auth', authRouter); api.use('/api/dashboard', dashboardRouter); +api.use('/api/admin', adminRouter); api.use('/api/simulate', simulateRouter); api.use('/api/contact', contactRouter); api.use('/webhook', webhookRouter); @@ -220,6 +224,12 @@ if (isDatabaseEnabled()) { } await initConnectorStore({ databaseReady }); +/** + * Ostatni entity: firmy, uzivatele, role, resitele, typy ticketu, akce, widgety. + * Jedno misto, kde se rekne, co existuje - viz `data/bootstrap.ts`. + */ +await bootstrapData({ databaseReady }); + // Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md). const server = app.listen(config.port, '0.0.0.0', () => { console.info(`[start] csbot-prototype bezi na portu ${config.port}`); @@ -237,7 +247,7 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) { server.close(() => { // Rozepsany zapis do souboru se musi dokoncit, jinak se posledni zmena // ztrati - debounce je kratky, ale nenulovy. - void flushConnectorStore() + void Promise.all([flushConnectorStore(), flushStores()]) .catch((err: unknown) => console.error('[stop] zapis dat selhal:', err)) .then(() => closeDatabase()) .finally(() => process.exit(0)); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 35d83fc..e60d38e 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -4,11 +4,20 @@ import { config } from '../config.js'; import { findUserById } from '../data/users.js'; import type { JwtPayload, User } from '../types.js'; +/** Kdyz je request pod impersonaci, tady je, kdo za tim opravdu je. */ +export interface Impersonation { + actorId: string; + actorEmail: string; + /** false = jen cteni. Vychozi rezim, zapis se musi vyslovne zapnout. */ + allowWrites: boolean; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { user?: User; + impersonation?: Impersonation; } } } @@ -30,6 +39,26 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) { return res.status(401).json({ error: 'unauthorized', message: 'Uživatel neexistuje.' }); } req.user = user; + + if (payload.act) { + req.impersonation = { + actorId: payload.act, + actorEmail: payload.actEmail ?? payload.act, + allowWrites: payload.writes === true, + }; + + // Jen cteni znamena jen cteni. Bez teto vetve by rezim byl jen napis. + if (!req.impersonation.allowWrites && req.method !== 'GET') { + console.warn( + `[auth] ${req.impersonation.actorEmail} zkusil zapis za ${user.email} bez povoleni`, + ); + return res.status(403).json({ + error: 'forbidden', + message: 'Přepnutí na jiný účet je jen pro čtení. Zapisovat takhle nelze.', + }); + } + } + return next(); } catch (err) { console.warn('[auth] neplatny token:', err instanceof Error ? err.message : err); diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..631c039 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,145 @@ +/** + * Platformni sprava: prepnuti na jiny ucet a audit. + * + * Impersonace je citliva vec, proto ma pevna pravidla: + * - smi jen spravce platformy a **nikdy na jineho spravce platformy**, + * - token plati kratce a neda se obnovit, + * - **vychozi rezim je jen cteni**, zapis se musi vyslovne zapnout, + * - start, konec i kazdy zapis jde do auditu, + * - v portalu je po celou dobu vyrazny pruh. Bez nej clovek zapomene + * a smaze neco cizim jmenem. + */ + +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { config } from '../config.js'; +import { listAudit, recordAudit } from '../data/audit.js'; +import { listAllUsers, findUserById } from '../data/users.js'; +import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js'; +import { readScope } from './crud.js'; + +export const adminRouter = Router(); +adminRouter.use(requireAuth); + +/** Kratka platnost je zamer. Impersonace neni rezim, ve kterem se pracuje. */ +const IMPERSONATION_MINUTES = 30; + +const startSchema = z.object({ + userId: z.string().min(1), + /** + * true = smi i zapisovat. Vyslovne, protoze vychozi je jen cteni: + * omylem smazany zaznam cizim jmenem se sponta nevrati. + */ + allowWrites: z.boolean().default(false), + reason: z.string().trim().max(300).optional(), +}); + +adminRouter.post('/impersonate', requirePlatformAdmin, (req, res) => { + const parsed = startSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'validation_error', message: 'Vyberte uživatele.' }); + } + + const target = findUserById(parsed.data.userId); + if (!target) { + return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' }); + } + if (target.id === req.user!.id) { + return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' }); + } + // Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus. + if (target.platformAdmin) { + return res.status(403).json({ + error: 'forbidden', + message: 'Na jiného správce platformy se přepnout nelze.', + }); + } + + const token = jwt.sign( + { + sub: target.id, + email: target.email, + // `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl. + act: req.user!.id, + actEmail: req.user!.email, + writes: parsed.data.allowWrites, + }, + config.jwtSecret, + { expiresIn: `${IMPERSONATION_MINUTES}m` }, + ); + + recordAudit({ + userId: target.id, + userEmail: target.email, + actedBy: req.user!.id, + tenantId: null, + action: 'impersonate.start', + target: target.id, + detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null }, + }); + + console.warn( + `[admin] ${req.user!.email} se prepina na ${target.email}` + + `${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`, + ); + + return res.json({ + token, + expiresInMinutes: IMPERSONATION_MINUTES, + user: { id: target.id, name: target.name, email: target.email }, + allowWrites: parsed.data.allowWrites, + }); +}); + +/** + * Konec impersonace. + * + * Server token nezneplatnuje - JWT je bezstavovy a drzet seznam odvolanych by + * znamenalo stav, ktery tu jinak neni. Klient se vrati ke svemu tokenu, ktery + * si nechal. Token impersonace stejne vyprsi za pul hodiny. + */ +adminRouter.post('/impersonate/stop', (req, res) => { + if (req.impersonation) { + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + actedBy: req.impersonation.actorId, + tenantId: null, + action: 'impersonate.stop', + target: req.user!.id, + }); + } + return res.status(204).end(); +}); + +/** Koho lze prepnout. Spravci platformy se nenabizeji. */ +adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => { + res.json({ + items: listAllUsers() + .filter((user) => !user.platformAdmin && user.enabled) + .map((user) => ({ id: user.id, name: user.name, email: user.email })), + }); +}); + +// ------------------------------------------------------------------- audit + +adminRouter.get('/audit', (req, res) => { + // Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log. + if (!req.user!.platformAdmin) { + return res.status(403).json({ error: 'forbidden', message: 'Audit vidí správce platformy.' }); + } + + const limit = Number(req.query.limit ?? 200); + return void listAudit({ + ...readScope(req), + action: typeof req.query.action === 'string' ? req.query.action : undefined, + result: req.query.result === 'denied' ? 'denied' : undefined, + limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200, + }) + .then((items) => res.json({ items })) + .catch((err: unknown) => { + console.error('[admin] audit se nepodarilo precist:', err); + res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' }); + }); +}); diff --git a/src/routes/crud.ts b/src/routes/crud.ts new file mode 100644 index 0000000..8fbf168 --- /dev/null +++ b/src/routes/crud.ts @@ -0,0 +1,210 @@ +/** + * Fabrika na CRUD routy nad obecnym ulozistem. + * + * Kazda nova entita by jinak znamenala stejnych sto radku: vyresit firmu, + * zvalidovat telo, vratit 404 na cizi zaznam, prevest na verzi pro klienta. + * Napsat to osmkrat znamena, ze se to sedmkrat opravi spatne. + * + * Pouziti: + * ```ts + * crudRouter({ + * store: ticketTypes, + * createSchema, updateSchema, + * build: (input, tenantId) => ({ ...input, key: slug(input.name) }), + * }) + * ``` + * + * Co fabrika drzi za pravidla: + * - filtr na firmu je vzdy povinny, cizi zaznam je 404 a ne 403, + * - zapis vyzaduje pravo, cteni staci prihlaseni, + * - platformni zaznamy (`tenantId: null`) smi menit jen spravce platformy. + */ + +import { randomUUID } from 'node:crypto'; +import { Router, type Request, type Response } from 'express'; +import type { z } from 'zod'; +import { accessFor } from '../data/access.js'; +import { hasPermission } from '../data/permissions.js'; +import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js'; + +/** Firma, ve ktere se prave pracuje. Zapis je vzdy do jedne. */ +export function currentTenant(req: Request, res: Response): string | null { + const access = accessFor(req.user!); + const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; + const tenantId = requested ?? access.defaultTenantId; + + if (!tenantId) { + res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); + return null; + } + if (!access.tenants.some((tenant) => tenant.id === tenantId)) { + console.warn(`[crud] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); + res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); + return null; + } + return tenantId; +} + +/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */ +export function readScope(req: Request, includeGlobal = true): ListOptions { + const access = accessFor(req.user!); + return { tenantIds: access.tenants.map((tenant) => tenant.id), includeGlobal }; +} + +export interface CrudOptions { + store: EntityStore; + /** Prefix ID, napr. `tt` pro typ ticketu. */ + idPrefix: string; + /** + * Schemata maji volny vstupni typ (`unknown`). Duvod: `.default()` a + * `.transform()` posouvaji vstup jinam nez vystup, a `z.ZodType` by pak + * na kazde schema s vychozi hodnotou nesedelo. + */ + createSchema: z.ZodType; + updateSchema: z.ZodType; + /** Pravo potrebne k zapisu. Cteni staci prihlaseni. */ + writePermission: string; + /** Sestavi novy zaznam z overeneho vstupu. */ + build: (input: C, tenantId: string) => Omit & Partial; + /** Vlastni kontrola nad ulozenym zaznamem. Vraci popisy problemu. */ + validate?: (entity: T, all: T[]) => string[]; + /** Uprava pred odeslanim klientovi, napr. schovani citlivych poli. */ + toPublic?: (entity: T) => unknown; + /** true = zaznamy vidi jen spravce platformy. */ + platformOnly?: boolean; +} + +export function crudRouter(options: CrudOptions): Router { + const router = Router(); + const publish = options.toPublic ?? ((entity: T) => entity); + + /** Smi uzivatel zapisovat? Jedno misto, aby se to nekontrolovalo jen nekde. */ + function canWrite(req: Request, res: Response, entity?: T): boolean { + if (options.platformOnly && !req.user!.platformAdmin) { + res.status(403).json({ error: 'forbidden', message: 'Tohle nastavuje správce platformy.' }); + return false; + } + // Platformni zaznam smi menit jen spravce platformy, i kdyz pravo jinak ma. + if (entity && entity.tenantId === null && !req.user!.platformAdmin) { + res.status(403).json({ + error: 'forbidden', + message: 'Tenhle záznam je systémový a mění ho jen správce platformy.', + }); + return false; + } + if (!hasPermission(req.user!, options.writePermission)) { + console.warn( + `[crud] ${req.user!.email}: chybi pravo ${options.writePermission} u ${options.store.kind}`, + ); + res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); + return false; + } + return true; + } + + router.get('/', async (req, res) => { + if (options.platformOnly && !req.user!.platformAdmin) { + return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' }); + } + const items = await options.store.list(readScope(req)); + return res.json({ items: items.map(publish) }); + }); + + router.get('/:id', async (req, res) => { + const entity = await options.store.get(req.params.id, readScope(req)); + if (!entity) { + return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + } + return res.json(publish(entity)); + }); + + router.post('/', async (req, res) => { + if (!canWrite(req, res)) return; + + const tenantId = currentTenant(req, res); + if (!tenantId) return; + + const parsed = options.createSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + issues: parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }); + } + + const timestamp = nowIso(); + const entity = { + ...options.build(parsed.data, tenantId), + id: `${options.idPrefix}_${randomUUID().slice(0, 8)}`, + tenantId, + createdAt: timestamp, + updatedAt: timestamp, + } as T; + + if (options.validate) { + const problems = options.validate(entity, await options.store.list(readScope(req))); + if (problems.length > 0) { + return res.status(400).json({ error: 'validation_error', message: problems[0] }); + } + } + + const created = await options.store.create(entity); + return res.status(201).json(publish(created)); + }); + + router.patch('/:id', async (req, res) => { + const existing = await options.store.get(req.params.id, readScope(req)); + if (!existing) { + return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + } + if (!canWrite(req, res, existing)) return; + + const parsed = options.updateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + if (options.validate) { + const merged = { ...existing, ...(parsed.data as Partial) } as T; + const others = (await options.store.list(readScope(req))).filter( + (item) => item.id !== existing.id, + ); + const problems = options.validate(merged, others); + if (problems.length > 0) { + return res.status(400).json({ error: 'validation_error', message: problems[0] }); + } + } + + const updated = await options.store.update( + req.params.id, + parsed.data as Partial, + readScope(req), + ); + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + } + return res.json(publish(updated)); + }); + + router.delete('/:id', async (req, res) => { + const existing = await options.store.get(req.params.id, readScope(req)); + if (!existing) { + return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + } + if (!canWrite(req, res, existing)) return; + + const removed = await options.store.remove(req.params.id, readScope(req)); + return removed + ? res.status(204).end() + : res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + }); + + return router; +} diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 90a8bbe..97dd67e 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -29,6 +29,7 @@ import { serviceCatalog, serviceCategories, visibleServices, + withRuntimeOptions, } from '../data/services.js'; import { getLayout, @@ -39,9 +40,10 @@ import { } from '../data/dashboardLayouts.js'; import { collectScopes } from '../data/flowScope.js'; import { widgets } from '../data/widgets.js'; +import { listCustomWidgets, sizesFor } from '../data/customWidgets.js'; import { listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; -import { findPersonByEmail, listPeople } from '../data/people.js'; +import { findPersonByEmail, listGroups, listPeople } from '../data/people.js'; import { addComment, assignTicket, @@ -56,6 +58,9 @@ import { import { requireAuth } from '../middleware/auth.js'; import { validateRules } from '../scripts/mapping.js'; import { connectorsRouter } from './connectors.js'; +import { settingsRouter } from './settings.js'; +import { ticketActionsRouter } from './ticketActions.js'; +import { widgetDataRouter } from './widgetData.js'; import { scriptsRouter } from './scripts.js'; import { streamRouter } from './stream.js'; @@ -108,8 +113,30 @@ dashboardRouter.get('/incidents', (_req, res) => { // ------------------------------------------------------- rozlozeni dashboardu -dashboardRouter.get('/widgets', (_req, res) => { - res.json({ items: widgets }); +/** + * Katalog widgetu: pevne z kodu plus vlastni firmy. + * + * Vlastni widget je pro klienta tentyz tvar jako pevny, jen `custom: true`. + * Diky tomu se rozlozeni dashboardu nemuselo menit. + */ +dashboardRouter.get('/widgets', (req, res) => { + const access = accessFor(req.user!); + const tenantIds = access.tenants.map((tenant) => tenant.id); + + const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({ + id: widget.id, + name: widget.name, + description: widget.description, + kind: widget.render, + sizes: sizesFor(widget.render), + defaultSize: widget.size, + custom: true, + render: widget.render, + source: widget.source, + ...(widget.target !== undefined ? { target: widget.target } : {}), + })); + + res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] }); }); /** @@ -367,6 +394,15 @@ dashboardRouter.use('/scripts', scriptsRouter); // Sluzby a konektory. `/connectors/services` je uvnitr toho routeru. dashboardRouter.use('/connectors', connectorsRouter); +// Nastaveni vsech entit. Cele stoji na fabrice crudRouter. +dashboardRouter.use('/settings', settingsRouter); + +// Data pro vlastni widgety. Jeden request na cely prehled. +dashboardRouter.use('/widget-data', widgetDataRouter); + +// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`. +dashboardRouter.use('/tickets', ticketActionsRouter); + // ------------------------------------------------------------------- sluzby /** @@ -384,10 +420,17 @@ dashboardRouter.get('/services', (req, res) => { const tenantId = requested ?? access.defaultTenantId; const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id)); + const tenantIds = tenantId ? [tenantId] : []; res.json({ categories: serviceCategories, - items: serviceCatalog().filter((service) => visible.has(service.id)), + items: withRuntimeOptions( + serviceCatalog().filter((service) => visible.has(service.id)), + { + people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })), + groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })), + }, + ), // Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu, // a jakou zakladni adresu ukazat u webhooku. operatorsByType, diff --git a/src/routes/settings.ts b/src/routes/settings.ts new file mode 100644 index 0000000..d1388a3 --- /dev/null +++ b/src/routes/settings.ts @@ -0,0 +1,658 @@ +/** + * Nastaveni: firmy, uzivatele, role, resitele, skupiny, zalozky, typy ticketu, + * akce a vlastni widgety. + * + * Vsechno stoji na `crudRouter`, takze se tady pise jen to, co je u dane entity + * jine: schema vstupu, jak se z nej sestavi zaznam a co se ma overit. Zbytek + * (firma, 404 na cizi zaznam, prava, chybove tvary) je v te fabrice. + * + * Po kazdem zapisu se obnovi kopie v pameti (`refreshCaches`). Bez toho by + * uzivatel ulozil roli a prava by se zmenila az po restartu. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { recordAudit } from '../data/audit.js'; +import { bootstrapDataRefresh } from '../data/refresh.js'; +import { + customWidgetStore, + listCustomWidgets, + sizesFor, + validateWidget, + type CustomWidget, +} from '../data/customWidgets.js'; +import { groupStore, listAllPeople, personStore, type Person } from '../data/people.js'; +import { + allPermissions, + roleStore, + rolesFor, + type Role, +} from '../data/permissions.js'; +import { + featuresStore, + moduleCatalog, + defaultLimits, + defaultModules, + featuresOf, + type TenantFeatures, +} from '../data/tenantFeatures.js'; +import { listTenants, tenantStore, type Tenant } from '../data/tenants.js'; +import { listTicketTypes, ticketTypeStore, type TicketType } from '../data/ticketTypes.js'; +import { + actionStore, + listActions, + validateAction, + type TicketAction, +} from '../data/ticketActions.js'; +import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js'; +import { requireAuth } from '../middleware/auth.js'; +import { crudRouter, currentTenant, readScope } from './crud.js'; + +export const settingsRouter = Router(); +settingsRouter.use(requireAuth); + +/** Zapis do jakekoliv entity muze zmenit prava nebo nabidky, proto obnova. */ +settingsRouter.use((req, res, next) => { + if (req.method === 'GET') return next(); + res.on('finish', () => { + if (res.statusCode < 400) void bootstrapDataRefresh(); + }); + return next(); +}); + +// ------------------------------------------------------------------ katalogy + +/** Co lze nastavit. Klient z toho kresli zaskrtavatka, nehada si vlastni seznam. */ +settingsRouter.get('/catalog', (req, res) => { + res.json({ + permissions: allPermissions(), + modules: moduleCatalog, + defaultLimits, + widgetRenders: (['stat', 'chart', 'list', 'table', 'gauge'] as const).map((render) => ({ + render, + sizes: sizesFor(render), + })), + platformAdmin: req.user!.platformAdmin, + }); +}); + +// -------------------------------------------------------------------- firmy + +const tenantCreate = z.object({ + name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80), + note: z.string().trim().max(500).optional(), +}); + +settingsRouter.use( + '/tenants', + crudRouter, Partial>({ + store: tenantStore, + idPrefix: 'tnt', + createSchema: tenantCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(80).optional(), + note: z.string().trim().max(500).optional(), + enabled: z.boolean().optional(), + }), + writePermission: 'tenant.manage', + platformOnly: true, + // Firma nepatri jine firme, proto tenantId null. + build: (input) => ({ name: input.name, note: input.note ?? '', enabled: true, tenantId: null }), + validate: (tenant, all) => + all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase()) + ? [`Firma ${tenant.name} už existuje.`] + : [], + }), +); + +// ---------------------------------------------------------------- uzivatele + +const membershipSchema = z.object({ + tenantId: z.string().min(1), + roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'), +}); + +const userCreate = z.object({ + email: z.string().trim().email('Zadejte platný e-mail.'), + name: z.string().trim().min(2).max(80), + password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'), + platformAdmin: z.boolean().optional(), + memberships: z.array(membershipSchema).default([]), +}); + +/** Heslo se z API nikdy nevraci, ani jako hash. */ +function publicUser(user: StoredUser) { + const { passwordHash: _passwordHash, ...rest } = user; + return rest; +} + +settingsRouter.use( + '/users', + crudRouter, Partial>({ + store: userStore, + idPrefix: 'usr', + createSchema: userCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(80).optional(), + email: z.string().trim().email().optional(), + password: z.string().min(8).optional(), + platformAdmin: z.boolean().optional(), + memberships: z.array(membershipSchema).optional(), + enabled: z.boolean().optional(), + }), + writePermission: 'user.manage', + build: (input) => ({ + email: input.email.toLowerCase(), + name: input.name, + passwordHash: hashPassword(input.password), + platformAdmin: input.platformAdmin ?? false, + memberships: input.memberships, + enabled: true, + // Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. + tenantId: null, + }), + toPublic: publicUser, + validate: (user, all) => { + const problems: string[] = []; + if (all.some((other) => other.email.toLowerCase() === user.email.toLowerCase())) { + problems.push(`E-mail ${user.email} už někdo má.`); + } + for (const membership of user.memberships) { + if (!listTenants().some((tenant) => tenant.id === membership.tenantId)) { + problems.push(`Firma ${membership.tenantId} neexistuje.`); + } + } + return problems; + }, + }), +); + +/** + * Zmena hesla musi projit hashovanim. + * Bez teto vetve by `PATCH` ulozil plaintext do pole `password`, ktere nikdo + * nikdy neprecte, a heslo by se nezmenilo. + */ +settingsRouter.patch('/users/:id/password', async (req, res) => { + const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'validation_error', message: 'Heslo musí mít aspoň 8 znaků.' }); + } + + const target = await userStore.get(req.params.id, readScope(req)); + if (!target) return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' }); + + // Svoje heslo si smi zmenit kazdy, cizi jen kdo spravuje uzivatele. + const own = target.id === req.user!.id; + if (!own && !req.user!.platformAdmin) { + return res.status(403).json({ error: 'forbidden', message: 'Cizí heslo měnit nemůžete.' }); + } + + await userStore.update( + req.params.id, + { passwordHash: hashPassword(parsed.data.password) } as Partial, + readScope(req), + ); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: null, + action: 'user.password', + target: target.id, + }); + return res.status(204).end(); +}); + +/** Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. */ +settingsRouter.get('/users-overview', (req, res) => { + if (!req.user!.platformAdmin) { + return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' }); + } + return res.json({ items: listAllUsers().map(publicUser) }); +}); + +// ---------------------------------------------------------------------- role + +const roleCreate = z.object({ + name: z.string().trim().min(2).max(60), + description: z.string().trim().max(300).optional(), + permissions: z.array(z.string()).default([]), +}); + +settingsRouter.use( + '/roles', + crudRouter, Partial>({ + store: roleStore, + idPrefix: 'role', + createSchema: roleCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + description: z.string().trim().max(300).optional(), + permissions: z.array(z.string()).optional(), + }), + writePermission: 'role.manage', + build: (input, tenantId) => ({ + key: `role_${tenantId}_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, + name: input.name, + description: input.description ?? '', + permissions: input.permissions, + system: false, + }), + validate: (role) => { + const known = new Set(allPermissions().map((item) => item.key)); + const unknown = role.permissions.filter((permission) => !known.has(permission)); + // Neznamé právo je chyba, ne varovani: role by tise nedelala, co se ceka. + return unknown.length > 0 ? [`Neznámá práva: ${unknown.join(', ')}`] : []; + }, + }), +); + +/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */ +settingsRouter.get('/roles-available', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + return res.json({ items: rolesFor(tenantId) }); +}); + +// ------------------------------------------------------------------ resitele + +const personCreate = z.object({ + name: z.string().trim().min(2).max(80), + email: z.string().trim().email(), + role: z.string().trim().max(60).optional(), + capacity: z.number().int().min(1).max(200).optional(), +}); + +settingsRouter.use( + '/people', + crudRouter, Partial>({ + store: personStore, + idPrefix: 'ppl', + createSchema: personCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(80).optional(), + email: z.string().trim().email().optional(), + role: z.string().trim().max(60).optional(), + capacity: z.number().int().min(1).max(200).optional(), + enabled: z.boolean().optional(), + }), + writePermission: 'people.manage', + build: (input) => ({ + name: input.name, + email: input.email.toLowerCase(), + role: input.role ?? '', + capacity: input.capacity ?? 8, + enabled: true, + }), + validate: (person, all) => + all.some((other) => other.email.toLowerCase() === person.email.toLowerCase()) + ? [`Řešitel s e-mailem ${person.email} už v této firmě je.`] + : [], + }), +); + +/** Vcetne vypnutych. Pro spravu tymu. */ +settingsRouter.get('/people-overview', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + return res.json({ items: listAllPeople([tenantId]) }); +}); + +// ------------------------------------------------------------------- skupiny + +const groupCreate = z.object({ + name: z.string().trim().min(2).max(60), + personIds: z.array(z.string()).default([]), +}); + +settingsRouter.use( + '/groups', + crudRouter({ + store: groupStore, + idPrefix: 'grp', + createSchema: groupCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + personIds: z.array(z.string()).optional(), + }), + writePermission: 'group.manage', + build: (input: z.infer) => ({ + name: input.name, + personIds: input.personIds, + }), + }), +); + +// ------------------------------------------------------- zalozky a limity firmy + +const featuresUpdate = z.object({ + modules: z.array(z.string()).optional(), + limits: z + .object({ + automations: z.number().int().min(0).max(10_000), + widgets: z.number().int().min(0).max(200), + actions: z.number().int().min(0).max(1_000), + connectors: z.number().int().min(0).max(500), + }) + .optional(), + serviceIds: z.array(z.string()).optional(), +}); + +/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */ +settingsRouter.get('/features', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + return res.json({ tenantId, features: featuresOf(tenantId) }); +}); + +settingsRouter.put('/features', async (req, res) => { + if (!req.user!.platformAdmin) { + return res.status(403).json({ error: 'forbidden', message: 'Záložky nastavuje správce platformy.' }); + } + const tenantId = currentTenant(req, res); + if (!tenantId) return; + + const parsed = featuresUpdate.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + const known = new Set(moduleCatalog.map((module) => module.key)); + const unknown = (parsed.data.modules ?? []).filter((key) => !known.has(key as never)); + if (unknown.length > 0) { + return res.status(400).json({ + error: 'validation_error', + message: `Neznámé moduly: ${unknown.join(', ')}`, + }); + } + + // Povinne moduly se doplni vzdy. Bez prehledu a nastaveni nema portal kde zacit. + const required = moduleCatalog.filter((module) => module.required).map((module) => module.key); + const modules = [...new Set([...(parsed.data.modules ?? defaultModules()), ...required])]; + + const existing = (await featuresStore.list({ tenantIds: [tenantId] })).find( + (item) => item.tenantId === tenantId, + ); + + const payload: Partial = { + modules: modules as TenantFeatures['modules'], + limits: parsed.data.limits ?? featuresOf(tenantId).limits, + serviceIds: parsed.data.serviceIds ?? featuresOf(tenantId).serviceIds, + }; + + const saved = existing + ? await featuresStore.update(existing.id, payload, { tenantIds: [tenantId] }) + : await featuresStore.create({ + id: `feat_${tenantId}`, + tenantId, + modules: payload.modules!, + limits: payload.limits!, + serviceIds: payload.serviceIds!, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'tenant.features', + target: tenantId, + detail: { modules }, + }); + + return res.json({ tenantId, features: saved }); +}); + +// -------------------------------------------------------------- typy ticketu + +const fieldSchema = z.object({ + id: z.string().min(1).optional(), + key: z + .string() + .trim() + .regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Klíč: písmena, číslice a _, začíná písmenem.'), + label: z.string().trim().min(1), + type: z.enum(['string', 'number', 'boolean', 'date']), + required: z.boolean(), + hint: z.string().trim().max(200).optional(), + options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), +}); + +const typeCreate = z.object({ + key: z + .string() + .trim() + .regex(/^[a-z][a-z0-9-]*$/, 'Klíč typu: malá písmena, číslice a pomlčky.'), + name: z.string().trim().min(2).max(60), + icon: z.string().trim().max(40).optional(), + statuses: z.array(z.string().trim().min(1)).default([]), + fields: z.array(fieldSchema).default([]), +}); + +/** ID poli musi byt stabilni, odkazuji se na nej podminky. Chybejici dogeneruje. */ +function withFieldIds(fields: z.infer[]): TicketType['fields'] { + return fields.map((field, index) => ({ + id: field.id ?? `fld_${field.key}_${index}`, + key: field.key, + label: field.label, + type: field.type, + required: field.required, + ...(field.hint ? { hint: field.hint } : {}), + ...(field.options ? { options: field.options } : {}), + })); +} + +settingsRouter.use( + '/ticket-types', + crudRouter, Partial>({ + store: ticketTypeStore, + idPrefix: 'tt', + createSchema: typeCreate, + // Transformace dogeneruje ID poli. Bez ni by pole prislo bez `id` + // a podminky v akcich by se nemely na co odkazat. + updateSchema: z + .object({ + name: z.string().trim().min(2).max(60).optional(), + icon: z.string().trim().max(40).optional(), + statuses: z.array(z.string().trim().min(1)).optional(), + fields: z.array(fieldSchema).optional(), + }) + .transform(({ fields, ...rest }): Partial => ({ + ...rest, + ...(fields ? { fields: withFieldIds(fields) } : {}), + })), + writePermission: 'ticketType.manage', + build: (input) => ({ + key: input.key, + name: input.name, + icon: input.icon ?? 'LifeBuoy', + statuses: input.statuses, + fields: withFieldIds(input.fields), + }), + validate: (type, all) => { + const problems: string[] = []; + if (all.some((other) => other.key === type.key)) { + problems.push(`Typ s klíčem ${type.key} už v této firmě je.`); + } + const keys = new Set(); + for (const field of type.fields) { + if (keys.has(field.key)) problems.push(`Pole ${field.key} je uvedené dvakrát.`); + keys.add(field.key); + } + return problems; + }, + }), +); + +// -------------------------------------------------------------------- akce + +const conditionSchema = z.object({ + fieldId: z.string().min(1), + operator: z.enum([ + 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', + 'contains', 'startsWith', 'isEmpty', 'isNotEmpty', 'isTrue', 'isFalse', + ]), + value: z.string().optional(), +}); + +/** Telo akce. Vysledek se pretypuje na `ActionBody`, viz komentar u kroku. */ +const bodySchema: z.ZodType = z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('operation'), + serviceId: z.string().min(1), + operationId: z.string().min(1), + connectorId: z.string().min(1).nullable().default(null), + inputs: z.record(z.string()).default({}), + }), + // Kroky se tady netypuji: jejich schema je u automatizaci a duplikovat ho + // sem by znamenalo dve definice, ze kterych se jedna casem rozejde. + z.object({ + kind: z.literal('tree'), + steps: z.array(z.record(z.unknown())).default([]), + }), + z.object({ + kind: z.literal('script'), + scriptId: z.string().min(1), + inputs: z.record(z.string()).default({}), + }), + ]) + .transform((body) => body as TicketAction['body']); + +const actionCreate = z.object({ + label: z.string().trim().min(2).max(60), + icon: z.string().trim().max(40).optional(), + style: z.enum(['primary', 'default', 'danger']).default('default'), + order: z.number().int().min(0).max(999).default(10), + ticketTypeIds: z.array(z.string()).default([]), + tags: z.array(z.string().trim().min(1)).default([]), + visibleWhen: z.array(conditionSchema).default([]), + confirm: z.string().trim().max(300).nullable().default(null), + form: z + .array( + z.object({ + id: z.string().min(1), + label: z.string().trim().min(1), + kind: z.enum(['text', 'longtext', 'choice']), + required: z.boolean(), + options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), + hint: z.string().optional(), + }), + ) + .default([]), + body: bodySchema, +}); + +settingsRouter.use( + '/actions', + crudRouter, Partial>({ + store: actionStore, + idPrefix: 'tka', + createSchema: actionCreate, + updateSchema: z.object({ + label: z.string().trim().min(2).max(60).optional(), + icon: z.string().trim().max(40).optional(), + style: z.enum(['primary', 'default', 'danger']).optional(), + order: z.number().int().min(0).max(999).optional(), + ticketTypeIds: z.array(z.string()).optional(), + tags: z.array(z.string().trim().min(1)).optional(), + visibleWhen: z.array(conditionSchema).optional(), + confirm: z.string().trim().max(300).nullable().optional(), + body: bodySchema.optional(), + enabled: z.boolean().optional(), + }), + writePermission: 'action.manage', + build: (input) => ({ + label: input.label, + icon: input.icon ?? 'Play', + style: input.style, + order: input.order, + ticketTypeIds: input.ticketTypeIds, + tags: input.tags, + visibleWhen: input.visibleWhen, + confirm: input.confirm, + form: input.form, + body: input.body, + enabled: true, + }), + validate: (action) => validateAction(action, listTicketTypes([action.tenantId])), + }), +); + +/** Akce firmy vcetne vypnutych. Pro spravu. */ +settingsRouter.get('/actions-overview', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + return res.json({ items: listActions([tenantId]) }); +}); + +// -------------------------------------------------------------- vlastni widgety + +const sourceSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('ticketCount'), + filter: z.record(z.unknown()).default({}), + groupBy: z.enum(['assignee', 'group', 'status', 'type', 'tag', 'channel']).optional(), + }), + z.object({ + kind: z.literal('ticketList'), + filter: z.record(z.unknown()).default({}), + limit: z.number().int().min(1).max(50).default(5), + }), + z.object({ + kind: z.literal('ticketSeries'), + filter: z.record(z.unknown()).default({}), + bucket: z.enum(['day', 'week']).default('day'), + }), + z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }), +]); + +const widgetCreate = z.object({ + name: z.string().trim().min(2).max(60), + description: z.string().trim().max(200).optional(), + render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']), + source: sourceSchema, + size: z.enum(['third', 'half', 'full']), + target: z.number().optional(), + /** true = widget jen pro me, jinak pro celou firmu. */ + personal: z.boolean().default(false), +}); + +settingsRouter.use( + '/widgets', + crudRouter, Partial>({ + store: customWidgetStore, + idPrefix: 'cw', + createSchema: widgetCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + description: z.string().trim().max(200).optional(), + render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']).optional(), + source: sourceSchema.optional(), + size: z.enum(['third', 'half', 'full']).optional(), + target: z.number().optional(), + }), + writePermission: 'widget.manage', + build: (input) => ({ + name: input.name, + description: input.description ?? '', + render: input.render, + source: input.source as CustomWidget['source'], + size: input.size, + ...(input.target !== undefined ? { target: input.target } : {}), + // Osobni widget vidi jen jeho autor. Vyplni se az v route, viz nize. + ownerId: null, + }), + validate: (widget) => validateWidget(widget), + }), +); + +/** Widgety firmy plus osobni prihlaseneho. */ +settingsRouter.get('/widgets-overview', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + return res.json({ items: listCustomWidgets([tenantId], req.user!.id) }); +}); diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts new file mode 100644 index 0000000..948dd80 --- /dev/null +++ b/src/routes/ticketActions.ts @@ -0,0 +1,415 @@ +/** + * Akce na ticketu: co se nabizi a spusteni. + * + * Seznam **filtruje server**, ne klient. Je to totez pravidlo jako u `access`: + * kdyby si klient pocital, ktera tlacitka ukazat, pocitalo by se to na dvou + * mistech a jednou se to rozejde. + * + * Spusteni zapise zaznam do logu ticketu **hned**, jeste nez se neco stane. + * Log ticketu uz je strom a lide do nej chodi - zvlastni evidence "kdo co + * zmackl" by znamenala dve casove osy a hledani ve dvou mistech. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { isDenied, resolveScope } from '../data/access.js'; +import { recordAudit } from '../data/audit.js'; +import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; +import { findGroup } from '../data/people.js'; +import { hasPermission } from '../data/permissions.js'; +import { renderTemplate } from '../data/templates.js'; +import { + appendTrace, + assignTicketGroup, + getTicket, + setTicketTags, + setTicketType, + type Ticket, +} from '../data/ticketStore.js'; +import { + actionPermission, + actionsForTicket, + conditionsPass, + findAction, + type TicketAction, + type TicketFacts, +} from '../data/ticketActions.js'; +import { findTicketType, validateTicketFields } from '../data/ticketTypes.js'; +import { runScript } from '../scripts/runner.js'; +import { scriptIdFor } from '../scripts/lookup.js'; + +export const ticketActionsRouter = Router(); + +/** + * Hodnoty, proti kterym se vyhodnocuji podminky a sablony. + * + * Vestavena pole ticketu maji prefix `ticket.`, protoze na ne odkazuji + * podminky. Vlastni pole typu jsou navic pod svym `key`, aby se do sablony + * pisalo `{{orderNumber}}` a ne `{{ticket.fld_order_no}}`. + */ +function factsOf(ticket: Ticket): TicketFacts { + const facts: TicketFacts = { + 'ticket.id': ticket.id, + 'ticket.subject': ticket.subject, + 'ticket.body': ticket.body, + 'ticket.status': ticket.status, + 'ticket.priority': ticket.priority, + 'ticket.company': ticket.customer.company, + 'ticket.contact': ticket.customer.contact, + 'ticket.reply': ticket.customer.reply, + }; + + const type = ticket.typeId ? findTicketType(ticket.typeId) : undefined; + for (const field of type?.fields ?? []) { + const value = ticket.fields[field.key] ?? null; + facts[`ticket.${field.id}`] = value; + facts[field.key] = value; + } + + return facts; +} + +/** Sablony v nastaveni akce se dosazuji z ticketu a z doptavaciho formulare. */ +function fillInputs( + inputs: Record, + facts: TicketFacts, + form: Record, +): Record { + const values: Record = {}; + for (const [key, value] of Object.entries(facts)) { + values[key] = value === null ? '' : String(value); + } + for (const [key, value] of Object.entries(form)) values[key] = value; + + const result: Record = {}; + for (const [key, template] of Object.entries(inputs)) { + result[key] = renderTemplate(template, values); + } + return result; +} + +/** Verze pro klienta. Telo akce se nevraci, klient ho k nicemu nepotrebuje. */ +function publicAction(action: TicketAction) { + return { + id: action.id, + label: action.label, + icon: action.icon, + style: action.style, + confirm: action.confirm, + form: action.form, + }; +} + +/** Akce, ktere na ticket sedi. Uz vyfiltrovane podle typu, tagu, podminek a prav. */ +ticketActionsRouter.get('/:id/actions', async (req, res) => { + const scope = resolveScope(req.user!, { + scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + if (isDenied(scope)) { + return res.status(scope.status).json({ error: scope.error, message: scope.message }); + } + + const ticket = getTicket(req.params.id, scope.tenantIds); + if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + + const actions = actionsForTicket( + [ticket.tenantId], + { typeId: ticket.typeId, tags: ticket.tags }, + factsOf(ticket), + (permission) => hasPermission(req.user!, permission), + ); + + return res.json({ items: actions.map(publicAction) }); +}); + +const runSchema = z.object({ + form: z.record(z.string()).default({}), +}); + +/** + * Spusteni akce. + * + * Kontroluje se znovu vsechno, co se kontrolovalo pri vypisu. Klient mohl mit + * otevrenou starou stranku, nebo tlacitko vubec nezobrazit a poslat request sam. + */ +ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => { + const scope = resolveScope(req.user!, { + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + if (isDenied(scope)) { + return res.status(scope.status).json({ error: scope.error, message: scope.message }); + } + + const ticket = getTicket(req.params.id, scope.tenantIds); + if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + + const action = findAction(req.params.actionId); + if (!action || action.tenantId !== ticket.tenantId || !action.enabled) { + return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' }); + } + + if (!hasPermission(req.user!, actionPermission(action.id))) { + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: ticket.tenantId, + action: 'ticket.action.run', + target: `${ticket.id}/${action.id}`, + result: 'denied', + }); + return res.status(403).json({ error: 'forbidden', message: 'K této akci nemáte oprávnění.' }); + } + + const facts = factsOf(ticket); + if (!conditionsPass(action.visibleWhen, facts)) { + // 409, ne 400: vstup je v poradku, jen v tomhle stavu to nedava smysl. + return res.status(409).json({ + error: 'conflict', + message: 'V tomhle stavu ticketu akci spustit nelze. Zkuste stránku obnovit.', + }); + } + + const parsed = runSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'validation_error', message: 'Neplatný formulář.' }); + } + + for (const field of action.form) { + if (field.required && (parsed.data.form[field.id] ?? '').trim() === '') { + return res.status(400).json({ + error: 'validation_error', + message: `${field.label} je povinné.`, + }); + } + } + + // Zaznam do logu hned, jeste nez se neco stane. Kdyz to spadne, je videt, + // ze to nekdo spustil a co se dalo. + appendTrace(ticket.id, [ + { + kind: 'action', + status: 'info', + label: `Ručně spustil ${req.user!.name}: ${action.label}`, + }, + ]); + + const result = await runAction(action, facts, parsed.data.form, ticket, req.user!.email); + + appendTrace(ticket.id, [ + { + kind: 'action', + status: result.ok ? 'ok' : 'error', + label: `${action.label}: ${result.summary}`, + serviceId: action.body.kind === 'operation' ? action.body.serviceId : null, + operationId: action.body.kind === 'operation' ? action.body.operationId : null, + response: result.detail, + durationMs: result.durationMs, + }, + ]); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: ticket.tenantId, + action: 'ticket.action.run', + target: `${ticket.id}/${action.id}`, + detail: { ok: result.ok, summary: result.summary }, + // `denied` je vyhrazene pro odepreni prava. Neuspesny beh je vysledek, + // ne odepreni - jinak by se v auditu nepoznalo, co bylo co. + result: 'ok', + }); + + // Chyba akce neni chyba API, je to vysledek. Proto 200 v obou pripadech. + return res.json(result); +}); + +interface ActionResult { + ok: boolean; + summary: string; + detail: string | null; + durationMs: number; + outputs: Record; +} + +/** + * Vykonani tela akce. + * + * Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu + * a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola + * se odsud totez z workeru. + */ +async function runAction( + action: TicketAction, + facts: TicketFacts, + form: Record, + ticket: Ticket, + actorEmail: string, +): Promise { + const startedAt = Date.now(); + const body = action.body; + + if (body.kind === 'tree') { + // Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas, + // misto aby se tvarilo, ze se neco stalo. + return { + ok: false, + summary: 'strom akce zatím nejde vykonat, chybí runtime', + detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.', + durationMs: Date.now() - startedAt, + outputs: {}, + }; + } + + const scriptId = + body.kind === 'script' ? body.scriptId : scriptIdFor(body.serviceId, body.operationId); + + if (!scriptId) { + return { + ok: false, + summary: 'operace nemá výkonnou část', + detail: `Služba ${body.kind === 'operation' ? body.serviceId : ''} nemá pro tuhle operaci skript, takže se nedá vykonat.`, + durationMs: Date.now() - startedAt, + outputs: {}, + }; + } + + const connector = + body.kind === 'operation' + ? body.connectorId + ? await getConnector(body.connectorId, [ticket.tenantId]) + : await defaultConnectorFor(ticket.tenantId, body.serviceId) + : null; + + const inputs = fillInputs(body.inputs, facts, form); + // Klic je stabilni na dvojici ticket a akce, takze dvojklik nevystavi + // druhou fakturu. + const idempotencyKey = `ticket:${ticket.id}:${action.id}`; + + const result = await runScript(scriptId, inputs, { + connector: connector ?? null, + idempotencyKey, + }); + + if (!result.ok) { + console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${result.error?.message}`); + } + + return { + ok: result.ok, + summary: result.ok ? 'proběhlo' : (result.error?.message ?? 'selhalo'), + detail: result.error?.detail ?? (result.ok ? JSON.stringify(result.outputs) : null), + durationMs: result.durationMs, + outputs: result.outputs, + }; +} + +// ------------------------------------------------- vestavene akce na ticketu + +/** + * Vestavene akce jsou ve stejnem rezimu jako vlastni: kazda ma svoje pravo + * a projde auditem. Diky tomu jde vestavenou akci nekomu vypnout bez zmeny kodu. + * + * Fabrika, ne tri skoro stejne handlery. Kazdy by jinak resil znovu firmu, + * pravo, 404, audit a udalost. + */ +function builtinAction(options: { + path: string; + permission: string; + auditAction: string; + schema: z.ZodType; + apply: (ticket: Ticket, input: T, tenantIds: string[]) => Ticket | undefined; +}): void { + ticketActionsRouter.post(`/:id/${options.path}`, (req, res) => { + const scope = resolveScope(req.user!, { + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + if (isDenied(scope)) { + return res.status(scope.status).json({ error: scope.error, message: scope.message }); + } + + const ticket = getTicket(req.params.id, scope.tenantIds); + if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + + if (!hasPermission(req.user!, options.permission)) { + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + actedBy: req.impersonation?.actorId ?? null, + tenantId: ticket.tenantId, + action: options.auditAction, + target: ticket.id, + result: 'denied', + }); + return res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); + } + + const parsed = options.schema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + const updated = options.apply(ticket, parsed.data, scope.tenantIds); + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + } + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + actedBy: req.impersonation?.actorId ?? null, + tenantId: ticket.tenantId, + action: options.auditAction, + target: ticket.id, + detail: parsed.data as Record, + }); + + return res.json(updated); + }); +} + +builtinAction({ + path: 'type', + permission: 'ticket.type.change', + auditAction: 'ticket.type', + schema: z.object({ + typeId: z.string().min(1).nullable(), + fields: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), + }), + apply: (ticket, input, tenantIds) => { + // Typ musi patrit te same firme, jinak by ticket dostal cizi pole. + if (input.typeId) { + const type = findTicketType(input.typeId); + if (!type || type.tenantId !== ticket.tenantId) return undefined; + const problems = validateTicketFields(type, input.fields ?? ticket.fields); + // Nevyplnena povinna pole nejsou duvod typ neprepnout, jen se zaloguji. + if (problems.length > 0) console.info(`[tickets] ${ticket.id}: ${problems.join(' ')}`); + } + return setTicketType(ticket.id, input.typeId, input.fields, tenantIds); + }, +}); + +builtinAction({ + path: 'tags', + permission: 'ticket.tag', + auditAction: 'ticket.tags', + schema: z.object({ tags: z.array(z.string().trim().min(1).max(40)).max(20) }), + apply: (ticket, input, tenantIds) => setTicketTags(ticket.id, input.tags, tenantIds), +}); + +builtinAction({ + path: 'group', + permission: 'ticket.assign.group', + auditAction: 'ticket.group', + schema: z.object({ groupId: z.string().min(1).nullable() }), + apply: (ticket, input, tenantIds) => { + if (input.groupId) { + const group = findGroup(input.groupId); + if (!group || group.tenantId !== ticket.tenantId) return undefined; + } + return assignTicketGroup(ticket.id, input.groupId, tenantIds); + }, +}); diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts new file mode 100644 index 0000000..cab4dba --- /dev/null +++ b/src/routes/widgetData.ts @@ -0,0 +1,276 @@ +/** + * Data pro vlastni widgety. + * + * Jeden request na cely prehled. Kdyby si kazda dlazdice nacitala data sama, + * deset dlazdic by znamenalo deset dotazu - puvodni pravidlo "data si nacita + * prehled, ne widgety" tim zustava v platnosti i u vlastnich widgetu. + * + * Widget, ktery selze, vraci chybu **na sve pozici**. Jeden rozbity zdroj nesmi + * zhasnout cely prehled. + * + * Uzivatel nepise dotazy: filtr je tentyz, ktery uz umi seznam ticketu, takze + * nejde poslat nic, co by server polozilo. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { isDenied, resolveScope, type ResolvedScope } from '../data/access.js'; +import { + findCustomWidget, + type CustomWidget, + type WidgetGroupBy, + type WidgetTicketFilter, +} from '../data/customWidgets.js'; +import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js'; +import { channelLabels, getWorkload, listTickets, type Ticket } from '../data/ticketStore.js'; +import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; + +export const widgetDataRouter = Router(); + +/** + * Popisky stavu. `ticketStore` je neexportuje, a duplikovat je sem je mensi + * zlo nez rozsirovat verejne rozhrani uloziste kvuli jednomu widgetu. + */ +const statusLabels: Record = { + new: 'Nový', + open: 'V řešení', + waiting: 'Čeká na klienta', + resolved: 'Vyřešeno', +}; + +const requestSchema = z.object({ + widgetIds: z.array(z.string().min(1)).max(24), +}); + +/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */ +type WidgetValue = + | { kind: 'number'; value: number } + | { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> } + | { kind: 'series'; points: Array<{ date: string; value: number }> } + | { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> } + | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }; + +interface WidgetResult { + id: string; + ok: boolean; + value?: WidgetValue; + error?: string; +} + +/** Od kdy se pocita. `all` znamena bez omezeni. */ +function since(period: WidgetTicketFilter['period']): number | null { + const now = Date.now(); + switch (period) { + case 'today': + return new Date(new Date().toDateString()).getTime(); + case '7d': + return now - 7 * 86_400_000; + case '30d': + return now - 30 * 86_400_000; + case 'month': { + const date = new Date(); + return new Date(date.getFullYear(), date.getMonth(), 1).getTime(); + } + default: + return null; + } +} + +/** + * Filtr nad tickety. + * + * Zamerne se filtruje az nad vysledkem `listTickets`, ktery uz zaridil hranici + * firmy. Skladat vlastni dotaz by znamenalo druhe misto, kde se na filtr firmy + * da zapomenout. + */ +function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string | null): boolean { + if (filter.status && filter.status.length > 0 && !filter.status.includes(ticket.status)) return false; + if (filter.channel && filter.channel.length > 0 && !filter.channel.includes(ticket.channel)) return false; + if (filter.typeIds && filter.typeIds.length > 0) { + if (!ticket.typeId || !filter.typeIds.includes(ticket.typeId)) return false; + } + if (filter.tags && filter.tags.length > 0) { + if (!filter.tags.some((tag) => ticket.tags.includes(tag))) return false; + } + if (filter.groupIds && filter.groupIds.length > 0) { + if (!ticket.assigneeGroupId || !filter.groupIds.includes(ticket.assigneeGroupId)) return false; + } + if (filter.assignee && filter.assignee.length > 0) { + const wanted = filter.assignee.flatMap((value) => { + if (value === 'me') return personId ? [personId] : []; + return [value]; + }); + const unassigned = filter.assignee.includes('unassigned'); + if (ticket.assignee === null) { + if (!unassigned) return false; + } else if (!wanted.includes(ticket.assignee.id)) { + return false; + } + } + + const from = since(filter.period); + if (from !== null && new Date(ticket.createdAt).getTime() < from) return false; + + return true; +} + +/** Popisek skupiny. Bez nej by v tabulce byla jen ID. */ +function groupLabel(key: string, groupBy: WidgetGroupBy): string { + switch (groupBy) { + case 'assignee': + return key === '' ? 'Bez řešitele' : (findPerson(key)?.name ?? key); + case 'group': + return key === '' ? 'Bez skupiny' : (findGroup(key)?.name ?? key); + case 'type': + return key === '' ? 'Bez typu' : (findTicketType(key)?.name ?? key); + case 'status': + return statusLabels[key] ?? key; + case 'channel': + return channelLabels[key as keyof typeof channelLabels] ?? key; + case 'tag': + return key === '' ? 'Bez tagu' : key; + default: + return key; + } +} + +function groupKeys(ticket: Ticket, groupBy: string): string[] { + switch (groupBy) { + case 'assignee': + return [ticket.assignee?.id ?? '']; + case 'group': + return [ticket.assigneeGroupId ?? '']; + case 'type': + return [ticket.typeId ?? '']; + case 'status': + return [ticket.status]; + case 'channel': + return [ticket.channel]; + case 'tag': + // Ticket s vic tagy se pocita do kazdeho. Jinak by soucet nesedel na nic. + return ticket.tags.length > 0 ? ticket.tags : ['']; + default: + return ['']; + } +} + +function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue { + const source = widget.source; + + if (source.kind === 'workload') { + const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds); + return { + kind: 'workload', + rows: workload.rows.map((row) => ({ + name: row.person.name, + open: row.open, + overCapacity: row.overloaded, + })), + }; + } + + const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) => + matches(ticket, source.filter, scope.personId), + ); + + if (source.kind === 'ticketCount') { + if (!source.groupBy) return { kind: 'number', value: tickets.length }; + + const counts = new Map(); + for (const ticket of tickets) { + for (const key of groupKeys(ticket, source.groupBy)) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return { + kind: 'groups', + rows: [...counts.entries()] + .map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value })) + .sort((a, b) => b.value - a.value), + }; + } + + if (source.kind === 'ticketList') { + return { + kind: 'tickets', + items: tickets + .slice(0, source.limit) + .map((ticket) => ({ + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + assignee: ticket.assignee?.name ?? null, + })), + }; + } + + // Casova rada. Prazdne dny se doplnuji, jinak by graf preskakoval. + const days = source.bucket === 'week' ? 12 * 7 : 14; + const points: Array<{ date: string; value: number }> = []; + for (let offset = days - 1; offset >= 0; offset -= 1) { + const day = new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); + points.push({ + date: day, + value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length, + }); + } + return { kind: 'series', points }; +} + +widgetDataRouter.post('/', (req, res) => { + const scope = resolveScope(req.user!, { + scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + if (isDenied(scope)) { + return res.status(scope.status).json({ error: scope.error, message: scope.message }); + } + + const parsed = requestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: 'Pošlete seznam ID widgetů, nejvýš 24.', + }); + } + + const results: WidgetResult[] = parsed.data.widgetIds.map((id) => { + const widget = findCustomWidget(id); + if (!widget || !scope.tenantIds.includes(widget.tenantId)) { + return { id, ok: false, error: 'Widget neexistuje.' }; + } + // Osobni widget vidi jen jeho autor. + if (widget.ownerId !== null && widget.ownerId !== req.user!.id) { + return { id, ok: false, error: 'Widget neexistuje.' }; + } + + try { + return { id, ok: true, value: computeWidget(widget, scope) }; + } catch (err) { + // Jeden rozbity zdroj nesmi zhasnout cely prehled. + console.error(`[widgets] ${id} selhal:`, err); + return { id, ok: false, error: err instanceof Error ? err.message : 'Data se nepodařilo spočítat.' }; + } + }); + + return res.json({ items: results }); +}); + +/** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */ +widgetDataRouter.get('/options', (req, res) => { + const scope = resolveScope(req.user!, { + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + if (isDenied(scope)) { + return res.status(scope.status).json({ error: scope.error, message: scope.message }); + } + + return res.json({ + types: listTicketTypes(scope.tenantIds).map((type) => ({ id: type.id, name: type.name })), + groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })), + statuses: Object.entries(statusLabels).map(([value, label]) => ({ value, label })), + channels: Object.entries(channelLabels).map(([value, label]) => ({ value, label })), + // Tagy se berou z toho, co je na ticketech - vlastni seznam tagu neexistuje. + tags: [...new Set(listTickets({ tenantIds: scope.tenantIds }).flatMap((ticket) => ticket.tags))].sort(), + }); +}); diff --git a/src/scripts/lookup.ts b/src/scripts/lookup.ts new file mode 100644 index 0000000..bd7ac52 --- /dev/null +++ b/src/scripts/lookup.ts @@ -0,0 +1,14 @@ +/** + * Ktery skript obsluhuje danou operaci sluzby. + * + * Zvlastni soubor kvuli cyklickym importum: routy potrebuji tuhle jednu vec, + * ale `registry.ts` je asynchronni a tahne za sebou nacitani souboru. + * Katalog uz odpoved zna, protoze operace ze skriptu nesou `scriptId`. + */ + +import { findOperation } from '../data/services.js'; + +export function scriptIdFor(serviceId: string, operationId: string): string | undefined { + const operation = findOperation(serviceId, operationId, 'action'); + return operation?.implementation === 'script' ? operation.scriptId : undefined; +} diff --git a/src/types.ts b/src/types.ts index 3643715..ffd476d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,12 +6,19 @@ * `platformAdmin` - to je nase pravo, ne zakaznicke. */ -/** Role uvnitr jedne firmy. */ -export type TenantRole = 'admin' | 'agent'; - +/** + * Clenstvi ve firme. + * + * `roleIds` je seznam, ne jedna role. Duvod: `'admin' | 'agent'` na ucetni, + * skladnika a vedouciho nestacilo a pridavat dalsi hodnoty do unionu je slepa + * ulicka, kazdy klient chce jine. Role jsou proto zaznamy, viz `data/permissions.ts`. + * + * Odkaz smi byt ID role, nebo klic systemove role (`admin`, `agent`), aby slo + * zapsat oboji a stara data dal fungovala. + */ export interface Membership { tenantId: string; - role: TenantRole; + roleIds: string[]; } export interface User { @@ -37,6 +44,14 @@ export interface PublicUser { export interface JwtPayload { sub: string; email: string; + /** + * Jen u tokenu impersonace: kdo se za uzivatele vydava. + * Bez toho by v auditu zustalo jen "uzivatel to udelal sam". + */ + act?: string; + actEmail?: string; + /** Jen u impersonace: smi i zapisovat. */ + writes?: boolean; } export function toPublicUser(user: User): PublicUser { @@ -44,7 +59,7 @@ export function toPublicUser(user: User): PublicUser { return rest; } -/** Role uzivatele v dane firme, nebo undefined kdyz do ni nepatri. */ -export function roleIn(user: User, tenantId: string): TenantRole | undefined { - return user.memberships.find((m) => m.tenantId === tenantId)?.role; +/** Role uzivatele v dane firme. Prazdne pole = do firmy nepatri. */ +export function rolesIn(user: User, tenantId: string): string[] { + return user.memberships.find((m) => m.tenantId === tenantId)?.roleIds ?? []; }