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:<id>, 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6e3d0640ff
commit
e7cf499a0b
+37
-19
@@ -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)) {
|
||||
|
||||
+96
-67
@@ -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>('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<void> {
|
||||
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>('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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
+48
-4
@@ -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) =>
|
||||
|
||||
+40
-12
@@ -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>('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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
+114
-4
@@ -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<string, string | number | boolean | null>;
|
||||
/**
|
||||
* 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<Ticket, 'assignee'> {
|
||||
/**
|
||||
* 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<Ticket, 'assignee' | 'typeId' | 'fields' | 'tags' | 'assigneeGroupId'> {
|
||||
assigneeId: string | null;
|
||||
assigneeGroupId?: string | null;
|
||||
typeId?: string | null;
|
||||
fields?: Record<string, string | number | boolean | null>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const channelLabels: Record<TicketChannel, string> = {
|
||||
@@ -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<string, string | number | boolean | null>;
|
||||
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<string, string | number | boolean | null> | 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,
|
||||
|
||||
+112
-38
@@ -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<StoredUser>('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<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+11
-1
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
@@ -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<T extends TenantEntity, C, U> {
|
||||
store: EntityStore<T>;
|
||||
/** 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<C>` by pak
|
||||
* na kazde schema s vychozi hodnotou nesedelo.
|
||||
*/
|
||||
createSchema: z.ZodType<C, z.ZodTypeDef, unknown>;
|
||||
updateSchema: z.ZodType<U, z.ZodTypeDef, unknown>;
|
||||
/** Pravo potrebne k zapisu. Cteni staci prihlaseni. */
|
||||
writePermission: string;
|
||||
/** Sestavi novy zaznam z overeneho vstupu. */
|
||||
build: (input: C, tenantId: string) => Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
|
||||
/** 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<T extends TenantEntity, C, U>(options: CrudOptions<T, C, U>): 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<T>) } 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<T>,
|
||||
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;
|
||||
}
|
||||
+47
-4
@@ -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,
|
||||
|
||||
@@ -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<Tenant, z.infer<typeof tenantCreate>, Partial<Tenant>>({
|
||||
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<StoredUser, z.infer<typeof userCreate>, Partial<StoredUser>>({
|
||||
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<StoredUser>,
|
||||
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<Role, z.infer<typeof roleCreate>, Partial<Role>>({
|
||||
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<Person, z.infer<typeof personCreate>, Partial<Person>>({
|
||||
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<typeof groupCreate>) => ({
|
||||
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<TenantFeatures> = {
|
||||
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<typeof fieldSchema>[]): 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<TicketType, z.infer<typeof typeCreate>, Partial<TicketType>>({
|
||||
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<TicketType> => ({
|
||||
...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<string>();
|
||||
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<TicketAction['body'], z.ZodTypeDef, unknown> = 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<TicketAction, z.infer<typeof actionCreate>, Partial<TicketAction>>({
|
||||
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<CustomWidget, z.infer<typeof widgetCreate>, Partial<CustomWidget>>({
|
||||
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) });
|
||||
});
|
||||
@@ -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<string, string>,
|
||||
facts: TicketFacts,
|
||||
form: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>,
|
||||
ticket: Ticket,
|
||||
actorEmail: string,
|
||||
): Promise<ActionResult> {
|
||||
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<T>(options: {
|
||||
path: string;
|
||||
permission: string;
|
||||
auditAction: string;
|
||||
schema: z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
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<string, unknown>,
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string, number>();
|
||||
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(),
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+22
-7
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user