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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 07:46:22 +02:00
co-authored by Claude Opus 5
parent e7cf499a0b
commit afbe948da3
37 changed files with 5594 additions and 106 deletions
+114
View File
@@ -0,0 +1,114 @@
/**
* Audit: kdo co udelal.
*
* Zapisuji se i **odepreni**. Bez nich by opakovane pokusy o cizi firmu nikde
* nezustaly a poznalo by se to jen tak, ze si toho nekdo vsimne v konzoli.
*
* Bez auditu nema smysl impersonace: kdyz se spravce platformy podiva za
* uzivatele a neco zmeni, musi zustat zaznam, ze to byl on.
*
* Zapis je **pripisovaci**. Nic se neaktualizuje ani nemaze, jen se po case
* odmazavaji nejstarsi zaznamy, aby to nerostlo bez konce.
*/
import { randomUUID } from 'node:crypto';
import { defineStore, nowIso, type ListOptions, type TenantEntity } from './store/index.js';
export interface AuditEntry extends TenantEntity {
/** Kdo to udelal. */
userId: string;
userEmail: string;
/** Vyplnene pri impersonaci: kdo se za nej vydaval. */
actedBy: string | null;
/** Co se stalo, napr. `connector.update`, `impersonate.start`. */
action: string;
/** Nad cim, napr. ID konektoru. */
target: string | null;
detail: Record<string, unknown>;
result: 'ok' | 'denied';
}
export const auditStore = defineStore<AuditEntry>('audit');
/** Kolik zaznamu se drzi. Starsi se pri zapisu odmazou. */
const MAX_ENTRIES = 5_000;
export interface AuditInput {
userId: string;
userEmail: string;
actedBy?: string | null;
tenantId: string | null;
action: string;
target?: string | null;
detail?: Record<string, unknown>;
result?: 'ok' | 'denied';
}
/**
* Zapise zaznam.
*
* Zamerne **nevraci chybu a necekana se**: kdyby audit shodil request, znamenalo
* by to, ze rozbity audit rozbije celou aplikaci. Chyba se loguje.
*/
export function recordAudit(input: AuditInput): void {
const timestamp = nowIso();
const entry: AuditEntry = {
id: `aud_${randomUUID().slice(0, 12)}`,
tenantId: input.tenantId,
userId: input.userId,
userEmail: input.userEmail,
actedBy: input.actedBy ?? null,
action: input.action,
target: input.target ?? null,
detail: input.detail ?? {},
result: input.result ?? 'ok',
createdAt: timestamp,
updatedAt: timestamp,
};
void auditStore
.create(entry)
.then(() => trim())
.catch((err: unknown) => {
console.error('[audit] zaznam se nepodarilo ulozit:', err);
});
}
let trimming = false;
/** Odmaze nejstarsi zaznamy nad limit. Bezi nejvyse jednou naraz. */
async function trim(): Promise<void> {
if (trimming) return;
trimming = true;
try {
const all = await auditStore.listAll();
if (all.length <= MAX_ENTRIES) return;
const sorted = all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const excess = sorted.slice(0, all.length - MAX_ENTRIES);
for (const entry of excess) {
await auditStore.remove(entry.id, { tenantIds: [], includeGlobal: true });
}
console.info(`[audit] odmazano ${excess.length} nejstarsich zaznamu`);
} catch (err) {
console.error('[audit] uklid selhal:', err);
} finally {
trimming = false;
}
}
export interface AuditFilter extends ListOptions {
action?: string;
result?: 'ok' | 'denied';
limit?: number;
}
/** Nejnovejsi nahore. Audit se cte odzadu. */
export async function listAudit(filter: AuditFilter): Promise<AuditEntry[]> {
const all = await auditStore.list(filter);
return all
.filter((entry) => !filter.action || entry.action.startsWith(filter.action))
.filter((entry) => !filter.result || entry.result === filter.result)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, filter.limit ?? 200);
}
+40 -4
View File
@@ -8,6 +8,8 @@
import { randomBytes } from 'node:crypto';
import { publish } from '../events/bus.js';
import { defineStore } from './store/index.js';
import { withMirror } from './store/mirror.js';
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
import { actionInputsFor, findService } from './services.js';
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
@@ -292,6 +294,39 @@ function deriveKind(flow: AutomationFlow): AutomationKind {
const store = new Map<string, StoredAutomation>();
let idCounter = 0;
/**
* Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook
* hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise.
* Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`.
*/
const mirror = withMirror(defineStore<StoredAutomation>('automation'));
/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */
function save(automation: StoredAutomation): void {
store.set(automation.id, automation);
mirror.save(automation);
}
/**
* Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
* Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili
* v pameti.
*/
export async function initAutomations(): Promise<void> {
const rows = await mirror.load(() => [...store.values()]);
store.clear();
for (const row of rows) store.set(row.id, row);
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nova
// automatizace prepsala starou.
for (const id of store.keys()) {
const number = Number(/^AUT-(\d+)$/.exec(id)?.[1] ?? 0);
if (number > idCounter) idCounter = number;
}
}
function nextId(): string {
idCounter += 1;
return `AUT-${String(idCounter).padStart(2, '0')}`;
@@ -813,7 +848,7 @@ export function createAutomation(name: string, tenantId: string): AutomationDeta
createdAt: now,
updatedAt: now,
};
store.set(id, stored);
save(stored);
console.info(`[automations] vytvorena automatizace ${id} "${name}"`);
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id });
return toDetail(stored);
@@ -859,7 +894,7 @@ export function updateAutomation(
updated.enabled = false;
}
store.set(id, updated);
save(updated);
console.info(
`[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
);
@@ -926,7 +961,7 @@ export function regenerateWebhookToken(
},
updatedAt: new Date().toISOString(),
};
store.set(id, updated);
save(updated);
console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`);
return toDetail(updated);
}
@@ -959,7 +994,7 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati
successRate: Math.round(successRate * 10) / 10,
lastRunAt: new Date().toISOString(),
};
store.set(id, updated);
save(updated);
publish(
'automation.run',
@@ -975,6 +1010,7 @@ export function deleteAutomation(id: string, tenantIds: string[]): boolean {
const stored = findWritable(id, tenantIds);
const name = stored?.name;
const existed = stored !== undefined && store.delete(id);
if (existed) mirror.drop(id);
if (!existed) {
console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`);
} else {
+132
View File
@@ -0,0 +1,132 @@
/**
* Nacteni vsech ulozist pri startu.
*
* Jedno misto, kde se rekne "tohle jsou entity a tohle jejich vychozi sada".
* Kdyby to bylo rozeseto po modulech, jeden by se pri pridani zapomnel a chybel
* by az v provozu.
*
* Poradi zalezi: role a firmy musi byt driv nez prava, prava driv nez cokoliv,
* co se na ne pta.
*/
import { refreshActions, actionPermissions, actionStore, seedActions } from './ticketActions.js';
import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { auditStore } from './audit.js';
import {
groupStore,
personStore,
refreshGroups,
refreshPeople,
seedGroups,
seedPeople,
} from './people.js';
import {
refreshRoles,
roleStore,
setDynamicPermissions,
systemRoles,
invalidatePermissions,
} from './permissions.js';
import { initStores, type EntityStore, type TenantEntity } from './store/index.js';
import { featuresStore, refreshFeatures, seedFeatures } from './tenantFeatures.js';
import { refreshTenants, seedTenants, tenantStore } from './tenants.js';
import { refreshTicketTypes, seedTicketTypes, ticketTypeStore } from './ticketTypes.js';
import { refreshUsers, seedUsers, userStore } from './users.js';
import { initTickets } from './ticketStore.js';
import { initAutomations } from './automationStore.js';
import { initLayouts } from './dashboardLayouts.js';
import { initIncidents } from './incidentStore.js';
/**
* Seznam entit. Pridani nove znamena jeden radek tady, ne hledani po kodu.
* `seed` se pouzije jen kdyz uloziste pro dany druh nic nema.
*/
const entities: Array<{
store: EntityStore<TenantEntity>;
seed?: () => TenantEntity[];
}> = [
{ store: tenantStore as EntityStore<TenantEntity>, seed: seedTenants },
{ store: userStore as EntityStore<TenantEntity>, seed: seedUsers },
{ store: roleStore as EntityStore<TenantEntity>, seed: systemRoles },
{ store: personStore as EntityStore<TenantEntity>, seed: seedPeople },
{ store: groupStore as EntityStore<TenantEntity>, seed: seedGroups },
{ store: featuresStore as EntityStore<TenantEntity>, seed: seedFeatures },
{ store: ticketTypeStore as EntityStore<TenantEntity>, seed: seedTicketTypes },
{ store: actionStore as EntityStore<TenantEntity>, seed: seedActions },
{ store: customWidgetStore as EntityStore<TenantEntity>, seed: seedCustomWidgets },
// Audit vychozi sadu nema, zaznamy vznikaji az provozem.
{ store: auditStore as EntityStore<TenantEntity> },
];
/**
* Provozni data, ktera se v pameti meni na miste a ukladaji cela (`withMirror`).
*
* Jsou zvlast od seznamu vys, protoze si init resi sami: nactou uloziste,
* srovnaji podle nej kopii v pameti a dopocitaji citace ID.
*/
const runtimeData: Array<{ name: string; init: () => Promise<void> }> = [
{ name: 'tickety', init: initTickets },
{ name: 'automatizace', init: initAutomations },
{ name: 'incidenty', init: initIncidents },
{ name: 'rozlozeni dashboardu', init: initLayouts },
];
/**
* Obnovi kopie v pameti u entit, ktere se ctou pri kazdem requestu.
* Vola se pri startu a po kazdem zapisu, ktery je muze zmenit.
*/
export async function refreshCaches(): Promise<void> {
await Promise.all([
refreshTenants(),
refreshUsers(),
refreshRoles(),
refreshPeople(),
refreshGroups(),
refreshFeatures(),
refreshTicketTypes(),
refreshActions(),
refreshCustomWidgets(),
]);
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
setDynamicPermissions(actionPermissions());
invalidatePermissions();
}
export async function bootstrapData(options: { databaseReady: boolean }): Promise<string> {
const mode = initStores(options);
for (const entity of entities) {
try {
await entity.store.init(entity.seed);
} catch (err) {
// Jedna rozbita entita nesmi shodit start. Portal pak ukaze prazdny seznam,
// coz je lepsi nez nefunkcni sluzba pro AppFactory.
console.error(
`[data] uloziste ${entity.store.kind} se nepodarilo nacist:`,
err instanceof Error ? err.message : err,
);
}
}
await refreshCaches();
console.info(`[data] nactena uloziste (${mode}): ${entities.map((e) => e.store.kind).join(', ')}`);
/*
* Tickety az po resitelich a typech: `toTicket` dohledava resitele podle ID
* a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje.
*/
for (const item of runtimeData) {
try {
await item.init();
} catch (err) {
console.error(
`[data] ${item.name} se nepodarilo nacist:`,
err instanceof Error ? err.message : err,
);
}
}
console.info(`[data] nactena provozni data: ${runtimeData.map((i) => i.name).join(', ')}`);
return mode;
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Konektory mimo databazi.
*
* Jedna implementace pro dva rezimy. Lisi se **jen tim, kam se to uklada**:
*
* - `memorySnapshot` nikam, tedy cista pamet procesu,
* - `fileSnapshot` do JSON souboru, coz prezije restart procesu i containeru.
*
* Kdyby to byly dve implementace, jedna by se casem opravila a druha ne.
* Persistence je proto parametr, ne kopie kodu.
*
* Tajne hodnoty se do souboru **zapisuji zasifrovane**. Plaintext v souboru je
* stejny problem jako plaintext v tabulce: kdo se dostane k disku nebo k zaloze,
* ma klientske klice k iDokladu.
*/
import { randomUUID } from 'node:crypto';
import { openAll, sealAll } from '../../db/secretBox.js';
import { findService } from '../services.js';
import type { SnapshotStore } from '../snapshot.js';
import {
nowIso,
type Connector,
type ConnectorRepository,
type CreateConnectorInput,
type UpdateConnectorInput,
} from './types.js';
/**
* Tvar zaznamu v souboru. Proti `Connector` se lisi jen tim, ze `secrets`
* je zasifrovana obalka misto ctecich hodnot.
*/
interface StoredConnector extends Omit<Connector, 'values'> {
secrets: Record<string, unknown>;
}
export interface LocalConnectorsOptions {
store: SnapshotStore<StoredConnector>;
/** false = tajne hodnoty se do souboru nezapisuji, protoze neni cim sifrovat. */
canEncrypt: boolean;
}
export function createLocalConnectors(options: LocalConnectorsOptions): ConnectorRepository & {
init(): Promise<void>;
flush(): Promise<void>;
} {
const { store, canEncrypt } = options;
const rows: Connector[] = [];
/** Ulozi aktualni stav. V pametovem rezimu to nic nedela. */
function persist(): void {
if (store.kind === 'memory') return;
store.save(
rows.map((row) => {
const { values, ...rest } = row;
// Bez klice se hodnoty nezapisuji vubec. Radsi je zadat znovu nez je
// mit v souboru citelne.
return { ...rest, secrets: canEncrypt ? sealAll(values) : {} };
}),
);
}
function byTenant(tenantIds: string[]): Connector[] {
return rows.filter((row) => tenantIds.includes(row.tenantId));
}
function clearDefaults(tenantId: string, serviceId: string): void {
for (const row of rows) {
if (row.tenantId === tenantId && row.serviceId === serviceId) row.isDefault = false;
}
}
/** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */
function copy(row: Connector): Connector {
return { ...row, values: { ...row.values } };
}
return {
kind: store.kind === 'file' ? 'memory' : 'memory',
async init() {
const stored = await store.load();
rows.length = 0;
for (const item of stored) {
const { secrets, ...rest } = item;
rows.push({ ...rest, values: openAll(secrets) });
}
await seed();
},
async flush() {
await store.flush();
},
async list(tenantIds, listOptions = {}) {
return byTenant(tenantIds)
.filter((row) => !listOptions.serviceId || row.serviceId === listOptions.serviceId)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'))
.map(copy);
},
async get(id, tenantIds) {
const row = rows.find((item) => item.id === id);
// Cizi konektor se chova jako neexistujici, ne jako chyba prava.
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
return copy(row);
},
async defaultFor(tenantId, serviceId) {
const forService = rows.filter(
(row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled,
);
const chosen = forService.find((row) => row.isDefault) ?? forService[0];
return chosen ? copy(chosen) : undefined;
},
async countsByService(tenantIds) {
const counts = new Map<string, number>();
for (const row of byTenant(tenantIds)) {
counts.set(row.serviceId, (counts.get(row.serviceId) ?? 0) + 1);
}
return counts;
},
async create(input: CreateConnectorInput) {
const timestamp = nowIso();
const existing = rows.filter(
(row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId,
);
const connector: Connector = {
id: `con_${randomUUID().slice(0, 8)}`,
tenantId: input.tenantId,
serviceId: input.serviceId,
name: input.name,
baseUrl: input.baseUrl ?? null,
values: { ...(input.values ?? {}) },
enabled: true,
status: 'untested',
lastCheckAt: null,
lastError: null,
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
isDefault: input.isDefault ?? existing.length === 0,
createdAt: timestamp,
updatedAt: timestamp,
};
if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId);
rows.push(connector);
persist();
return copy(connector);
},
async update(id, patch: UpdateConnectorInput, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
if (patch.name !== undefined) row.name = patch.name;
if (patch.baseUrl !== undefined) row.baseUrl = patch.baseUrl;
if (patch.enabled !== undefined) row.enabled = patch.enabled;
if (patch.values) {
for (const [key, value] of Object.entries(patch.values)) {
if (value === '') delete row.values[key];
else row.values[key] = value;
}
// Zmena udaju znamena, ze predchozi overeni uz nic nerika.
row.status = 'untested';
row.lastError = null;
}
if (patch.isDefault === true) {
clearDefaults(row.tenantId, row.serviceId);
row.isDefault = true;
}
row.updatedAt = nowIso();
persist();
return copy(row);
},
async remove(id, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return false;
rows.splice(rows.indexOf(row), 1);
// Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu
// prestaly fungovat, aniz by se cokoliv jineho zmenilo.
if (row.isDefault) {
const next = rows.find(
(item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId,
);
if (next) next.isDefault = true;
}
persist();
return true;
},
async setStatus(id, status, error, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
row.status = status;
row.lastError = error;
row.lastCheckAt = nowIso();
row.updatedAt = row.lastCheckAt;
persist();
return copy(row);
},
};
/**
* Ukazkovy konektor bez vyplnenych udaju.
*
* Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy
* "sluzba je napojena, ale chybi pristupove udaje". Zaklada se jen kdyz nic
* jineho neni, aby po restartu neprepsal to, co uzivatel nastavil.
*/
async function seed(): Promise<void> {
if (rows.length > 0) return;
if (!findService('idoklad')) return;
const timestamp = nowIso();
rows.push({
id: `con_${randomUUID().slice(0, 8)}`,
tenantId: 'tnt_automia',
serviceId: 'idoklad',
name: 'iDoklad Automia',
baseUrl: null,
values: {},
enabled: true,
status: 'untested',
lastCheckAt: null,
lastError: null,
isDefault: true,
createdAt: timestamp,
updatedAt: timestamp,
});
persist();
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Vlastni widgety na prehledu.
*
* Puvodni katalog (`widgets.ts`) byl pevny: `kind` rikal, kterou komponentu
* vykreslit, a nic dalsiho se nastavit nedalo. Vlastni widget to rozdeluje
* na dve veci:
*
* - `render` jak se to kresli (cislo, graf, seznam, tabulka, ukazatel),
* - `source` odkud jsou data.
*
* **Uzivatel nepise dotazy.** Vybira z filtru, ktery uz umi seznam ticketu.
* Tim je "widget nad stavy ticketu" hotovy bez jedineho noveho dotazu do dat
* a bez moznosti napsat neco, co server polozi.
*
* Popis je v documentation/09-navrh-rozsireni.md, bod 4.
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import type { TicketChannel, TicketStatus } from './ticketStore.js';
export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
/** Podle ceho se da seskupovat. Bez toho by byl widget na kazdeho cloveka. */
export type WidgetGroupBy = 'assignee' | 'group' | 'status' | 'type' | 'tag' | 'channel';
export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all';
/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */
export interface WidgetTicketFilter {
status?: TicketStatus[];
channel?: TicketChannel[];
typeIds?: string[];
tags?: string[];
/** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */
assignee?: string[];
groupIds?: string[];
period?: WidgetPeriod;
}
export type WidgetSource =
| { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy }
| { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number }
| { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' }
| { kind: 'workload'; groupIds?: string[] };
export type WidgetSize = 'third' | 'half' | 'full';
export interface CustomWidget extends TenantEntity {
tenantId: string;
/** null = firemni widget, jinak osobni. */
ownerId: string | null;
name: string;
description: string;
render: WidgetRender;
source: WidgetSource;
/** Porovnavaci hodnota u ukazatele. */
target?: number;
size: WidgetSize;
}
export const customWidgetStore = defineStore<CustomWidget>('customWidget');
const cache = withCache(customWidgetStore);
export function seedCustomWidgets(): CustomWidget[] {
const timestamp = nowIso();
const base = { tenantId: 'tnt_automia', ownerId: null, createdAt: timestamp, updatedAt: timestamp };
return [
{
...base,
id: 'cw_orders_open',
name: 'Otevřené objednávky',
description: 'Tickety typu Objednávka, které nejsou vyřešené.',
render: 'stat',
source: {
kind: 'ticketCount',
filter: { typeIds: ['tt_order'], status: ['new', 'open', 'waiting'] },
},
size: 'third',
},
{
...base,
id: 'cw_by_assignee',
name: 'Nevyřešené podle lidí',
description: 'Kdo co má u sebe. Ukázka seskupení.',
render: 'table',
source: {
kind: 'ticketCount',
filter: { status: ['new', 'open', 'waiting'] },
groupBy: 'assignee',
},
size: 'half',
},
];
}
export async function refreshCustomWidgets(): Promise<void> {
await cache.refresh();
}
/** Widgety firmy: firemni plus osobni prihlaseneho. */
export function listCustomWidgets(tenantIds: string[], userId: string): CustomWidget[] {
return cache
.all()
.filter((widget) => tenantIds.includes(widget.tenantId))
.filter((widget) => widget.ownerId === null || widget.ownerId === userId)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
export function findCustomWidget(id: string): CustomWidget | undefined {
return cache.byId(id);
}
/** Ktere sirky ma smysl nabizet. Graf v tretine sloupce se necte. */
export function sizesFor(render: WidgetRender): WidgetSize[] {
if (render === 'stat' || render === 'gauge') return ['third', 'half'];
return ['half', 'full'];
}
/**
* Overi definici widgetu.
* Vraci popisy problemu, prazdne pole znamena v poradku.
*/
export function validateWidget(widget: CustomWidget): string[] {
const problems: string[] = [];
if (!sizesFor(widget.render).includes(widget.size)) {
problems.push(
`Šířka ${widget.size} nemá u ${widget.render} smysl, vyberte ${sizesFor(widget.render).join(' nebo ')}.`,
);
}
if (widget.source.kind === 'ticketList' && widget.source.limit > 50) {
problems.push('Seznam smí mít nejvýš 50 řádků, jinak se dlaždice nedá přečíst.');
}
// Ukazatel bez cile nema co ukazat.
if (widget.render === 'gauge' && (widget.target === undefined || widget.target <= 0)) {
problems.push('Ukazatel potřebuje cílovou hodnotu větší než nula.');
}
// Cislo ze seznamu nebo casove rady nejde vykreslit jako jedna hodnota.
if (widget.render === 'stat' && widget.source.kind !== 'ticketCount') {
problems.push('Jedno číslo umí jen zdroj Počet ticketů.');
}
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') {
problems.push('Graf umí jen zdroj Časová řada.');
}
return problems;
}
+31 -3
View File
@@ -3,10 +3,13 @@
* clovek ve dvou firmach chce v kazde videt neco jineho a smichat mu to
* dohromady by bylo horsi nez zadne nastaveni.
*
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi rozlozeni.
* Uklada se do uloziste (databaze nebo JSON soubor), takze upraveny dashboard
* prezije restart. Vychozi sada se neuklada - kdo si nic neupravil, nema zaznam.
*/
import { findWidget, type WidgetSize } from './widgets.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withMirror } from './store/mirror.js';
export interface LayoutItem {
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
@@ -30,10 +33,28 @@ const defaultLayout: LayoutItem[] = [
const layouts = new Map<string, LayoutItem[]>();
/**
* Zaznam v ulozisti. ID je dvojice uzivatel a firma, takze zapis nepotrebuje
* hledat, co uz tam je - prepise se podle klice.
*/
interface StoredLayout extends TenantEntity {
userId: string;
items: LayoutItem[];
}
const mirror = withMirror(defineStore<StoredLayout>('dashboardLayout'));
function key(userId: string, tenantId: string): string {
return `${userId}:${tenantId}`;
}
/** Nacte ulozena rozlozeni. Vola se pri startu, viz data/bootstrap.ts. */
export async function initLayouts(): Promise<void> {
const rows = await mirror.load();
layouts.clear();
for (const row of rows) layouts.set(key(row.userId, row.tenantId ?? ''), row.items);
}
export function getLayout(userId: string, tenantId: string): LayoutItem[] {
return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item }));
}
@@ -44,14 +65,21 @@ export function hasCustomLayout(userId: string, tenantId: string): boolean {
}
export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] {
layouts.set(key(userId, tenantId), items);
const id = key(userId, tenantId);
layouts.set(id, items);
const timestamp = nowIso();
mirror.save({ id, tenantId, userId, items, createdAt: timestamp, updatedAt: timestamp });
console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`);
return items;
}
/** Vrati dashboard do vychoziho stavu. */
export function resetLayout(userId: string, tenantId: string): LayoutItem[] {
layouts.delete(key(userId, tenantId));
const id = key(userId, tenantId);
layouts.delete(id);
// Vychozi rozlozeni se neuklada, takze vraceni znamena smazat zaznam.
mirror.drop(id);
console.info(`[layout] ${userId}@${tenantId}: vraceno na vychozi`);
return getLayout(userId, tenantId);
}
+47 -1
View File
@@ -1,9 +1,15 @@
/**
* Uloziste incidentu. Stejny princip jako ticketStore - kazda zmena
* posle udalost na sbernici a projevi se v dashboardu okamzite.
* posle udalost na sbernici a projevi se v dashboardu okamzite, drzi se
* v pameti a po zmene se cely zaznam zapise do uloziste.
*
* Incident je **platformni**, ne firemni: vypadek hlasove brany se tyka vsech,
* kdo ji pouzivaji. Proto `tenantId: null`.
*/
import { publish } from '../events/bus.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withMirror } from './store/mirror.js';
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
@@ -54,6 +60,44 @@ const incidents: Incident[] = [
let counter = 231;
/** Tvar v ulozisti. Cas vzniku je `startedAt`, `createdAt` je kvuli rozhrani. */
interface StoredIncident extends Incident, TenantEntity {
tenantId: null;
}
const mirror = withMirror(defineStore<StoredIncident>('incident'));
function toStored(incident: Incident): StoredIncident {
return {
...incident,
tenantId: null,
createdAt: incident.startedAt,
updatedAt: nowIso(),
};
}
/** Ulozi incident. Necekana se, chyba se loguje. */
function persist(incident: Incident): void {
mirror.save(toStored(incident));
}
/** Nacte incidenty z uloziste. Vola se pri startu, viz data/bootstrap.ts. */
export async function initIncidents(): Promise<void> {
const rows = await mirror.load(() => incidents.map(toStored));
incidents.length = 0;
for (const row of rows) {
// Sloupce uloziste zpatky nepatri, incident je nezna.
const { tenantId: _tenantId, createdAt: _createdAt, updatedAt: _updatedAt, ...incident } = row;
incidents.push(incident);
}
for (const incident of incidents) {
const number = Number(/^INC-(\d+)$/.exec(incident.id)?.[1] ?? 0);
if (number > counter) counter = number;
}
}
export function listIncidents(): Incident[] {
return [...incidents].sort((a, b) => {
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
@@ -78,6 +122,7 @@ export function createIncident(input: {
resolvedAt: null,
};
incidents.unshift(incident);
persist(incident);
publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, {
incidentId: incident.id,
@@ -95,6 +140,7 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide
incident.status = status;
incident.resolvedAt = status === 'resolved' ? new Date().toISOString() : null;
persist(incident);
if (status === 'resolved') {
publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, {
+272
View File
@@ -0,0 +1,272 @@
/**
* Prava a role jako data.
*
* `TenantRole = '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 a pravo je retezec.
*
* Katalog prav je zdroj pravdy o tom, co vubec existuje. Stejny princip jako
* u katalogu sluzeb a widgetu: nastaveni role je zaskrtavatkova tabulka nad
* timhle seznamem, ne volne psane retezce.
*
* Popis modelu je v documentation/16-prava-a-role.md.
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import type { User } from '../types.js';
export type PermissionKey = string;
export interface PermissionDefinition {
key: PermissionKey;
label: string;
/** Do ktere skupiny patri v nastaveni role. */
group: string;
/** true = pravo je nase, klientsky admin ho neprideluje. */
platform?: boolean;
hint?: string;
}
/**
* Katalog prav.
*
* Pravo k vlastni akci se sem nepise - vznika spolu s akci jako `action:<id>`
* a nabizi se dynamicky, viz `dynamicPermissions` nize.
*/
export const permissionCatalog: PermissionDefinition[] = [
// ------------------------------------------------------------------ tickety
{ key: 'ticket.view', label: 'Vidět tickety', group: 'Tickety' },
{ key: 'ticket.comment', label: 'Komentovat ticket', group: 'Tickety' },
{ key: 'ticket.assign.self', label: 'Vzít ticket na sebe', group: 'Tickety' },
{
key: 'ticket.assign.others',
label: 'Přehodit ticket na kolegu',
group: 'Tickety',
hint: 'Bez tohoto práva si člověk může ticket jen vzít, ne ho poslat dál.',
},
{ key: 'ticket.assign.group', label: 'Přehodit ticket na skupinu', group: 'Tickety' },
{ key: 'ticket.status.change', label: 'Měnit stav ticketu', group: 'Tickety' },
{ key: 'ticket.priority.change', label: 'Měnit prioritu', group: 'Tickety' },
{ key: 'ticket.type.change', label: 'Měnit typ ticketu', group: 'Tickety' },
{ key: 'ticket.reopen', label: 'Otevřít vyřešený ticket', group: 'Tickety' },
{ key: 'ticket.tag', label: 'Přidávat a odebírat tagy', group: 'Tickety' },
// ------------------------------------------------------------- nastaveni firmy
{ key: 'ticketType.manage', label: 'Spravovat typy ticketů', group: 'Nastavení firmy' },
{ key: 'action.manage', label: 'Spravovat definice akcí', group: 'Nastavení firmy' },
{ key: 'automation.edit', label: 'Upravovat automatizace', group: 'Nastavení firmy' },
{ key: 'connector.manage', label: 'Spravovat konektory', group: 'Nastavení firmy' },
{ key: 'widget.manage', label: 'Spravovat vlastní widgety', group: 'Nastavení firmy' },
{ key: 'group.manage', label: 'Spravovat skupiny řešitelů', group: 'Nastavení firmy' },
// -------------------------------------------------------------------- lide
{ key: 'people.manage', label: 'Spravovat řešitele', group: 'Lidé' },
{ key: 'user.manage', label: 'Spravovat uživatele a členství', group: 'Lidé' },
{
key: 'role.manage',
label: 'Spravovat role a práva',
group: 'Lidé',
hint: 'Kdo to má, může si přidat jakékoliv další právo.',
},
// ------------------------------------------------------------------ platforma
{
key: 'tenant.manage',
label: 'Spravovat firmy',
group: 'Platforma',
platform: true,
},
{
key: 'tenant.features',
label: 'Nastavovat záložky a limity firem',
group: 'Platforma',
platform: true,
},
{
key: 'script.edit',
label: 'Upravovat skripty služeb',
group: 'Platforma',
platform: true,
hint: 'Úprava skriptu mění chování všeho, co ho používá.',
},
{
key: 'service.visibility',
label: 'Zpřístupňovat služby firmám',
group: 'Platforma',
platform: true,
},
{
key: 'impersonate',
label: 'Přihlásit se za jiného uživatele',
group: 'Platforma',
platform: true,
},
{ key: 'audit.view', label: 'Vidět audit', group: 'Platforma', platform: true },
];
const catalogKeys = new Set(permissionCatalog.map((item) => item.key));
// ------------------------------------------------------------------------ role
export interface Role extends TenantEntity {
/** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */
key: string;
name: string;
description: string;
permissions: PermissionKey[];
/** true = systemova role, nejde smazat ani prejmenovat. */
system: boolean;
}
export const roleStore = defineStore<Role>('role');
/** Vsechna prava mimo platformni. Pro systemovou roli spravce firmy. */
function tenantPermissions(): PermissionKey[] {
return permissionCatalog.filter((item) => !item.platform).map((item) => item.key);
}
/**
* Systemove role.
*
* `admin` a `agent` zustavaji, aby se zadny existujici ucet nemusel predelavat.
* Jsou platformni (`tenantId: null`), takze je vidi kazda firma a nikdo krome
* nas je nezmeni.
*/
export function systemRoles(): Role[] {
const timestamp = nowIso();
const base = { tenantId: null, system: true, createdAt: timestamp, updatedAt: timestamp };
return [
{
...base,
id: 'role_admin',
key: 'admin',
name: 'Správce firmy',
description: 'Vidí a nastavuje všechno ve své firmě.',
permissions: tenantPermissions(),
},
{
...base,
id: 'role_agent',
key: 'agent',
name: 'Řešitel',
description: 'Řeší tickety. Nemůže přehazovat práci ani měnit nastavení.',
permissions: [
'ticket.view',
'ticket.comment',
'ticket.assign.self',
'ticket.status.change',
'ticket.tag',
],
},
{
...base,
id: 'role_accountant',
key: 'accountant',
name: 'Účetní',
description: 'Tickety kolem dokladů a akce nad nimi. Ukázka vlastní role.',
permissions: ['ticket.view', 'ticket.comment', 'ticket.assign.self', 'ticket.status.change'],
},
];
}
// -------------------------------------------------------------- vyhodnocovani
/** Prava, ktera nejsou v katalogu, ale vznikaji za behu (akce). */
let dynamicPermissions: PermissionDefinition[] = [];
/**
* Zaregistruje prava vznikla z definic akci.
* Vola se pri zmene akci, aby je nastaveni role nabidlo.
*/
export function setDynamicPermissions(items: PermissionDefinition[]): void {
dynamicPermissions = items;
}
export function allPermissions(): PermissionDefinition[] {
return [...permissionCatalog, ...dynamicPermissions];
}
/** Neznamy klic se ignoruje a loguje. Pravo, ktere zmizelo, nesmi shodit prihlaseni. */
export function knownPermission(key: PermissionKey): boolean {
return catalogKeys.has(key) || dynamicPermissions.some((item) => item.key === key);
}
/**
* Cache efektivnich prav za uzivatele.
*
* Prava se ctou pri kazdem requestu, ale meni se zridka. Bez cache by kazdy
* dotaz znamenal cteni roli z uloziste.
*/
const effective = new Map<string, { at: number; permissions: Set<PermissionKey> }>();
const CACHE_MS = 5_000;
/** Zahodi cache. Vola se po zmene roli nebo clenstvi. */
export function invalidatePermissions(): void {
effective.clear();
}
let loadedRoles: Role[] = [];
/** Nacte role do pameti. Vola se pri startu a po kazde zmene. */
export async function refreshRoles(): Promise<void> {
loadedRoles = await roleStore.listAll();
invalidatePermissions();
}
export function rolesById(): Map<string, Role> {
return new Map(loadedRoles.map((role) => [role.id, role]));
}
/** Role dostupne dane firme: jeji vlastni plus systemove. */
export function rolesFor(tenantId: string | null): Role[] {
return loadedRoles.filter((role) => role.tenantId === null || role.tenantId === tenantId);
}
/**
* Prava uzivatele.
*
* Spojeni pres vsechna clenstvi, protoze pravo je bud, nebo neni - rozlisovat
* ho za firmu by znamenalo predavat firmu do kazde kontroly a to je presne to
* misto, kde se na ni zapomene.
*
* Spravce platformy ma vsechno. Je to nase pravo, ne zakaznicke.
*/
export function permissionsOf(user: User): Set<PermissionKey> {
if (user.platformAdmin) return new Set(allPermissions().map((item) => item.key));
const cached = effective.get(user.id);
if (cached && Date.now() - cached.at < CACHE_MS) return cached.permissions;
const byId = rolesById();
const byKey = new Map(loadedRoles.map((role) => [role.key, role]));
const result = new Set<PermissionKey>();
for (const membership of user.memberships) {
for (const roleRef of membership.roleIds) {
// Odkaz muze byt ID nebo klic systemove role, aby slo zapsat oboji.
const role = byId.get(roleRef) ?? byKey.get(roleRef);
if (!role) {
console.warn(`[permissions] ${user.email}: clenstvi odkazuje na neznamou roli ${roleRef}`);
continue;
}
for (const permission of role.permissions) {
if (knownPermission(permission)) result.add(permission);
else console.warn(`[permissions] role ${role.key}: nezname pravo ${permission}`);
}
}
}
effective.set(user.id, { at: Date.now(), permissions: result });
return result;
}
export function hasPermission(user: User, permission: PermissionKey): boolean {
return permissionsOf(user).has(permission);
}
/** Ma uzivatel aspon jedno z prav? Pro zalozky, kde staci cokoliv z modulu. */
export function hasAnyPermission(user: User, permissions: PermissionKey[]): boolean {
const owned = permissionsOf(user);
return permissions.some((permission) => owned.has(permission));
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Obnova kopii v pameti po zapisu.
*
* Zvlastni soubor kvuli cyklickym importum: routy potrebuji obnovu, ale
* `bootstrap.ts` importuje vsechna uloziste vcetne tech, ktere routy pouzivaji.
* Tenhle modul je proto tenka prepazka.
*
* Volani se **slucuje**. Deset zapisu za sebou znamena jednu obnovu, jinak by
* hromadna uprava znamenala desetkrat precteni vsech roli a lidi.
*/
import { refreshCaches } from './bootstrap.js';
let pending: Promise<void> | null = null;
let again = false;
export async function bootstrapDataRefresh(): Promise<void> {
if (pending) {
// Uz se obnovuje. Poznacime, ze po dokonceni ma prijit jeste jedna -
// jinak by se zmena z posledniho zapisu neprojevila.
again = true;
return pending;
}
pending = refreshCaches()
.catch((err: unknown) => {
console.error('[data] obnova kopii selhala:', err);
})
.finally(() => {
pending = null;
if (again) {
again = false;
void bootstrapDataRefresh();
}
});
return pending;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Ukladani do JSON souboru.
*
* Prostredek pro mockup, kde databaze neni. Prezije **restart procesu**
* i containeru, ale ne redeploy - filesystem containeru je docasny. Je to tedy
* mezistupen mezi pameti a databazi, ne jeji nahrada.
*
* Zapis je atomicky: nejdriv do docasneho souboru, pak prejmenovani. Pri padu
* uprostred zapisu tak nezustane polovicni JSON, ktery by se pri startu
* nenacetl. Zapisy se navic slucuji, aby deset uprav za sebou neznamenalo
* deset zapisu na disk.
*
* Rozbity soubor **neshodi start**. Zaloguje se a jede se s prazdnymi daty:
* aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
/** Obalka souboru. `version` je tu kvuli budoucim zmenam tvaru dat. */
interface SnapshotFile<T> {
version: number;
savedAt: string;
items: T[];
}
const VERSION = 1;
const DEBOUNCE_MS = 200;
export interface SnapshotStore<T> {
readonly kind: 'memory' | 'file';
/** Popis pro portal a log. */
readonly location: string | null;
load(): Promise<T[]>;
/** Naplanuje zapis. Vraci hned, zapis probehne po slouceni. */
save(items: T[]): void;
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
flush(): Promise<void>;
}
/** Uloziste, ktere nikam nezapisuje. Rezim pameti. */
export function memorySnapshot<T>(): SnapshotStore<T> {
return {
kind: 'memory',
location: null,
async load() {
return [];
},
save() {
// Zamerne nic. Pametovy rezim data neuklada.
},
async flush() {
// Zamerne nic.
},
};
}
export function fileSnapshot<T>(file: string): SnapshotStore<T> {
let pending: T[] | null = null;
let timer: NodeJS.Timeout | null = null;
let writing: Promise<void> = Promise.resolve();
async function writeNow(items: T[]): Promise<void> {
const payload: SnapshotFile<T> = {
version: VERSION,
savedAt: new Date().toISOString(),
items,
};
const temp = `${file}.tmp`;
try {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(temp, JSON.stringify(payload, null, 2), 'utf8');
// Prejmenovani je atomicke. Bez nej by pad uprostred zapisu nechal
// polovicni JSON, ktery se pri startu nenacte.
await fs.rename(temp, file);
} catch (err) {
console.error(
`[snapshot] ${file} se nepodarilo zapsat:`,
err instanceof Error ? err.message : err,
);
await fs.rm(temp, { force: true }).catch(() => undefined);
}
}
return {
kind: 'file',
location: file,
async load() {
let raw: string;
try {
raw = await fs.readFile(file, 'utf8');
} catch {
// Chybejici soubor je bezny stav, ne chyba. Prvni spusteni.
return [];
}
try {
const parsed = JSON.parse(raw) as SnapshotFile<T>;
if (!Array.isArray(parsed.items)) throw new Error('chybi pole items');
if (parsed.version !== VERSION) {
console.warn(
`[snapshot] ${file} ma verzi ${parsed.version}, ocekava se ${VERSION}. ` +
'Data se nacitaji, ale zkontrolujte tvar.',
);
}
console.info(`[snapshot] ${file}: nacteno ${parsed.items.length} zaznamu`);
return parsed.items;
} catch (err) {
// Rozbity soubor se zalohuje, at neni prepsan, a jede se s prazdnymi daty.
const broken = `${file}.broken`;
console.error(
`[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` +
`puvodni soubor je v ${broken}`,
);
await fs.rename(file, broken).catch(() => undefined);
return [];
}
},
save(items) {
// Kopie, aby pozdejsi zmeny v pameti neovlivnily to, co se prave zapisuje.
pending = items.map((item) => ({ ...item }));
if (timer) return;
timer = setTimeout(() => {
timer = null;
const snapshot = pending;
pending = null;
if (snapshot) writing = writing.then(() => writeNow(snapshot));
}, DEBOUNCE_MS);
},
async flush() {
if (timer) {
clearTimeout(timer);
timer = null;
}
const snapshot = pending;
pending = null;
if (snapshot) writing = writing.then(() => writeNow(snapshot));
await writing;
},
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Uloziste se synchronni kopii v pameti.
*
* Nektere entity se ctou pri **kazdem requestu**: uzivatel v autorizaci, firmy
* pri vypoctu prav, resitele u ticketu. Delat kvuli tomu asynchronni dotaz by
* znamenalo predelat autorizacni middleware na async a cist tabulku i pri kazdem
* pingu na health.
*
* Meni se naopak zridka. Drzi se proto kopie v pameti, cte se z ni synchronne
* a po kazdem zapisu se obnovi.
*
* Pouziva se **jen na konfiguracni entity**, tedy male seznamy. Na tickety nebo
* behy ne - tam by kopie v pameti byla to same jako zadna databaze.
*
* Pri vic instancich by kopie mohla byt na chvili zastarala. Resenim je
* `LISTEN/NOTIFY`, viz documentation/10-runtime-a-kapacita.md.
*/
import type { EntityStore, TenantEntity } from './types.js';
export interface CachedStore<T extends TenantEntity> {
readonly store: EntityStore<T>;
/** Nacte kopii. Vola se pri startu a po kazdem zapisu. */
refresh(): Promise<void>;
/** Synchronni cteni vsech zaznamu. */
all(): T[];
find(predicate: (entity: T) => boolean): T | undefined;
byId(id: string): T | undefined;
}
export function withCache<T extends TenantEntity>(store: EntityStore<T>): CachedStore<T> {
let rows: T[] = [];
return {
store,
async refresh() {
rows = await store.listAll();
},
all() {
return rows;
},
find(predicate) {
return rows.find(predicate);
},
byId(id) {
return rows.find((row) => row.id === id);
},
};
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Vyber uloziste pro obecne entity.
*
* Jedine misto, kde se rozhoduje mezi souborem, pameti a databazi. Nikde jinde
* se to nezjistuje - kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo.
*
* Pouziti:
* ```ts
* const roles = defineStore<Role>('role');
* await roles.init(() => systemRoles); // pri startu
* await roles.list({ tenantIds, includeGlobal: true });
* ```
*
* Vsechny vytvorene stores se registruji, aby se pri ukonceni procesu daly
* najednou dopsat na disk.
*/
import path from 'node:path';
import { config } from '../../config.js';
import { isDatabaseEnabled } from '../../db/pool.js';
import { createLocalStore } from './local.js';
import { createPostgresStore } from './postgres.js';
import type { EntityStore, TenantEntity } from './types.js';
export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js';
export { isVisible, nowIso } from './types.js';
/** Rezim se urcuje jednou pri startu, aby se stores nechovaly kazdy jinak. */
let useDatabase = false;
let ready = false;
const registry = new Map<string, EntityStore<TenantEntity>>();
/** Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores. */
export function initStores(options: { databaseReady: boolean }): 'postgres' | 'file' | 'memory' {
useDatabase = isDatabaseEnabled() && options.databaseReady;
ready = true;
if (useDatabase) return 'postgres';
return config.dataDir === '' ? 'memory' : 'file';
}
export function storesMode(): 'postgres' | 'file' | 'memory' {
if (useDatabase) return 'postgres';
return config.dataDir === '' ? 'memory' : 'file';
}
/**
* Vytvori uloziste pro dany druh entity.
*
* Vola se na urovni modulu, tedy jeste pred `initStores`. Backend se proto
* vybira az pri prvnim pouziti - `init` uz o rezimu vi.
*/
export function defineStore<T extends TenantEntity>(kind: string): EntityStore<T> {
let backend: EntityStore<T> | null = null;
function resolve(): EntityStore<T> {
if (!backend) {
if (!ready) {
console.warn(`[stores] ${kind} se pouziva pred initStores, jede se v pameti`);
}
backend = useDatabase
? createPostgresStore<T>(kind)
: createLocalStore<T>({
kind,
file: config.dataDir === '' ? null : path.join(config.dataDir, `${kind}.json`),
});
}
return backend;
}
const store: EntityStore<T> = {
kind,
init: (seed) => resolve().init(seed),
list: (options) => resolve().list(options),
listAll: () => resolve().listAll(),
get: (id, options) => resolve().get(id, options),
getRaw: (id) => resolve().getRaw(id),
create: (entity) => resolve().create(entity),
put: (entity) => resolve().put(entity),
update: (id, patch, options) => resolve().update(id, patch, options),
remove: (id, options) => resolve().remove(id, options),
flush: () => resolve().flush(),
};
registry.set(kind, store as EntityStore<TenantEntity>);
return store;
}
/** Dopise vsechna uloziste na disk. Vola se pri ukonceni procesu. */
export async function flushStores(): Promise<void> {
await Promise.all([...registry.values()].map((store) => store.flush()));
}
/** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */
export function storeKinds(): string[] {
return [...registry.keys()].sort();
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Obecne uloziste v souboru nebo v pameti.
*
* Jeden kod pro oba rezimy, lisi se jen tim, kam se zapisuje. Persistence je
* proto parametr (`SnapshotStore`), ne druha kopie kodu.
*/
import { fileSnapshot, memorySnapshot, type SnapshotStore } from '../snapshot.js';
import {
isVisible,
nowIso,
type EntityStore,
type ListOptions,
type TenantEntity,
} from './types.js';
export interface LocalStoreOptions {
kind: string;
/** Cesta k souboru, nebo null pro cistou pamet. */
file: string | null;
}
export function createLocalStore<T extends TenantEntity>(
options: LocalStoreOptions,
): EntityStore<T> {
const snapshot: SnapshotStore<T> = options.file
? fileSnapshot<T>(options.file)
: memorySnapshot<T>();
let rows: T[] = [];
function persist(): void {
snapshot.save(rows);
}
/** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */
function copy(row: T): T {
return structuredClone(row);
}
return {
kind: options.kind,
async init(seed) {
rows = await snapshot.load();
// Vychozi sada jen kdyz nic neni. Po restartu nesmi prepsat to,
// co uzivatel nastavil.
if (rows.length === 0 && seed) {
rows = seed();
if (rows.length > 0) persist();
}
},
async list(listOptions: ListOptions) {
return rows.filter((row) => isVisible(row, listOptions)).map(copy);
},
async listAll() {
return rows.map(copy);
},
async get(id, listOptions) {
const row = rows.find((item) => item.id === id);
// Cizi zaznam se chova jako neexistujici, ne jako chyba prava.
if (!row || !isVisible(row, listOptions)) return undefined;
return copy(row);
},
async getRaw(id) {
const row = rows.find((item) => item.id === id);
return row ? copy(row) : undefined;
},
async create(entity) {
rows.push(entity);
persist();
return copy(entity);
},
async put(entity) {
const index = rows.findIndex((item) => item.id === entity.id);
if (index === -1) rows.push(entity);
else rows[index] = entity;
persist();
},
async update(id, patch, listOptions) {
const index = rows.findIndex((item) => item.id === id);
if (index === -1 || !isVisible(rows[index], listOptions)) return undefined;
// ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi.
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
rows[index] = { ...rows[index], ...(rest as Partial<T>), updatedAt: nowIso() };
persist();
return copy(rows[index]);
},
async remove(id, listOptions) {
const index = rows.findIndex((item) => item.id === id);
if (index === -1 || !isVisible(rows[index], listOptions)) return false;
rows.splice(index, 1);
persist();
return true;
},
async flush() {
await snapshot.flush();
},
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Uloziste pro data, ktera se v pameti meni na miste.
*
* `withCache` resi konfiguracni entity: male seznamy, ktere se ctou pri kazdem
* requestu a meni zridka. Tickety, automatizace a rozlozeni dashboardu jsou
* jiny pripad - drzi se v pameti kvuli rychlosti a synchronnimu cteni, ale
* **meni se za provozu** a kazda zmena musi prezit restart.
*
* Rozdil proti `withCache` je smer:
* - `withCache` cte z uloziste do pameti po zapisu,
* - `withMirror` zapisuje z pameti do uloziste po zmene.
*
* Zapis je zamerne **fire-and-forget**. Kdyby selhani zapisu shodilo request,
* rozbite uloziste by rozbilo celou aplikaci. Data zustanou spravne v pameti
* a chyba se zaloguje - tohle je stejna dohoda jako u auditu (viz data/audit.ts).
*
* Pouziti:
* ```ts
* const mirror = withMirror(defineStore<StoredTicket>('ticket'));
* const rows = await mirror.load(() => seededTickets); // pri startu
* mirror.save(ticket); // po kazde zmene
* ```
*/
import type { EntityStore, TenantEntity } from './types.js';
export interface MirroredStore<T extends TenantEntity> {
readonly store: EntityStore<T>;
/**
* Nacte data z uloziste. Kdyz je prazdne, ulozi vychozi sadu z `seed`
* a vrati ji - takze prvni start naplni uloziste a druhy uz cte jeho obsah.
*/
load(seed?: () => T[]): Promise<T[]>;
/** Zapise cely zaznam, zalozi nebo prepise. Necekana se. */
save(entity: T): void;
/** Smaze zaznam. Necekana se. */
drop(id: string): void;
}
export function withMirror<T extends TenantEntity>(store: EntityStore<T>): MirroredStore<T> {
function report(action: string, err: unknown): void {
console.error(`[${store.kind}] ${action} se nepodarilo ulozit:`, err);
}
return {
store,
async load(seed) {
await store.init(seed);
return store.listAll();
},
save(entity) {
void store.put(entity).catch((err: unknown) => report(entity.id, err));
},
drop(id) {
/*
* Firma se dohleda ze zaznamu. `remove` ji potrebuje a prazdny seznam
* znamena "nic" (viz isVisible), takze bez dohledani by se nesmazalo nic.
* Pravo si volajici overil driv, tady uz jde jen o zapis.
*/
void store
.getRaw(id)
.then((entity) => {
if (!entity) return false;
return store.remove(id, {
tenantIds: entity.tenantId === null ? [] : [entity.tenantId],
includeGlobal: true,
});
})
.catch((err: unknown) => report(`smazani ${id}`, err));
},
};
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Obecne uloziste v Postgresu, tabulka `records`.
*
* Rozdil proti souborovemu ulozisti je jen v tomhle souboru. Volajici nepozna,
* ktere je zapnute.
*/
import { query, queryOne } from '../../db/pool.js';
import {
nowIso,
type EntityStore,
type ListOptions,
type TenantEntity,
} from './types.js';
interface RecordRow {
id: string;
tenant_id: string | null;
data: Record<string, unknown>;
created_at: Date;
updated_at: Date;
}
function toEntity<T extends TenantEntity>(row: RecordRow): T {
// `data` nese cely zaznam vcetne id a casu, sloupce jsou tam kvuli dotazum.
return {
...(row.data as unknown as T),
id: row.id,
tenantId: row.tenant_id,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
export function createPostgresStore<T extends TenantEntity>(kind: string): EntityStore<T> {
/**
* Podminka na viditelnost.
*
* Prazdny seznam firem znamena "nic", ne "vse". Bez teto vetve by
* `= ANY('{}')` sice nic nevratilo, ale spolehat se na to je past.
*/
function scope(options: ListOptions): { sql: string; params: unknown[] } {
const params: unknown[] = [kind, options.tenantIds];
const global = options.includeGlobal === true ? ' OR tenant_id IS NULL' : '';
return {
sql: `kind = $1 AND (tenant_id = ANY($2)${global})`,
params,
};
}
return {
kind,
async init(seed) {
if (!seed) return;
// Vychozi sada jen kdyz tabulka pro tenhle druh jeste nic nema.
const existing = await queryOne<{ count: string }>(
'SELECT count(*) AS count FROM records WHERE kind = $1',
[kind],
);
if (Number(existing?.count ?? 0) > 0) return;
for (const entity of seed()) {
await this.create(entity);
}
},
async list(options) {
const { sql, params } = scope(options);
const rows = await query<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE ${sql}
ORDER BY created_at`,
params,
);
return rows.map((row) => toEntity<T>(row));
},
async listAll() {
const rows = await query<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE kind = $1 ORDER BY created_at`,
[kind],
);
return rows.map((row) => toEntity<T>(row));
},
async get(id, options) {
const { sql, params } = scope(options);
const row = await queryOne<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE ${sql} AND id = $${params.length + 1}`,
[...params, id],
);
return row ? toEntity<T>(row) : undefined;
},
async getRaw(id) {
const row = await queryOne<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE kind = $1 AND id = $2`,
[kind, id],
);
return row ? toEntity<T>(row) : undefined;
},
async create(entity) {
const row = await queryOne<RecordRow>(
`INSERT INTO records (kind, id, tenant_id, data, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $5)
RETURNING id, tenant_id, data, created_at, updated_at`,
[kind, entity.id, entity.tenantId, JSON.stringify(entity), entity.createdAt],
);
return toEntity<T>(row!);
},
async put(entity) {
// Konflikt na primarnim klici (kind, id), viz migraci 002_records.sql.
await query(
`INSERT INTO records (kind, id, tenant_id, data, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (kind, id) DO UPDATE
SET data = excluded.data, tenant_id = excluded.tenant_id, updated_at = now()`,
[kind, entity.id, entity.tenantId, JSON.stringify(entity), entity.createdAt],
);
},
async update(id, patch, options) {
const current = await this.get(id, options);
if (!current) return undefined;
// ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi.
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
const merged = { ...current, ...(rest as Partial<T>), updatedAt: nowIso() };
const row = await queryOne<RecordRow>(
`UPDATE records SET data = $3::jsonb, tenant_id = $4, updated_at = now()
WHERE kind = $1 AND id = $2
RETURNING id, tenant_id, data, created_at, updated_at`,
[kind, id, JSON.stringify(merged), merged.tenantId],
);
return row ? toEntity<T>(row) : undefined;
},
async remove(id, options) {
const current = await this.get(id, options);
if (!current) return false;
await query('DELETE FROM records WHERE kind = $1 AND id = $2', [kind, id]);
return true;
},
async flush() {
// Databaze nic neodklada.
},
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Obecne uloziste zaznamu.
*
* Kazda nova entita (typy ticketu, akce, role, widgety, ...) potrebuje totez:
* seznam za firmu, cteni podle ID, zapis, mazani. Napsat to pro kazdou zvlast
* ve dvou backendech znamena osm skoro stejnych souboru, ze kterych se jeden
* casem opravi a ostatni ne.
*
* Proto jedno rozhrani a dve implementace:
* - `store/local.ts` JSON soubor nebo cista pamet,
* - `store/postgres.ts` tabulka `records` s JSONB.
*
* Vyber je na jednom miste v `store/index.ts`.
*
* Konektory maji vlastni uloziste zamerne: nesou sifrovana tajemstvi a potrebuji
* castecny unikatni index na vychozi napojeni. To se v obecnem ulozisti resit
* neda, viz documentation/14-databaze.md.
*/
/** Kazdy zaznam ma ID a vi, kdy vznikl. */
export interface Entity {
id: string;
createdAt: string;
updatedAt: string;
}
/**
* Zaznam patrici firme.
*
* `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv.
* Typicky systemova role nebo sluzba dostupna vsem.
*/
export interface TenantEntity extends Entity {
tenantId: string | null;
}
export interface ListOptions {
/**
* Ze kterych firem se smi vracet. **Povinny argument**, ne volitelny.
* Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se.
*/
tenantIds: string[];
/** true = vrati i platformni zaznamy (`tenantId: null`). */
includeGlobal?: boolean;
}
export interface EntityStore<T extends TenantEntity> {
readonly kind: string;
/** Nacte data z uloziste. Vola se jednou pri startu. */
init(seed?: () => T[]): Promise<void>;
list(options: ListOptions): Promise<T[]>;
/** Vsechny zaznamy bez ohledu na firmu. Jen pro spravce platformy. */
listAll(): Promise<T[]>;
get(id: string, options: ListOptions): Promise<T | undefined>;
/** Zaznam bez kontroly firmy. Volajici si ji musi overit sam. */
getRaw(id: string): Promise<T | undefined>;
create(entity: T): Promise<T>;
/**
* Zapise zaznam tak, jak je - zalozi nebo prepise.
*
* Je to tady pro entity, ktere se v pameti meni na miste a pak se ukladaji
* cele (tickety, automatizace, rozlozeni dashboardu). `update` s patchem by
* u nich znamenal skladat rozdil, ktery volajici uz zna cely.
*/
put(entity: T): Promise<void>;
update(id: string, patch: Partial<T>, options: ListOptions): Promise<T | undefined>;
remove(id: string, options: ListOptions): Promise<boolean>;
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
flush(): Promise<void>;
}
export function nowIso(): string {
return new Date().toISOString();
}
/** Vidi volajici tenhle zaznam? */
export function isVisible(entity: TenantEntity, options: ListOptions): boolean {
if (entity.tenantId === null) return options.includeGlobal === true;
return options.tenantIds.includes(entity.tenantId);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Co firma vubec ma: zalozky, limity, zpristupnene sluzby.
*
* **Dve vrstvy s jinym vlastnikem, ktere se nesmi michat:**
*
* | Vrstva | Kdo nastavuje | Znamena |
* | --------------- | ---------------- | ------------------------------ |
* | `TenantFeatures`| my | co ma firma zaplacene a zapnute |
* | `Role` | admin te firmy | kdo z jejich lidi to smi |
*
* Efektivni viditelnost je prunik. Vypnuty modul neexistuje ani pro admina te
* firmy - nema si ho jak zapnout, protoze ho nema. Kdyby to byla jedna vrstva,
* klientsky admin by si mohl zapnout, co si nekoupil.
*
* Popis je v documentation/17-zalozky-a-limity.md.
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import { hasAnyPermission } from './permissions.js';
import type { User } from '../types.js';
/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */
export type ModuleKey =
| 'overview'
| 'automations'
| 'services'
| 'connectors'
| 'scripts'
| 'tickets'
| 'incidents'
| 'settings';
export interface ModuleDefinition {
key: ModuleKey;
label: string;
/** Aspon jedno z tehle prav staci, aby zalozka mela smysl. */
permissions: string[];
/** true = zalozku nejde vypnout, bez ni by portal nemel kde zacit. */
required?: boolean;
/** true = jen pro spravce platformy, firma ji nikdy nevidi. */
platform?: boolean;
}
/**
* Katalog modulu. Zdroj pravdy o tom, co lze firme zapnout.
* Nova zalozka = zaznam tady plus cesta na klientovi.
*/
export const moduleCatalog: ModuleDefinition[] = [
{ key: 'overview', label: 'Přehled', permissions: [], required: true },
{ key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] },
{ key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] },
{ key: 'services', label: 'Služby', permissions: ['connector.manage'] },
{ key: 'connectors', label: 'Konektory', permissions: ['connector.manage'] },
{ key: 'incidents', label: 'Incidenty', permissions: ['ticket.view'] },
{ key: 'settings', label: 'Nastavení', permissions: [], required: true },
// Skripty jsou nase vykonna cast, klient je needituje.
{ key: 'scripts', label: 'Skripty', permissions: ['script.edit'], platform: true },
];
export interface TenantLimits {
automations: number;
widgets: number;
actions: number;
connectors: number;
}
export interface TenantFeatures extends TenantEntity {
/** Ktere moduly firma ma. Povinne se doplnuji vzdy. */
modules: ModuleKey[];
limits: TenantLimits;
/**
* Ktere sluzby firma vidi navic nad `visibility: everyone`.
* Prazdne pole = jen to, co je verejne.
*/
serviceIds: string[];
}
export const featuresStore = defineStore<TenantFeatures>('tenantFeatures');
const cache = withCache(featuresStore);
export const defaultLimits: TenantLimits = {
automations: 50,
widgets: 12,
actions: 30,
connectors: 20,
};
/** Vychozi sada: vsechno krome platformnich modulu. */
export function defaultModules(): ModuleKey[] {
return moduleCatalog.filter((module) => !module.platform).map((module) => module.key);
}
export function seedFeatures(): TenantFeatures[] {
const timestamp = nowIso();
// Vychozi zaznam je platformni (tenantId null) a plati pro kazdou firmu,
// ktera svuj vlastni nema. Diky tomu nova firma neni bez zalozek.
return [
{
id: 'feat_default',
tenantId: null,
modules: defaultModules(),
limits: defaultLimits,
serviceIds: [],
createdAt: timestamp,
updatedAt: timestamp,
},
];
}
export async function refreshFeatures(): Promise<void> {
await cache.refresh();
}
/** Nastaveni firmy, nebo vychozi, kdyz vlastni nema. */
export function featuresOf(tenantId: string | null): TenantFeatures {
const own = tenantId ? cache.find((item) => item.tenantId === tenantId) : undefined;
if (own) return own;
const fallback = cache.find((item) => item.tenantId === null);
if (fallback) return fallback;
// Bez ulozeneho nastaveni radsi vychozi v pameti nez prazdny portal.
const timestamp = nowIso();
return {
id: 'feat_fallback',
tenantId: null,
modules: defaultModules(),
limits: defaultLimits,
serviceIds: [],
createdAt: timestamp,
updatedAt: timestamp,
};
}
export function limitsOf(tenantId: string | null): TenantLimits {
return featuresOf(tenantId).limits;
}
export interface NavItem {
key: ModuleKey;
label: string;
}
/**
* Zalozky, ktere ma uzivatel videt.
*
* Prunik dvou vrstev: co firma ma a na co ma uzivatel pravo. Modul bez
* pozadovanych prav se nevykresli, i kdyz firma modul ma - jinak by clovek
* klikl a dostal 403.
*/
export function navFor(user: User, tenantId: string | null): NavItem[] {
const features = featuresOf(tenantId);
const enabled = new Set(features.modules);
return moduleCatalog
.filter((module) => {
if (module.platform) return user.platformAdmin;
if (!module.required && !enabled.has(module.key)) return false;
if (module.permissions.length === 0) return true;
return hasAnyPermission(user, module.permissions);
})
.map((module) => ({ key: module.key, label: module.label }));
}
/** Vidi firma tuhle sluzbu diky zpristupneni? */
export function tenantHasService(tenantId: string | null, serviceId: string): boolean {
return featuresOf(tenantId).serviceIds.includes(serviceId);
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Definice akci na ticketu.
*
* Akce a automatizacni strom jsou **dve samostatne veci**. Akce se vaze na typ
* nebo tag ticketu a spousti ji clovek kliknutim, strom se spousti udalosti
* a dela si to sam. Spolecny je pod obojim jen katalog sluzeb a model kroku.
*
* Telo akce ma tri druhy, kazdy je jina uroven slozitosti pro tehoz cloveka:
* - `operation` jeden krok sluzby, nastavi se formularem,
* - `tree` vlastni strom, tentyz builder jako u automatizaci,
* - `script` vlastni skript, kdyz jde o prevod dat.
*
* Popis je v documentation/09-navrh-rozsireni.md, sekce 1 a 2.
*/
import type { FlowStep } from './automationStore.js';
import { operatorAllowedForType, type ConditionOperator } from './conditions.js';
import type { PermissionDefinition } from './permissions.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import { findService } from './services.js';
import { ticketScopeFields, type TicketType } from './ticketTypes.js';
/** Podminka nad ticketem. Vsechny musi platit, aby se tlacitko ukazalo. */
export interface ActionCondition {
/** ID pole ze scope ticketu, napr. `ticket.status`. */
fieldId: string;
operator: ConditionOperator;
value?: string;
}
export type ActionBody =
/** Jeden krok sluzby. Nejcastejsi pripad, nastavi se bez builderu. */
| {
kind: 'operation';
serviceId: string;
operationId: string;
/** null = vychozi konektor firmy. */
connectorId: string | null;
inputs: Record<string, string>;
}
/** Vlastni strom. Tentyz model i builder jako u automatizaci. */
| { kind: 'tree'; steps: FlowStep[] }
/** Vlastni skript. Podminky a limity jsou u skriptu. */
| { kind: 'script'; scriptId: string; inputs: Record<string, string> };
/** Na co se uzivatel doptá pred spustenim. */
export interface ActionFormField {
id: string;
label: string;
kind: 'text' | 'longtext' | 'choice';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
export interface TicketAction extends TenantEntity {
tenantId: string;
/** Na co se vaze. Aspon jedno z obojiho, jinak by se nabizela vsude. */
ticketTypeIds: string[];
tags: string[];
label: string;
icon: string;
style: 'primary' | 'default' | 'danger';
order: number;
/** Kdy se tlacitko vubec ukaze. */
visibleWhen: ActionCondition[];
/** Text potvrzeni, null = spusti se hned. */
confirm: string | null;
form: ActionFormField[];
body: ActionBody;
enabled: boolean;
}
export const actionStore = defineStore<TicketAction>('ticketAction');
const cache = withCache(actionStore);
/** Pravo k akci vznika spolu s ni. Admin pak zaskrtava akce, ne prava. */
export function actionPermission(actionId: string): string {
return `action:${actionId}`;
}
export function seedActions(): TicketAction[] {
const timestamp = nowIso();
return [
{
id: 'tka_send_idoklad',
tenantId: 'tnt_automia',
ticketTypeIds: ['tt_order'],
tags: [],
label: 'Odeslat do iDokladu',
icon: 'Receipt',
style: 'primary',
order: 1,
// Dvakrat odeslana objednavka by znamenala dva doklady.
visibleWhen: [{ fieldId: 'ticket.status', operator: 'neq', value: 'resolved' }],
confirm: 'Objednávka se odešle do iDokladu. Pokračovat?',
form: [],
body: {
kind: 'operation',
serviceId: 'idoklad',
operationId: 'create-issued-invoice',
connectorId: null,
inputs: {
partnerId: '{{partnerId}}',
description: 'Objednávka {{orderNumber}}',
itemName: 'Zboží dle objednávky {{orderNumber}}',
unitPrice: '{{orderTotal}}',
},
},
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
},
];
}
export async function refreshActions(): Promise<void> {
await cache.refresh();
}
export function listActions(tenantIds: string[]): TicketAction[] {
return cache
.all()
.filter((action) => tenantIds.includes(action.tenantId))
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs'));
}
export function findAction(id: string): TicketAction | undefined {
return cache.byId(id);
}
/** Prava vznikla z akci. Registruji se do katalogu prav, aby je role nabidly. */
export function actionPermissions(): PermissionDefinition[] {
return cache.all().map((action) => ({
key: actionPermission(action.id),
label: `Spustit: ${action.label}`,
group: 'Akce na ticketu',
}));
}
// ------------------------------------------------------------- vyhodnocovani
/** Hodnoty, proti kterym se vyhodnocuje `visibleWhen`. */
export type TicketFacts = Record<string, string | number | boolean | null>;
function compare(operator: ConditionOperator, actual: unknown, expected: string | undefined): boolean {
const text = actual === null || actual === undefined ? '' : String(actual);
switch (operator) {
case 'eq':
return text === (expected ?? '');
case 'neq':
return text !== (expected ?? '');
case 'contains':
return text.toLowerCase().includes((expected ?? '').toLowerCase());
case 'startsWith':
return text.toLowerCase().startsWith((expected ?? '').toLowerCase());
case 'isEmpty':
return text.trim() === '';
case 'isNotEmpty':
return text.trim() !== '';
case 'isTrue':
return actual === true || text === 'true';
case 'isFalse':
return actual === false || text === 'false' || text === '';
case 'gt':
case 'gte':
case 'lt':
case 'lte': {
const left = Number(actual);
const right = Number(expected);
if (!Number.isFinite(left) || !Number.isFinite(right)) return false;
if (operator === 'gt') return left > right;
if (operator === 'gte') return left >= right;
if (operator === 'lt') return left < right;
return left <= right;
}
default:
return false;
}
}
/** Plati vsechny podminky? Prazdny seznam znamena "vzdy". */
export function conditionsPass(conditions: ActionCondition[], facts: TicketFacts): boolean {
return conditions.every((condition) => compare(condition.operator, facts[condition.fieldId], condition.value));
}
/**
* Akce, ktere na ticket sedi.
*
* 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.
*/
export function actionsForTicket(
tenantIds: string[],
ticket: { typeId: string | null; tags: string[] },
facts: TicketFacts,
can: (permission: string) => boolean,
): TicketAction[] {
return listActions(tenantIds).filter((action) => {
if (!action.enabled) return false;
const typeMatches =
action.ticketTypeIds.length === 0 ||
(ticket.typeId !== null && action.ticketTypeIds.includes(ticket.typeId));
const tagMatches =
action.tags.length === 0 || action.tags.some((tag) => ticket.tags.includes(tag));
// Prazdna vazba na obou stranach by znamenala akci vsude, to nechceme.
if (action.ticketTypeIds.length === 0 && action.tags.length === 0) return false;
if (!typeMatches || !tagMatches) return false;
if (!can(actionPermission(action.id))) return false;
return conditionsPass(action.visibleWhen, facts);
});
}
// ------------------------------------------------------------------ validace
/**
* Overi definici akce.
*
* Kontroluje se pri ulozeni, aby se preklep nedozvedel uzivatel az z padleho
* behu. Vraci popisy problemu, prazdne pole znamena v poradku.
*/
export function validateAction(action: TicketAction, types: TicketType[]): string[] {
const problems: string[] = [];
if (action.ticketTypeIds.length === 0 && action.tags.length === 0) {
problems.push('Vyberte typ ticketu nebo tag, jinak by se akce nabízela všude.');
}
for (const typeId of action.ticketTypeIds) {
if (!types.some((type) => type.id === typeId)) {
problems.push(`Typ ticketu ${typeId} v této firmě neexistuje.`);
}
}
// Podminky se smi ptat jen na to, co ticket opravdu ma.
const type = types.find((item) => action.ticketTypeIds.includes(item.id));
const available = new Map(ticketScopeFields(type).map((field) => [field.id, field]));
for (const condition of action.visibleWhen) {
const field = available.get(condition.fieldId);
if (!field) {
problems.push(`Podmínka odkazuje na pole ${condition.fieldId}, které ticket nemá.`);
continue;
}
if (!operatorAllowedForType(condition.operator, field.type)) {
problems.push(
`Operátor ${condition.operator} nelze použít na pole ${field.name} typu ${field.type}.`,
);
}
}
// Kopie do lokalni promenne: v callbacku by TypeScript zuzeni typu ztratil.
const body = action.body;
if (body.kind === 'operation') {
const service = findService(body.serviceId);
if (!service) {
problems.push(`Služba ${body.serviceId} neexistuje.`);
} else if (!service.actions.some((operation) => operation.id === body.operationId)) {
problems.push(`Služba ${service.name} nemá akci ${body.operationId}.`);
}
}
if (body.kind === 'tree' && body.steps.length === 0) {
problems.push('Strom akce nemá žádný krok.');
}
return problems;
}
+75 -7
View File
@@ -10,11 +10,16 @@
* - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe",
* - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila.
*
* POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu.
* Data se drzi v pameti a po kazde zmene se cely ticket zapise do uloziste
* (`withMirror`). Pri startu se cte uloziste, ukazkova sada nize se pouzije jen
* kdyz je prazdne. Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje
* `data/store/index.ts`, tady se to neresi.
*/
import { publish } from '../events/bus.js';
import { findPerson, type Person } from './people.js';
import { defineStore } from './store/index.js';
import { withMirror } from './store/mirror.js';
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
@@ -153,6 +158,63 @@ const tickets: StoredTicket[] = [];
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
const traces = new Map<string, TicketTraceEntry[]>();
/**
* Tvar v ulozisti. Log je soucasti zaznamu zamerne: v pameti se drzi zvlast
* kvuli objemu, ale ukladat ho jako druhou entitu by znamenalo dva zapisy
* pri kazdem kroku automatizace a moznost, ze jeden z nich selze.
*/
interface PersistedTicket extends StoredTicket {
trace: TicketTraceEntry[];
}
const mirror = withMirror(defineStore<PersistedTicket>('ticket'));
/** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */
function persist(ticket: StoredTicket): void {
mirror.save({ ...ticket, trace: traces.get(ticket.id) ?? [] });
}
/**
* Oznaci ticket jako zmeneny a ulozi ho.
*
* Kazda zmena jde skrz tohle, aby neslo upravit ticket a zapomenout na
* `updatedAt` nebo na zapis. Pary "prirad radek, uloz" se jinak rozejdou.
*/
function touch(ticket: StoredTicket): void {
ticket.updatedAt = new Date().toISOString();
persist(ticket);
}
/**
* Nacte tickety z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
* Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili
* v pameti. Po prvnim startu je tedy uloziste jediny zdroj pravdy.
*/
export async function initTickets(): Promise<void> {
const rows = await mirror.load(() =>
tickets.map((ticket) => ({ ...ticket, trace: traces.get(ticket.id) ?? [] })),
);
tickets.length = 0;
traces.clear();
for (const row of rows) {
const { trace, ...stored } = row;
tickets.push(stored);
traces.set(row.id, trace ?? []);
}
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety
// prepisovaly stare.
for (const ticket of tickets) {
const number = Number(/^TK-(\d+)$/.exec(ticket.id)?.[1] ?? 0);
if (number > ticketCounter) ticketCounter = number;
}
// Log muze byt dlouhy, do citace radku se to nepocita.
traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter);
}
let ticketCounter = 4_821;
let traceCounter = 0;
@@ -214,6 +276,11 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
}
const before = existing.length;
flattenTrace(inputs, null, existing);
// Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu.
const ticket = tickets.find((item) => item.id === ticketId);
if (ticket) persist(ticket);
return existing.length - before;
}
@@ -806,6 +873,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
};
tickets.unshift(stored);
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
persist(stored);
publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, {
ticketId: stored.id,
@@ -840,7 +908,7 @@ export function updateTicketStatus(
const previous = ticket.status;
ticket.status = status;
ticket.updatedAt = new Date().toISOString();
touch(ticket);
appendTrace(id, [
{
@@ -888,7 +956,7 @@ export function assignTicket(
}
ticket.assigneeId = person?.id ?? null;
ticket.updatedAt = new Date().toISOString();
touch(ticket);
appendTrace(id, [
{
@@ -926,7 +994,7 @@ export function setTicketType(
ticket.typeId = typeId;
if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields };
ticket.updatedAt = new Date().toISOString();
touch(ticket);
appendTrace(id, [
{ kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` },
@@ -941,7 +1009,7 @@ export function setTicketTags(id: string, tags: string[], tenantIds: string[]):
if (!ticket) return undefined;
ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
ticket.updatedAt = new Date().toISOString();
touch(ticket);
publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id });
return toTicket(ticket);
}
@@ -962,7 +1030,7 @@ export function assignTicketGroup(
ticket.assigneeGroupId = groupId;
if (groupId) ticket.assigneeId = null;
ticket.updatedAt = new Date().toISOString();
touch(ticket);
appendTrace(id, [
{
@@ -987,7 +1055,7 @@ export function addComment(
return undefined;
}
ticket.updatedAt = new Date().toISOString();
touch(ticket);
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id });
+167
View File
@@ -0,0 +1,167 @@
/**
* Typy ticketu a vlastni pole.
*
* Bez typu neni na cem tlacitka rozlisovat a bez vlastnich poli neni s cim
* pracovat: akce "Potvrdit objednavku" potrebuje cislo objednavky, ne jen
* predmet a text.
*
* Dve `id` u pole nejsou zbytecna, je to totez rozdeleni, ktere uz v projektu je:
* - **podminky odkazuji na `id`**, aby prejmenovani nic nerozbilo,
* - **sablony odkazuji na `key`**, aby si to clovek precetl.
*
* Typ versus tag: typ je prave jeden a stoji za nim pole, tagu je libovolne
* mnozstvi a nestoji za nimi nic. Podrobnosti v documentation/09, sekce
* "Typ nebo tag".
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import type { FieldType } from './conditions.js';
export interface TicketTypeField {
/** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */
id: string;
/** Pouziva se v sablonach jako `{{orderNumber}}`. */
key: string;
label: string;
type: FieldType;
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
export interface TicketType extends TenantEntity {
tenantId: string;
/** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */
key: string;
name: string;
/** Klic ikony, mapuje se na klientovi. */
icon: string;
/** Vlastni workflow stavu. Prazdne = vychozi ctverice ticketu. */
statuses: string[];
fields: TicketTypeField[];
}
export const ticketTypeStore = defineStore<TicketType>('ticketType');
const cache = withCache(ticketTypeStore);
export function seedTicketTypes(): TicketType[] {
const timestamp = nowIso();
return [
{
id: 'tt_order',
tenantId: 'tnt_automia',
key: 'order',
name: 'Objednávka',
icon: 'ShoppingCart',
statuses: [],
fields: [
{
id: 'fld_order_no',
key: 'orderNumber',
label: 'Číslo objednávky',
type: 'string',
required: true,
},
{
id: 'fld_order_total',
key: 'orderTotal',
label: 'Částka',
type: 'number',
required: false,
},
{
id: 'fld_order_partner',
key: 'partnerId',
label: 'ID odběratele v iDokladu',
type: 'number',
required: false,
hint: 'Umí ho dohledat akce Najít kontakt.',
},
],
createdAt: timestamp,
updatedAt: timestamp,
},
];
}
export async function refreshTicketTypes(): Promise<void> {
await cache.refresh();
}
export function listTicketTypes(tenantIds: string[]): TicketType[] {
return cache
.all()
.filter((type) => tenantIds.includes(type.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
export function findTicketType(id: string): TicketType | undefined {
return cache.byId(id);
}
export function findTicketTypeByKey(tenantId: string, key: string): TicketType | undefined {
return cache.find((type) => type.tenantId === tenantId && type.key === key);
}
/**
* Parametry, ktere typ ticketu prinasi do stromu.
*
* Vestavena pole ticketu plus vlastni pole typu. Tvarem odpovida
* `ProvidedField`, aby s tim builder umel pracovat bez zmeny.
*/
export function ticketScopeFields(type: TicketType | undefined): Array<{
id: string;
name: string;
type: FieldType;
required: boolean;
}> {
const builtin = [
{ id: 'ticket.id', name: 'ticketId', type: 'string' as FieldType, required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string' as FieldType, required: true },
{ id: 'ticket.body', name: 'body', type: 'string' as FieldType, required: false },
{ id: 'ticket.status', name: 'status', type: 'string' as FieldType, required: true },
{ id: 'ticket.priority', name: 'priority', type: 'string' as FieldType, required: true },
{ id: 'ticket.company', name: 'company', type: 'string' as FieldType, required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string' as FieldType, required: false },
{ id: 'ticket.reply', name: 'reply', type: 'string' as FieldType, required: false },
];
if (!type) return builtin;
return [
...builtin,
...type.fields.map((field) => ({
id: `ticket.${field.id}`,
name: field.key,
type: field.type,
required: field.required,
})),
];
}
/**
* Overi hodnoty vlastnich poli proti typu.
* Vraci popisy problemu, prazdne pole znamena v poradku.
*/
export function validateTicketFields(
type: TicketType | undefined,
values: Record<string, unknown>,
): string[] {
if (!type) return [];
const problems: string[] = [];
const known = new Set(type.fields.map((field) => field.key));
for (const field of type.fields) {
const value = values[field.key];
const missing = value === undefined || value === null || value === '';
if (field.required && missing) problems.push(`${field.label} je povinné.`);
}
for (const key of Object.keys(values)) {
if (!known.has(key)) problems.push(`Typ ${type.name} pole „${key}" nemá.`);
}
return problems;
}
+480
View File
@@ -1,5 +1,110 @@
import { config } from './config.js';
/**
* Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na
* serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne
* by znamenalo devet mist, ktere se casem rozejdou.
*/
function crudPaths(entity: {
/** Cast cesty, napr. `roles`. */
path: string;
/** Jak se o tom mluvi v popisu, napr. `roli`. */
label: string;
/** Pravo, ktere je na zapis potreba. */
permission: string;
}) {
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const body = {
required: true,
content: { 'application/json': { schema: { type: 'object' } } },
};
const record = {
description: 'Zaznam',
content: { 'application/json': { schema: { type: 'object' } } },
};
const denied = { '403': { description: `Chybi pravo ${entity.permission}` } };
const base = `/api/dashboard/settings/${entity.path}`;
return {
[base]: {
get: {
tags: ['Nastaveni'],
summary: `Seznam - ${entity.label}`,
description: 'Vraci jen zaznamy firem, do kterych volajici patri.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Seznam',
content: {
'application/json': {
schema: {
type: 'object',
properties: { items: { type: 'array', items: { type: 'object' } } },
},
},
},
},
...denied,
},
},
post: {
tags: ['Nastaveni'],
summary: `Vytvorit - ${entity.label}`,
security: [{ bearerAuth: [] }],
requestBody: body,
responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied },
},
},
[`${base}/{id}`]: {
get: {
tags: ['Nastaveni'],
summary: `Detail - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied },
},
patch: {
tags: ['Nastaveni'],
summary: `Upravit - ${entity.label}`,
description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.',
security: [{ bearerAuth: [] }],
parameters: [id],
requestBody: body,
responses: {
'200': record,
'400': { description: 'Neplatny vstup' },
'404': { description: 'Neexistuje' },
...denied,
},
},
delete: {
tags: ['Nastaveni'],
summary: `Smazat - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: {
'204': { description: 'Smazano' },
'404': { description: 'Neexistuje' },
...denied,
},
},
},
};
}
/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */
const settingsEntities = [
{ path: 'tenants', label: 'firmy', permission: 'tenant.manage' },
{ path: 'users', label: 'uzivatele', permission: 'user.manage' },
{ path: 'roles', label: 'role a prava', permission: 'role.manage' },
{ path: 'people', label: 'resitele', permission: 'people.manage' },
{ path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' },
{ path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' },
{ path: 'actions', label: 'akce na ticketu', permission: 'action.manage' },
{ path: 'widgets', label: 'vlastni widgety', permission: 'widget.manage' },
{ path: 'features', label: 'zalozky firmy', permission: 'tenant.manage' },
];
/**
* OpenAPI popis API.
*
@@ -29,6 +134,8 @@ export function buildOpenApiDocument() {
{ name: 'Sluzby', description: 'Katalog toho, co umime napojit' },
{ name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' },
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
{ name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' },
{ name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
@@ -556,6 +663,11 @@ export function buildOpenApiDocument() {
},
},
paths: {
// Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths` vyse.
...settingsEntities.reduce(
(all, entity) => ({ ...all, ...crudPaths(entity) }),
{} as Record<string, unknown>,
),
'/health': {
get: {
tags: ['Provoz'],
@@ -954,6 +1066,374 @@ export function buildOpenApiDocument() {
},
},
},
'/api/dashboard/tickets/{id}/type': {
post: {
tags: ['Tickety'],
summary: 'Nastavit typ ticketu',
description:
'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' +
'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['typeId'],
properties: {
typeId: { type: 'string', nullable: true, example: 'tt_order' },
fields: {
type: 'object',
description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.',
additionalProperties: true,
},
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.type.change' },
'404': { description: 'Ticket nebo typ neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/tags': {
post: {
tags: ['Tickety'],
summary: 'Nastavit tagy',
description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['tags'],
properties: {
tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } },
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.tag' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/group': {
post: {
tags: ['Tickety'],
summary: 'Prehodit na skupinu resitelu',
description:
'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' +
've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['groupId'],
properties: { groupId: { type: 'string', nullable: true } },
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.assign.group' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
'/api/dashboard/widget-data': {
post: {
tags: ['Dashboard'],
summary: 'Data vlastnich widgetu',
description:
'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['widgetIds'],
properties: { widgetIds: { type: 'array', items: { type: 'string' } } },
},
},
},
},
responses: {
'200': {
description: 'Data po widgetech',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/widget-data/options': {
get: {
tags: ['Dashboard'],
summary: 'Co jde ve vlastnim widgetu nastavit',
description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Moznosti',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/settings/catalog': {
get: {
tags: ['Nastaveni'],
summary: 'Katalog prav a modulu',
description:
'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Katalog',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/settings/users/{id}/password': {
patch: {
tags: ['Nastaveni'],
summary: 'Zmenit heslo',
description:
'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 8 } },
},
},
},
},
responses: {
'204': { description: 'Zmeneno' },
'400': { description: 'Kratke heslo' },
'403': { description: 'Cizi heslo bez prava' },
},
},
},
'/api/admin/impersonate': {
post: {
tags: ['Sprava platformy'],
summary: 'Prepnout se na jiny ucet',
description:
'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' +
'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
allowWrites: {
type: 'boolean',
default: false,
description: 'true = i zapis. Musi se zapnout vedome.',
},
},
},
},
},
},
responses: {
'200': {
description: 'Token na cizi ucet',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
'404': { description: 'Ucet neexistuje' },
},
},
},
'/api/admin/impersonate/stop': {
post: {
tags: ['Sprava platformy'],
summary: 'Ukoncit prepnuti',
description:
'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.',
security: [{ bearerAuth: [] }],
responses: { '204': { description: 'Zapsano' } },
},
},
'/api/admin/impersonate/candidates': {
get: {
tags: ['Sprava platformy'],
summary: 'Koho lze prepnout',
description: 'Spravci platformy se nenabizeji.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Ucty',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/admin/audit': {
get: {
tags: ['Sprava platformy'],
summary: 'Audit',
description:
'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' +
'Nejnovejsi nahore.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'action', in: 'query', schema: { type: 'string' } },
{ name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } },
],
responses: {
'200': {
description: 'Zaznamy',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/dashboard/incidents': {
get: {
tags: ['Dashboard'],
+12 -2
View File
@@ -217,13 +217,23 @@ dashboardRouter.delete('/layout', (req, res) => {
// ------------------------------------------------------------------- tickety
/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */
/**
* Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu.
*
* Skupiny jdou stejnym endpointem zamerne: kdo smi prirazovat ticket, smi ho
* prirazit i skupine, a druhy request na dve polozky nema smysl. Sprava skupin
* je jina vec a ma vlastni pravo v nastaveni.
*/
dashboardRouter.get('/people', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const me = findPersonByEmail(req.user!.email);
return res.json({ items: listPeople(scope.tenantIds), meId: me?.id ?? null });
return res.json({
items: listPeople(scope.tenantIds),
groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })),
meId: me?.id ?? null,
});
});
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];