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:
JiriUhlir
2026-08-13 07:06:38 +02:00
co-authored by Claude Opus 5
parent 6e3d0640ff
commit e7cf499a0b
17 changed files with 2307 additions and 156 deletions
+37 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
+33
View File
@@ -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
View File
@@ -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));
+29
View File
@@ -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);
+145
View File
@@ -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.' });
});
});
+210
View File
@@ -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
View File
@@ -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,
+658
View File
@@ -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) });
});
+415
View File
@@ -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);
},
});
+276
View File
@@ -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(),
});
});
+14
View File
@@ -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
View File
@@ -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 ?? [];
}