Cely navrh rozsireni: role, firmy, typy ticketu, akce, widgety, audit

Implementace vsech bodu z documentation/09-navrh-rozsireni.md. Vsechno lezi
v obecnem ulozisti, ktere umi Postgres i JSON soubor.

Zaklad, aby se nepsalo osmkrat totez:
- src/data/store/: jedno rozhrani EntityStore, dve implementace (local se
  souborem nebo pameti, postgres nad tabulkou records). Vyber je na jednom
  miste v store/index.ts
- src/data/store/cached.ts: synchronni kopie v pameti pro entity ctene pri
  kazdem requestu (uzivatel v autorizaci, firmy pri vypoctu prav). Bez toho by
  se autorizacni middleware musel predelat na async
- src/routes/crud.ts: fabrika na CRUD routy. Kazda entita by jinak znamenala
  stejnych sto radku a sedmkrat by se opravila spatne
- src/data/bootstrap.ts: jedno misto, kde je seznam entit a jejich vychozi sady
- migrace 002_records.sql: jedna tabulka s JSONB. Tvary se jeste hybou a nikdo
  se nad nimi nedotazuje po polich. Az se to usadi, entita se povysi na vlastni
  tabulku, presne jako uz maji konektory

Prava a role (bod 5):
- role jsou zaznamy, pravo je retezec, katalog prav je zdroj pravdy. Union
  'admin' | 'agent' na ucetni a skladnika nestacil a pridavat hodnoty je slepa
  ulicka, kazdy klient chce jine
- Membership.roleIds misto role. Systemove role admin a agent zustavaji, takze
  se zadny ucet nemusel predelavat
- accessFor vraci prava i zalozky. Klient si nic nedovozuje

Zalozky a limity za firmu (bod 6):
- TenantFeatures: moduly, limity, zpristupnene sluzby. Dve vrstvy s jinym
  vlastnikem, ktere se nesmi michat: co ma firma zaplacene nastavujeme my,
  kdo z jejich lidi to smi nastavuje jejich admin. Efektivni viditelnost je
  prunik, takze vypnuty modul neexistuje ani pro admina te firmy
- navigace ze serveru, ne konstanta na klientovi

Firmy, uzivatele, resitele a skupiny: CRUD vcetne clenstvi a hesel. Heslo se
z API nikdy nevraci, ani jako hash. Skupiny resitelu kvuli tomu, ze prehazovat
praci na jmeno nestaci - clovek chce rict "tohle je pro ucetni".

Typy ticketu a akce (body 1, 2, 3):
- typ ticketu s vlastnimi polemi, plus tagy. Akce se vazou na typ nebo tag,
  ale za tagem nestoji zadna pole, takze akce na tagu umi jen vestavena pole
- Ticket dostal typeId, fields, tags a assigneeGroupId
- definice akce s telem jako operace, strom nebo skript. Pravo vznika spolu
  s akci jako action:<id>, admin pak zaskrtava akce, ne prava
- CTA na ticketu filtruje server podle typu, tagu, podminek a prav. Kdyby to
  pocital klient, pocitalo by se to na dvou mistech
- vestavene akce (typ, tagy, skupina) jdou pres tutez fabriku, takze maji
  svoje pravo a projdou auditem
- spusteni zapise do logu ticketu hned, jeste nez se neco stane

Vlastni widgety (bod 4):
- rozdeleni na render a source, groupBy, filtr je tentyz, ktery umi seznam
  ticketu. Uzivatel nepise dotazy
- jeden batch endpoint na cely prehled. Widget, ktery selze, vraci chybu na sve
  pozici a nezhasne prehled

Audit a impersonace (bod 6c):
- audit zapisuje i odepreni, jinak by pokusy o cizi firmu nikde nezustaly
- impersonace: jen spravce platformy, nikdy na jineho spravce platformy,
  30 minut bez obnoveni, vychozi jen cteni. Zapis pod rezimem cteni vraci 403
  na urovni middleware, ne az v handleru

Overeno bez databaze: vsech devet ulozist se nacte, role se zaloz1 a prezije
restart, agent dostane 403 na spravu roli a uzsi navigaci, neznama prava se
odmitnou, heslo se nevraci, typ a tagy ticketu se ulozi, CTA se objevi, widget
data pocitaji vcetne seskupeni, impersonace odmitne zapis i prepnuti na admina,
audit obsahuje actedBy. Degradace pri nedostupne databazi taky overena: migrace
selzou, jede se do souboru a rekne se proc.

Neovereno: migrace 002_records.sql proti zive databazi. Kontejner uz nebyl
k dispozici, generickou vrstvu drzi tentyz pool a migrator jako konektory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 07:06:38 +02:00
co-authored by Claude Opus 5
parent 6e3d0640ff
commit e7cf499a0b
17 changed files with 2307 additions and 156 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* Platformni sprava: prepnuti na jiny ucet a audit.
*
* Impersonace je citliva vec, proto ma pevna pravidla:
* - smi jen spravce platformy a **nikdy na jineho spravce platformy**,
* - token plati kratce a neda se obnovit,
* - **vychozi rezim je jen cteni**, zapis se musi vyslovne zapnout,
* - start, konec i kazdy zapis jde do auditu,
* - v portalu je po celou dobu vyrazny pruh. Bez nej clovek zapomene
* a smaze neco cizim jmenem.
*/
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { config } from '../config.js';
import { listAudit, recordAudit } from '../data/audit.js';
import { listAllUsers, findUserById } from '../data/users.js';
import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js';
import { readScope } from './crud.js';
export const adminRouter = Router();
adminRouter.use(requireAuth);
/** Kratka platnost je zamer. Impersonace neni rezim, ve kterem se pracuje. */
const IMPERSONATION_MINUTES = 30;
const startSchema = z.object({
userId: z.string().min(1),
/**
* true = smi i zapisovat. Vyslovne, protoze vychozi je jen cteni:
* omylem smazany zaznam cizim jmenem se sponta nevrati.
*/
allowWrites: z.boolean().default(false),
reason: z.string().trim().max(300).optional(),
});
adminRouter.post('/impersonate', requirePlatformAdmin, (req, res) => {
const parsed = startSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Vyberte uživatele.' });
}
const target = findUserById(parsed.data.userId);
if (!target) {
return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
}
if (target.id === req.user!.id) {
return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' });
}
// Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus.
if (target.platformAdmin) {
return res.status(403).json({
error: 'forbidden',
message: 'Na jiného správce platformy se přepnout nelze.',
});
}
const token = jwt.sign(
{
sub: target.id,
email: target.email,
// `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl.
act: req.user!.id,
actEmail: req.user!.email,
writes: parsed.data.allowWrites,
},
config.jwtSecret,
{ expiresIn: `${IMPERSONATION_MINUTES}m` },
);
recordAudit({
userId: target.id,
userEmail: target.email,
actedBy: req.user!.id,
tenantId: null,
action: 'impersonate.start',
target: target.id,
detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null },
});
console.warn(
`[admin] ${req.user!.email} se prepina na ${target.email}` +
`${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`,
);
return res.json({
token,
expiresInMinutes: IMPERSONATION_MINUTES,
user: { id: target.id, name: target.name, email: target.email },
allowWrites: parsed.data.allowWrites,
});
});
/**
* Konec impersonace.
*
* Server token nezneplatnuje - JWT je bezstavovy a drzet seznam odvolanych by
* znamenalo stav, ktery tu jinak neni. Klient se vrati ke svemu tokenu, ktery
* si nechal. Token impersonace stejne vyprsi za pul hodiny.
*/
adminRouter.post('/impersonate/stop', (req, res) => {
if (req.impersonation) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
actedBy: req.impersonation.actorId,
tenantId: null,
action: 'impersonate.stop',
target: req.user!.id,
});
}
return res.status(204).end();
});
/** Koho lze prepnout. Spravci platformy se nenabizeji. */
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => {
res.json({
items: listAllUsers()
.filter((user) => !user.platformAdmin && user.enabled)
.map((user) => ({ id: user.id, name: user.name, email: user.email })),
});
});
// ------------------------------------------------------------------- audit
adminRouter.get('/audit', (req, res) => {
// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log.
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Audit vidí správce platformy.' });
}
const limit = Number(req.query.limit ?? 200);
return void listAudit({
...readScope(req),
action: typeof req.query.action === 'string' ? req.query.action : undefined,
result: req.query.result === 'denied' ? 'denied' : undefined,
limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200,
})
.then((items) => res.json({ items }))
.catch((err: unknown) => {
console.error('[admin] audit se nepodarilo precist:', err);
res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' });
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Fabrika na CRUD routy nad obecnym ulozistem.
*
* Kazda nova entita by jinak znamenala stejnych sto radku: vyresit firmu,
* zvalidovat telo, vratit 404 na cizi zaznam, prevest na verzi pro klienta.
* Napsat to osmkrat znamena, ze se to sedmkrat opravi spatne.
*
* Pouziti:
* ```ts
* crudRouter({
* store: ticketTypes,
* createSchema, updateSchema,
* build: (input, tenantId) => ({ ...input, key: slug(input.name) }),
* })
* ```
*
* Co fabrika drzi za pravidla:
* - filtr na firmu je vzdy povinny, cizi zaznam je 404 a ne 403,
* - zapis vyzaduje pravo, cteni staci prihlaseni,
* - platformni zaznamy (`tenantId: null`) smi menit jen spravce platformy.
*/
import { randomUUID } from 'node:crypto';
import { Router, type Request, type Response } from 'express';
import type { z } from 'zod';
import { accessFor } from '../data/access.js';
import { hasPermission } from '../data/permissions.js';
import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js';
/** Firma, ve ktere se prave pracuje. Zapis je vzdy do jedne. */
export function currentTenant(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[crud] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */
export function readScope(req: Request, includeGlobal = true): ListOptions {
const access = accessFor(req.user!);
return { tenantIds: access.tenants.map((tenant) => tenant.id), includeGlobal };
}
export interface CrudOptions<T extends TenantEntity, C, U> {
store: EntityStore<T>;
/** Prefix ID, napr. `tt` pro typ ticketu. */
idPrefix: string;
/**
* Schemata maji volny vstupni typ (`unknown`). Duvod: `.default()` a
* `.transform()` posouvaji vstup jinam nez vystup, a `z.ZodType<C>` by pak
* na kazde schema s vychozi hodnotou nesedelo.
*/
createSchema: z.ZodType<C, z.ZodTypeDef, unknown>;
updateSchema: z.ZodType<U, z.ZodTypeDef, unknown>;
/** Pravo potrebne k zapisu. Cteni staci prihlaseni. */
writePermission: string;
/** Sestavi novy zaznam z overeneho vstupu. */
build: (input: C, tenantId: string) => Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
/** Vlastni kontrola nad ulozenym zaznamem. Vraci popisy problemu. */
validate?: (entity: T, all: T[]) => string[];
/** Uprava pred odeslanim klientovi, napr. schovani citlivych poli. */
toPublic?: (entity: T) => unknown;
/** true = zaznamy vidi jen spravce platformy. */
platformOnly?: boolean;
}
export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T, C, U>): Router {
const router = Router();
const publish = options.toPublic ?? ((entity: T) => entity);
/** Smi uzivatel zapisovat? Jedno misto, aby se to nekontrolovalo jen nekde. */
function canWrite(req: Request, res: Response, entity?: T): boolean {
if (options.platformOnly && !req.user!.platformAdmin) {
res.status(403).json({ error: 'forbidden', message: 'Tohle nastavuje správce platformy.' });
return false;
}
// Platformni zaznam smi menit jen spravce platformy, i kdyz pravo jinak ma.
if (entity && entity.tenantId === null && !req.user!.platformAdmin) {
res.status(403).json({
error: 'forbidden',
message: 'Tenhle záznam je systémový a mění ho jen správce platformy.',
});
return false;
}
if (!hasPermission(req.user!, options.writePermission)) {
console.warn(
`[crud] ${req.user!.email}: chybi pravo ${options.writePermission} u ${options.store.kind}`,
);
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
return false;
}
return true;
}
router.get('/', async (req, res) => {
if (options.platformOnly && !req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
}
const items = await options.store.list(readScope(req));
return res.json({ items: items.map(publish) });
});
router.get('/:id', async (req, res) => {
const entity = await options.store.get(req.params.id, readScope(req));
if (!entity) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
return res.json(publish(entity));
});
router.post('/', async (req, res) => {
if (!canWrite(req, res)) return;
const tenantId = currentTenant(req, res);
if (!tenantId) return;
const parsed = options.createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
const timestamp = nowIso();
const entity = {
...options.build(parsed.data, tenantId),
id: `${options.idPrefix}_${randomUUID().slice(0, 8)}`,
tenantId,
createdAt: timestamp,
updatedAt: timestamp,
} as T;
if (options.validate) {
const problems = options.validate(entity, await options.store.list(readScope(req)));
if (problems.length > 0) {
return res.status(400).json({ error: 'validation_error', message: problems[0] });
}
}
const created = await options.store.create(entity);
return res.status(201).json(publish(created));
});
router.patch('/:id', async (req, res) => {
const existing = await options.store.get(req.params.id, readScope(req));
if (!existing) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
if (!canWrite(req, res, existing)) return;
const parsed = options.updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (options.validate) {
const merged = { ...existing, ...(parsed.data as Partial<T>) } as T;
const others = (await options.store.list(readScope(req))).filter(
(item) => item.id !== existing.id,
);
const problems = options.validate(merged, others);
if (problems.length > 0) {
return res.status(400).json({ error: 'validation_error', message: problems[0] });
}
}
const updated = await options.store.update(
req.params.id,
parsed.data as Partial<T>,
readScope(req),
);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
return res.json(publish(updated));
});
router.delete('/:id', async (req, res) => {
const existing = await options.store.get(req.params.id, readScope(req));
if (!existing) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
if (!canWrite(req, res, existing)) return;
const removed = await options.store.remove(req.params.id, readScope(req));
return removed
? res.status(204).end()
: res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
});
return router;
}
+47 -4
View File
@@ -29,6 +29,7 @@ import {
serviceCatalog,
serviceCategories,
visibleServices,
withRuntimeOptions,
} from '../data/services.js';
import {
getLayout,
@@ -39,9 +40,10 @@ import {
} from '../data/dashboardLayouts.js';
import { collectScopes } from '../data/flowScope.js';
import { widgets } from '../data/widgets.js';
import { listCustomWidgets, sizesFor } from '../data/customWidgets.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, listPeople } from '../data/people.js';
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
import {
addComment,
assignTicket,
@@ -56,6 +58,9 @@ import {
import { requireAuth } from '../middleware/auth.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { settingsRouter } from './settings.js';
import { ticketActionsRouter } from './ticketActions.js';
import { widgetDataRouter } from './widgetData.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
@@ -108,8 +113,30 @@ dashboardRouter.get('/incidents', (_req, res) => {
// ------------------------------------------------------- rozlozeni dashboardu
dashboardRouter.get('/widgets', (_req, res) => {
res.json({ items: widgets });
/**
* Katalog widgetu: pevne z kodu plus vlastni firmy.
*
* Vlastni widget je pro klienta tentyz tvar jako pevny, jen `custom: true`.
* Diky tomu se rozlozeni dashboardu nemuselo menit.
*/
dashboardRouter.get('/widgets', (req, res) => {
const access = accessFor(req.user!);
const tenantIds = access.tenants.map((tenant) => tenant.id);
const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({
id: widget.id,
name: widget.name,
description: widget.description,
kind: widget.render,
sizes: sizesFor(widget.render),
defaultSize: widget.size,
custom: true,
render: widget.render,
source: widget.source,
...(widget.target !== undefined ? { target: widget.target } : {}),
}));
res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] });
});
/**
@@ -367,6 +394,15 @@ dashboardRouter.use('/scripts', scriptsRouter);
// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru.
dashboardRouter.use('/connectors', connectorsRouter);
// Nastaveni vsech entit. Cele stoji na fabrice crudRouter.
dashboardRouter.use('/settings', settingsRouter);
// Data pro vlastni widgety. Jeden request na cely prehled.
dashboardRouter.use('/widget-data', widgetDataRouter);
// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`.
dashboardRouter.use('/tickets', ticketActionsRouter);
// ------------------------------------------------------------------- sluzby
/**
@@ -384,10 +420,17 @@ dashboardRouter.get('/services', (req, res) => {
const tenantId = requested ?? access.defaultTenantId;
const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id));
const tenantIds = tenantId ? [tenantId] : [];
res.json({
categories: serviceCategories,
items: serviceCatalog().filter((service) => visible.has(service.id)),
items: withRuntimeOptions(
serviceCatalog().filter((service) => visible.has(service.id)),
{
people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })),
groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })),
},
),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
+658
View File
@@ -0,0 +1,658 @@
/**
* Nastaveni: firmy, uzivatele, role, resitele, skupiny, zalozky, typy ticketu,
* akce a vlastni widgety.
*
* Vsechno stoji na `crudRouter`, takze se tady pise jen to, co je u dane entity
* jine: schema vstupu, jak se z nej sestavi zaznam a co se ma overit. Zbytek
* (firma, 404 na cizi zaznam, prava, chybove tvary) je v te fabrice.
*
* Po kazdem zapisu se obnovi kopie v pameti (`refreshCaches`). Bez toho by
* uzivatel ulozil roli a prava by se zmenila az po restartu.
*/
import { Router } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import { bootstrapDataRefresh } from '../data/refresh.js';
import {
customWidgetStore,
listCustomWidgets,
sizesFor,
validateWidget,
type CustomWidget,
} from '../data/customWidgets.js';
import { groupStore, listAllPeople, personStore, type Person } from '../data/people.js';
import {
allPermissions,
roleStore,
rolesFor,
type Role,
} from '../data/permissions.js';
import {
featuresStore,
moduleCatalog,
defaultLimits,
defaultModules,
featuresOf,
type TenantFeatures,
} from '../data/tenantFeatures.js';
import { listTenants, tenantStore, type Tenant } from '../data/tenants.js';
import { listTicketTypes, ticketTypeStore, type TicketType } from '../data/ticketTypes.js';
import {
actionStore,
listActions,
validateAction,
type TicketAction,
} from '../data/ticketActions.js';
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
import { requireAuth } from '../middleware/auth.js';
import { crudRouter, currentTenant, readScope } from './crud.js';
export const settingsRouter = Router();
settingsRouter.use(requireAuth);
/** Zapis do jakekoliv entity muze zmenit prava nebo nabidky, proto obnova. */
settingsRouter.use((req, res, next) => {
if (req.method === 'GET') return next();
res.on('finish', () => {
if (res.statusCode < 400) void bootstrapDataRefresh();
});
return next();
});
// ------------------------------------------------------------------ katalogy
/** Co lze nastavit. Klient z toho kresli zaskrtavatka, nehada si vlastni seznam. */
settingsRouter.get('/catalog', (req, res) => {
res.json({
permissions: allPermissions(),
modules: moduleCatalog,
defaultLimits,
widgetRenders: (['stat', 'chart', 'list', 'table', 'gauge'] as const).map((render) => ({
render,
sizes: sizesFor(render),
})),
platformAdmin: req.user!.platformAdmin,
});
});
// -------------------------------------------------------------------- firmy
const tenantCreate = z.object({
name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80),
note: z.string().trim().max(500).optional(),
});
settingsRouter.use(
'/tenants',
crudRouter<Tenant, z.infer<typeof tenantCreate>, Partial<Tenant>>({
store: tenantStore,
idPrefix: 'tnt',
createSchema: tenantCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
note: z.string().trim().max(500).optional(),
enabled: z.boolean().optional(),
}),
writePermission: 'tenant.manage',
platformOnly: true,
// Firma nepatri jine firme, proto tenantId null.
build: (input) => ({ name: input.name, note: input.note ?? '', enabled: true, tenantId: null }),
validate: (tenant, all) =>
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
? [`Firma ${tenant.name} už existuje.`]
: [],
}),
);
// ---------------------------------------------------------------- uzivatele
const membershipSchema = z.object({
tenantId: z.string().min(1),
roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'),
});
const userCreate = z.object({
email: z.string().trim().email('Zadejte platný e-mail.'),
name: z.string().trim().min(2).max(80),
password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'),
platformAdmin: z.boolean().optional(),
memberships: z.array(membershipSchema).default([]),
});
/** Heslo se z API nikdy nevraci, ani jako hash. */
function publicUser(user: StoredUser) {
const { passwordHash: _passwordHash, ...rest } = user;
return rest;
}
settingsRouter.use(
'/users',
crudRouter<StoredUser, z.infer<typeof userCreate>, Partial<StoredUser>>({
store: userStore,
idPrefix: 'usr',
createSchema: userCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().optional(),
password: z.string().min(8).optional(),
platformAdmin: z.boolean().optional(),
memberships: z.array(membershipSchema).optional(),
enabled: z.boolean().optional(),
}),
writePermission: 'user.manage',
build: (input) => ({
email: input.email.toLowerCase(),
name: input.name,
passwordHash: hashPassword(input.password),
platformAdmin: input.platformAdmin ?? false,
memberships: input.memberships,
enabled: true,
// Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz.
tenantId: null,
}),
toPublic: publicUser,
validate: (user, all) => {
const problems: string[] = [];
if (all.some((other) => other.email.toLowerCase() === user.email.toLowerCase())) {
problems.push(`E-mail ${user.email} už někdo má.`);
}
for (const membership of user.memberships) {
if (!listTenants().some((tenant) => tenant.id === membership.tenantId)) {
problems.push(`Firma ${membership.tenantId} neexistuje.`);
}
}
return problems;
},
}),
);
/**
* Zmena hesla musi projit hashovanim.
* Bez teto vetve by `PATCH` ulozil plaintext do pole `password`, ktere nikdo
* nikdy neprecte, a heslo by se nezmenilo.
*/
settingsRouter.patch('/users/:id/password', async (req, res) => {
const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Heslo musí mít aspoň 8 znaků.' });
}
const target = await userStore.get(req.params.id, readScope(req));
if (!target) return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
// Svoje heslo si smi zmenit kazdy, cizi jen kdo spravuje uzivatele.
const own = target.id === req.user!.id;
if (!own && !req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Cizí heslo měnit nemůžete.' });
}
await userStore.update(
req.params.id,
{ passwordHash: hashPassword(parsed.data.password) } as Partial<StoredUser>,
readScope(req),
);
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: null,
action: 'user.password',
target: target.id,
});
return res.status(204).end();
});
/** Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. */
settingsRouter.get('/users-overview', (req, res) => {
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
}
return res.json({ items: listAllUsers().map(publicUser) });
});
// ---------------------------------------------------------------------- role
const roleCreate = z.object({
name: z.string().trim().min(2).max(60),
description: z.string().trim().max(300).optional(),
permissions: z.array(z.string()).default([]),
});
settingsRouter.use(
'/roles',
crudRouter<Role, z.infer<typeof roleCreate>, Partial<Role>>({
store: roleStore,
idPrefix: 'role',
createSchema: roleCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
description: z.string().trim().max(300).optional(),
permissions: z.array(z.string()).optional(),
}),
writePermission: 'role.manage',
build: (input, tenantId) => ({
key: `role_${tenantId}_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`,
name: input.name,
description: input.description ?? '',
permissions: input.permissions,
system: false,
}),
validate: (role) => {
const known = new Set(allPermissions().map((item) => item.key));
const unknown = role.permissions.filter((permission) => !known.has(permission));
// Neznamé právo je chyba, ne varovani: role by tise nedelala, co se ceka.
return unknown.length > 0 ? [`Neznámá práva: ${unknown.join(', ')}`] : [];
},
}),
);
/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */
settingsRouter.get('/roles-available', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({ items: rolesFor(tenantId) });
});
// ------------------------------------------------------------------ resitele
const personCreate = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email(),
role: z.string().trim().max(60).optional(),
capacity: z.number().int().min(1).max(200).optional(),
});
settingsRouter.use(
'/people',
crudRouter<Person, z.infer<typeof personCreate>, Partial<Person>>({
store: personStore,
idPrefix: 'ppl',
createSchema: personCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().optional(),
role: z.string().trim().max(60).optional(),
capacity: z.number().int().min(1).max(200).optional(),
enabled: z.boolean().optional(),
}),
writePermission: 'people.manage',
build: (input) => ({
name: input.name,
email: input.email.toLowerCase(),
role: input.role ?? '',
capacity: input.capacity ?? 8,
enabled: true,
}),
validate: (person, all) =>
all.some((other) => other.email.toLowerCase() === person.email.toLowerCase())
? [`Řešitel s e-mailem ${person.email} už v této firmě je.`]
: [],
}),
);
/** Vcetne vypnutych. Pro spravu tymu. */
settingsRouter.get('/people-overview', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({ items: listAllPeople([tenantId]) });
});
// ------------------------------------------------------------------- skupiny
const groupCreate = z.object({
name: z.string().trim().min(2).max(60),
personIds: z.array(z.string()).default([]),
});
settingsRouter.use(
'/groups',
crudRouter({
store: groupStore,
idPrefix: 'grp',
createSchema: groupCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
personIds: z.array(z.string()).optional(),
}),
writePermission: 'group.manage',
build: (input: z.infer<typeof groupCreate>) => ({
name: input.name,
personIds: input.personIds,
}),
}),
);
// ------------------------------------------------------- zalozky a limity firmy
const featuresUpdate = z.object({
modules: z.array(z.string()).optional(),
limits: z
.object({
automations: z.number().int().min(0).max(10_000),
widgets: z.number().int().min(0).max(200),
actions: z.number().int().min(0).max(1_000),
connectors: z.number().int().min(0).max(500),
})
.optional(),
serviceIds: z.array(z.string()).optional(),
});
/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */
settingsRouter.get('/features', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({ tenantId, features: featuresOf(tenantId) });
});
settingsRouter.put('/features', async (req, res) => {
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Záložky nastavuje správce platformy.' });
}
const tenantId = currentTenant(req, res);
if (!tenantId) return;
const parsed = featuresUpdate.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
const known = new Set(moduleCatalog.map((module) => module.key));
const unknown = (parsed.data.modules ?? []).filter((key) => !known.has(key as never));
if (unknown.length > 0) {
return res.status(400).json({
error: 'validation_error',
message: `Neznámé moduly: ${unknown.join(', ')}`,
});
}
// Povinne moduly se doplni vzdy. Bez prehledu a nastaveni nema portal kde zacit.
const required = moduleCatalog.filter((module) => module.required).map((module) => module.key);
const modules = [...new Set([...(parsed.data.modules ?? defaultModules()), ...required])];
const existing = (await featuresStore.list({ tenantIds: [tenantId] })).find(
(item) => item.tenantId === tenantId,
);
const payload: Partial<TenantFeatures> = {
modules: modules as TenantFeatures['modules'],
limits: parsed.data.limits ?? featuresOf(tenantId).limits,
serviceIds: parsed.data.serviceIds ?? featuresOf(tenantId).serviceIds,
};
const saved = existing
? await featuresStore.update(existing.id, payload, { tenantIds: [tenantId] })
: await featuresStore.create({
id: `feat_${tenantId}`,
tenantId,
modules: payload.modules!,
limits: payload.limits!,
serviceIds: payload.serviceIds!,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'tenant.features',
target: tenantId,
detail: { modules },
});
return res.json({ tenantId, features: saved });
});
// -------------------------------------------------------------- typy ticketu
const fieldSchema = z.object({
id: z.string().min(1).optional(),
key: z
.string()
.trim()
.regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Klíč: písmena, číslice a _, začíná písmenem.'),
label: z.string().trim().min(1),
type: z.enum(['string', 'number', 'boolean', 'date']),
required: z.boolean(),
hint: z.string().trim().max(200).optional(),
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
});
const typeCreate = z.object({
key: z
.string()
.trim()
.regex(/^[a-z][a-z0-9-]*$/, 'Klíč typu: malá písmena, číslice a pomlčky.'),
name: z.string().trim().min(2).max(60),
icon: z.string().trim().max(40).optional(),
statuses: z.array(z.string().trim().min(1)).default([]),
fields: z.array(fieldSchema).default([]),
});
/** ID poli musi byt stabilni, odkazuji se na nej podminky. Chybejici dogeneruje. */
function withFieldIds(fields: z.infer<typeof fieldSchema>[]): TicketType['fields'] {
return fields.map((field, index) => ({
id: field.id ?? `fld_${field.key}_${index}`,
key: field.key,
label: field.label,
type: field.type,
required: field.required,
...(field.hint ? { hint: field.hint } : {}),
...(field.options ? { options: field.options } : {}),
}));
}
settingsRouter.use(
'/ticket-types',
crudRouter<TicketType, z.infer<typeof typeCreate>, Partial<TicketType>>({
store: ticketTypeStore,
idPrefix: 'tt',
createSchema: typeCreate,
// Transformace dogeneruje ID poli. Bez ni by pole prislo bez `id`
// a podminky v akcich by se nemely na co odkazat.
updateSchema: z
.object({
name: z.string().trim().min(2).max(60).optional(),
icon: z.string().trim().max(40).optional(),
statuses: z.array(z.string().trim().min(1)).optional(),
fields: z.array(fieldSchema).optional(),
})
.transform(({ fields, ...rest }): Partial<TicketType> => ({
...rest,
...(fields ? { fields: withFieldIds(fields) } : {}),
})),
writePermission: 'ticketType.manage',
build: (input) => ({
key: input.key,
name: input.name,
icon: input.icon ?? 'LifeBuoy',
statuses: input.statuses,
fields: withFieldIds(input.fields),
}),
validate: (type, all) => {
const problems: string[] = [];
if (all.some((other) => other.key === type.key)) {
problems.push(`Typ s klíčem ${type.key} už v této firmě je.`);
}
const keys = new Set<string>();
for (const field of type.fields) {
if (keys.has(field.key)) problems.push(`Pole ${field.key} je uvedené dvakrát.`);
keys.add(field.key);
}
return problems;
},
}),
);
// -------------------------------------------------------------------- akce
const conditionSchema = z.object({
fieldId: z.string().min(1),
operator: z.enum([
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
'contains', 'startsWith', 'isEmpty', 'isNotEmpty', 'isTrue', 'isFalse',
]),
value: z.string().optional(),
});
/** Telo akce. Vysledek se pretypuje na `ActionBody`, viz komentar u kroku. */
const bodySchema: z.ZodType<TicketAction['body'], z.ZodTypeDef, unknown> = z
.discriminatedUnion('kind', [
z.object({
kind: z.literal('operation'),
serviceId: z.string().min(1),
operationId: z.string().min(1),
connectorId: z.string().min(1).nullable().default(null),
inputs: z.record(z.string()).default({}),
}),
// Kroky se tady netypuji: jejich schema je u automatizaci a duplikovat ho
// sem by znamenalo dve definice, ze kterych se jedna casem rozejde.
z.object({
kind: z.literal('tree'),
steps: z.array(z.record(z.unknown())).default([]),
}),
z.object({
kind: z.literal('script'),
scriptId: z.string().min(1),
inputs: z.record(z.string()).default({}),
}),
])
.transform((body) => body as TicketAction['body']);
const actionCreate = z.object({
label: z.string().trim().min(2).max(60),
icon: z.string().trim().max(40).optional(),
style: z.enum(['primary', 'default', 'danger']).default('default'),
order: z.number().int().min(0).max(999).default(10),
ticketTypeIds: z.array(z.string()).default([]),
tags: z.array(z.string().trim().min(1)).default([]),
visibleWhen: z.array(conditionSchema).default([]),
confirm: z.string().trim().max(300).nullable().default(null),
form: z
.array(
z.object({
id: z.string().min(1),
label: z.string().trim().min(1),
kind: z.enum(['text', 'longtext', 'choice']),
required: z.boolean(),
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
hint: z.string().optional(),
}),
)
.default([]),
body: bodySchema,
});
settingsRouter.use(
'/actions',
crudRouter<TicketAction, z.infer<typeof actionCreate>, Partial<TicketAction>>({
store: actionStore,
idPrefix: 'tka',
createSchema: actionCreate,
updateSchema: z.object({
label: z.string().trim().min(2).max(60).optional(),
icon: z.string().trim().max(40).optional(),
style: z.enum(['primary', 'default', 'danger']).optional(),
order: z.number().int().min(0).max(999).optional(),
ticketTypeIds: z.array(z.string()).optional(),
tags: z.array(z.string().trim().min(1)).optional(),
visibleWhen: z.array(conditionSchema).optional(),
confirm: z.string().trim().max(300).nullable().optional(),
body: bodySchema.optional(),
enabled: z.boolean().optional(),
}),
writePermission: 'action.manage',
build: (input) => ({
label: input.label,
icon: input.icon ?? 'Play',
style: input.style,
order: input.order,
ticketTypeIds: input.ticketTypeIds,
tags: input.tags,
visibleWhen: input.visibleWhen,
confirm: input.confirm,
form: input.form,
body: input.body,
enabled: true,
}),
validate: (action) => validateAction(action, listTicketTypes([action.tenantId])),
}),
);
/** Akce firmy vcetne vypnutych. Pro spravu. */
settingsRouter.get('/actions-overview', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({ items: listActions([tenantId]) });
});
// -------------------------------------------------------------- vlastni widgety
const sourceSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('ticketCount'),
filter: z.record(z.unknown()).default({}),
groupBy: z.enum(['assignee', 'group', 'status', 'type', 'tag', 'channel']).optional(),
}),
z.object({
kind: z.literal('ticketList'),
filter: z.record(z.unknown()).default({}),
limit: z.number().int().min(1).max(50).default(5),
}),
z.object({
kind: z.literal('ticketSeries'),
filter: z.record(z.unknown()).default({}),
bucket: z.enum(['day', 'week']).default('day'),
}),
z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }),
]);
const widgetCreate = z.object({
name: z.string().trim().min(2).max(60),
description: z.string().trim().max(200).optional(),
render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']),
source: sourceSchema,
size: z.enum(['third', 'half', 'full']),
target: z.number().optional(),
/** true = widget jen pro me, jinak pro celou firmu. */
personal: z.boolean().default(false),
});
settingsRouter.use(
'/widgets',
crudRouter<CustomWidget, z.infer<typeof widgetCreate>, Partial<CustomWidget>>({
store: customWidgetStore,
idPrefix: 'cw',
createSchema: widgetCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
description: z.string().trim().max(200).optional(),
render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']).optional(),
source: sourceSchema.optional(),
size: z.enum(['third', 'half', 'full']).optional(),
target: z.number().optional(),
}),
writePermission: 'widget.manage',
build: (input) => ({
name: input.name,
description: input.description ?? '',
render: input.render,
source: input.source as CustomWidget['source'],
size: input.size,
...(input.target !== undefined ? { target: input.target } : {}),
// Osobni widget vidi jen jeho autor. Vyplni se az v route, viz nize.
ownerId: null,
}),
validate: (widget) => validateWidget(widget),
}),
);
/** Widgety firmy plus osobni prihlaseneho. */
settingsRouter.get('/widgets-overview', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return res.json({ items: listCustomWidgets([tenantId], req.user!.id) });
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Akce na ticketu: co se nabizi a spusteni.
*
* Seznam **filtruje server**, ne klient. Je to totez pravidlo jako u `access`:
* kdyby si klient pocital, ktera tlacitka ukazat, pocitalo by se to na dvou
* mistech a jednou se to rozejde.
*
* Spusteni zapise zaznam do logu ticketu **hned**, jeste nez se neco stane.
* Log ticketu uz je strom a lide do nej chodi - zvlastni evidence "kdo co
* zmackl" by znamenala dve casove osy a hledani ve dvou mistech.
*/
import { Router } from 'express';
import { z } from 'zod';
import { isDenied, resolveScope } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { findGroup } from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
import { renderTemplate } from '../data/templates.js';
import {
appendTrace,
assignTicketGroup,
getTicket,
setTicketTags,
setTicketType,
type Ticket,
} from '../data/ticketStore.js';
import {
actionPermission,
actionsForTicket,
conditionsPass,
findAction,
type TicketAction,
type TicketFacts,
} from '../data/ticketActions.js';
import { findTicketType, validateTicketFields } from '../data/ticketTypes.js';
import { runScript } from '../scripts/runner.js';
import { scriptIdFor } from '../scripts/lookup.js';
export const ticketActionsRouter = Router();
/**
* Hodnoty, proti kterym se vyhodnocuji podminky a sablony.
*
* Vestavena pole ticketu maji prefix `ticket.`, protoze na ne odkazuji
* podminky. Vlastni pole typu jsou navic pod svym `key`, aby se do sablony
* pisalo `{{orderNumber}}` a ne `{{ticket.fld_order_no}}`.
*/
function factsOf(ticket: Ticket): TicketFacts {
const facts: TicketFacts = {
'ticket.id': ticket.id,
'ticket.subject': ticket.subject,
'ticket.body': ticket.body,
'ticket.status': ticket.status,
'ticket.priority': ticket.priority,
'ticket.company': ticket.customer.company,
'ticket.contact': ticket.customer.contact,
'ticket.reply': ticket.customer.reply,
};
const type = ticket.typeId ? findTicketType(ticket.typeId) : undefined;
for (const field of type?.fields ?? []) {
const value = ticket.fields[field.key] ?? null;
facts[`ticket.${field.id}`] = value;
facts[field.key] = value;
}
return facts;
}
/** Sablony v nastaveni akce se dosazuji z ticketu a z doptavaciho formulare. */
function fillInputs(
inputs: Record<string, string>,
facts: TicketFacts,
form: Record<string, string>,
): Record<string, string> {
const values: Record<string, string> = {};
for (const [key, value] of Object.entries(facts)) {
values[key] = value === null ? '' : String(value);
}
for (const [key, value] of Object.entries(form)) values[key] = value;
const result: Record<string, string> = {};
for (const [key, template] of Object.entries(inputs)) {
result[key] = renderTemplate(template, values);
}
return result;
}
/** Verze pro klienta. Telo akce se nevraci, klient ho k nicemu nepotrebuje. */
function publicAction(action: TicketAction) {
return {
id: action.id,
label: action.label,
icon: action.icon,
style: action.style,
confirm: action.confirm,
form: action.form,
};
}
/** Akce, ktere na ticket sedi. Uz vyfiltrovane podle typu, tagu, podminek a prav. */
ticketActionsRouter.get('/:id/actions', async (req, res) => {
const scope = resolveScope(req.user!, {
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const actions = actionsForTicket(
[ticket.tenantId],
{ typeId: ticket.typeId, tags: ticket.tags },
factsOf(ticket),
(permission) => hasPermission(req.user!, permission),
);
return res.json({ items: actions.map(publicAction) });
});
const runSchema = z.object({
form: z.record(z.string()).default({}),
});
/**
* Spusteni akce.
*
* Kontroluje se znovu vsechno, co se kontrolovalo pri vypisu. Klient mohl mit
* otevrenou starou stranku, nebo tlacitko vubec nezobrazit a poslat request sam.
*/
ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const action = findAction(req.params.actionId);
if (!action || action.tenantId !== ticket.tenantId || !action.enabled) {
return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' });
}
if (!hasPermission(req.user!, actionPermission(action.id))) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.action.run',
target: `${ticket.id}/${action.id}`,
result: 'denied',
});
return res.status(403).json({ error: 'forbidden', message: 'K této akci nemáte oprávnění.' });
}
const facts = factsOf(ticket);
if (!conditionsPass(action.visibleWhen, facts)) {
// 409, ne 400: vstup je v poradku, jen v tomhle stavu to nedava smysl.
return res.status(409).json({
error: 'conflict',
message: 'V tomhle stavu ticketu akci spustit nelze. Zkuste stránku obnovit.',
});
}
const parsed = runSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatný formulář.' });
}
for (const field of action.form) {
if (field.required && (parsed.data.form[field.id] ?? '').trim() === '') {
return res.status(400).json({
error: 'validation_error',
message: `${field.label} je povinné.`,
});
}
}
// Zaznam do logu hned, jeste nez se neco stane. Kdyz to spadne, je videt,
// ze to nekdo spustil a co se dalo.
appendTrace(ticket.id, [
{
kind: 'action',
status: 'info',
label: `Ručně spustil ${req.user!.name}: ${action.label}`,
},
]);
const result = await runAction(action, facts, parsed.data.form, ticket, req.user!.email);
appendTrace(ticket.id, [
{
kind: 'action',
status: result.ok ? 'ok' : 'error',
label: `${action.label}: ${result.summary}`,
serviceId: action.body.kind === 'operation' ? action.body.serviceId : null,
operationId: action.body.kind === 'operation' ? action.body.operationId : null,
response: result.detail,
durationMs: result.durationMs,
},
]);
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.action.run',
target: `${ticket.id}/${action.id}`,
detail: { ok: result.ok, summary: result.summary },
// `denied` je vyhrazene pro odepreni prava. Neuspesny beh je vysledek,
// ne odepreni - jinak by se v auditu nepoznalo, co bylo co.
result: 'ok',
});
// Chyba akce neni chyba API, je to vysledek. Proto 200 v obou pripadech.
return res.json(result);
});
interface ActionResult {
ok: boolean;
summary: string;
detail: string | null;
durationMs: number;
outputs: Record<string, unknown>;
}
/**
* Vykonani tela akce.
*
* Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu
* a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola
* se odsud totez z workeru.
*/
async function runAction(
action: TicketAction,
facts: TicketFacts,
form: Record<string, string>,
ticket: Ticket,
actorEmail: string,
): Promise<ActionResult> {
const startedAt = Date.now();
const body = action.body;
if (body.kind === 'tree') {
// Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas,
// misto aby se tvarilo, ze se neco stalo.
return {
ok: false,
summary: 'strom akce zatím nejde vykonat, chybí runtime',
detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.',
durationMs: Date.now() - startedAt,
outputs: {},
};
}
const scriptId =
body.kind === 'script' ? body.scriptId : scriptIdFor(body.serviceId, body.operationId);
if (!scriptId) {
return {
ok: false,
summary: 'operace nemá výkonnou část',
detail: `Služba ${body.kind === 'operation' ? body.serviceId : ''} nemá pro tuhle operaci skript, takže se nedá vykonat.`,
durationMs: Date.now() - startedAt,
outputs: {},
};
}
const connector =
body.kind === 'operation'
? body.connectorId
? await getConnector(body.connectorId, [ticket.tenantId])
: await defaultConnectorFor(ticket.tenantId, body.serviceId)
: null;
const inputs = fillInputs(body.inputs, facts, form);
// Klic je stabilni na dvojici ticket a akce, takze dvojklik nevystavi
// druhou fakturu.
const idempotencyKey = `ticket:${ticket.id}:${action.id}`;
const result = await runScript(scriptId, inputs, {
connector: connector ?? null,
idempotencyKey,
});
if (!result.ok) {
console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${result.error?.message}`);
}
return {
ok: result.ok,
summary: result.ok ? 'proběhlo' : (result.error?.message ?? 'selhalo'),
detail: result.error?.detail ?? (result.ok ? JSON.stringify(result.outputs) : null),
durationMs: result.durationMs,
outputs: result.outputs,
};
}
// ------------------------------------------------- vestavene akce na ticketu
/**
* Vestavene akce jsou ve stejnem rezimu jako vlastni: kazda ma svoje pravo
* a projde auditem. Diky tomu jde vestavenou akci nekomu vypnout bez zmeny kodu.
*
* Fabrika, ne tri skoro stejne handlery. Kazdy by jinak resil znovu firmu,
* pravo, 404, audit a udalost.
*/
function builtinAction<T>(options: {
path: string;
permission: string;
auditAction: string;
schema: z.ZodType<T, z.ZodTypeDef, unknown>;
apply: (ticket: Ticket, input: T, tenantIds: string[]) => Ticket | undefined;
}): void {
ticketActionsRouter.post(`/:id/${options.path}`, (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
if (!hasPermission(req.user!, options.permission)) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
actedBy: req.impersonation?.actorId ?? null,
tenantId: ticket.tenantId,
action: options.auditAction,
target: ticket.id,
result: 'denied',
});
return res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
}
const parsed = options.schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
const updated = options.apply(ticket, parsed.data, scope.tenantIds);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
actedBy: req.impersonation?.actorId ?? null,
tenantId: ticket.tenantId,
action: options.auditAction,
target: ticket.id,
detail: parsed.data as Record<string, unknown>,
});
return res.json(updated);
});
}
builtinAction({
path: 'type',
permission: 'ticket.type.change',
auditAction: 'ticket.type',
schema: z.object({
typeId: z.string().min(1).nullable(),
fields: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
}),
apply: (ticket, input, tenantIds) => {
// Typ musi patrit te same firme, jinak by ticket dostal cizi pole.
if (input.typeId) {
const type = findTicketType(input.typeId);
if (!type || type.tenantId !== ticket.tenantId) return undefined;
const problems = validateTicketFields(type, input.fields ?? ticket.fields);
// Nevyplnena povinna pole nejsou duvod typ neprepnout, jen se zaloguji.
if (problems.length > 0) console.info(`[tickets] ${ticket.id}: ${problems.join(' ')}`);
}
return setTicketType(ticket.id, input.typeId, input.fields, tenantIds);
},
});
builtinAction({
path: 'tags',
permission: 'ticket.tag',
auditAction: 'ticket.tags',
schema: z.object({ tags: z.array(z.string().trim().min(1).max(40)).max(20) }),
apply: (ticket, input, tenantIds) => setTicketTags(ticket.id, input.tags, tenantIds),
});
builtinAction({
path: 'group',
permission: 'ticket.assign.group',
auditAction: 'ticket.group',
schema: z.object({ groupId: z.string().min(1).nullable() }),
apply: (ticket, input, tenantIds) => {
if (input.groupId) {
const group = findGroup(input.groupId);
if (!group || group.tenantId !== ticket.tenantId) return undefined;
}
return assignTicketGroup(ticket.id, input.groupId, tenantIds);
},
});
+276
View File
@@ -0,0 +1,276 @@
/**
* Data pro vlastni widgety.
*
* Jeden request na cely prehled. Kdyby si kazda dlazdice nacitala data sama,
* deset dlazdic by znamenalo deset dotazu - puvodni pravidlo "data si nacita
* prehled, ne widgety" tim zustava v platnosti i u vlastnich widgetu.
*
* Widget, ktery selze, vraci chybu **na sve pozici**. Jeden rozbity zdroj nesmi
* zhasnout cely prehled.
*
* Uzivatel nepise dotazy: filtr je tentyz, ktery uz umi seznam ticketu, takze
* nejde poslat nic, co by server polozilo.
*/
import { Router } from 'express';
import { z } from 'zod';
import { isDenied, resolveScope, type ResolvedScope } from '../data/access.js';
import {
findCustomWidget,
type CustomWidget,
type WidgetGroupBy,
type WidgetTicketFilter,
} from '../data/customWidgets.js';
import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js';
import { channelLabels, getWorkload, listTickets, type Ticket } from '../data/ticketStore.js';
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
export const widgetDataRouter = Router();
/**
* Popisky stavu. `ticketStore` je neexportuje, a duplikovat je sem je mensi
* zlo nez rozsirovat verejne rozhrani uloziste kvuli jednomu widgetu.
*/
const statusLabels: Record<string, string> = {
new: 'Nový',
open: 'V řešení',
waiting: 'Čeká na klienta',
resolved: 'Vyřešeno',
};
const requestSchema = z.object({
widgetIds: z.array(z.string().min(1)).max(24),
});
/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */
type WidgetValue =
| { kind: 'number'; value: number }
| { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> }
| { kind: 'series'; points: Array<{ date: string; value: number }> }
| { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> }
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> };
interface WidgetResult {
id: string;
ok: boolean;
value?: WidgetValue;
error?: string;
}
/** Od kdy se pocita. `all` znamena bez omezeni. */
function since(period: WidgetTicketFilter['period']): number | null {
const now = Date.now();
switch (period) {
case 'today':
return new Date(new Date().toDateString()).getTime();
case '7d':
return now - 7 * 86_400_000;
case '30d':
return now - 30 * 86_400_000;
case 'month': {
const date = new Date();
return new Date(date.getFullYear(), date.getMonth(), 1).getTime();
}
default:
return null;
}
}
/**
* Filtr nad tickety.
*
* Zamerne se filtruje az nad vysledkem `listTickets`, ktery uz zaridil hranici
* firmy. Skladat vlastni dotaz by znamenalo druhe misto, kde se na filtr firmy
* da zapomenout.
*/
function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string | null): boolean {
if (filter.status && filter.status.length > 0 && !filter.status.includes(ticket.status)) return false;
if (filter.channel && filter.channel.length > 0 && !filter.channel.includes(ticket.channel)) return false;
if (filter.typeIds && filter.typeIds.length > 0) {
if (!ticket.typeId || !filter.typeIds.includes(ticket.typeId)) return false;
}
if (filter.tags && filter.tags.length > 0) {
if (!filter.tags.some((tag) => ticket.tags.includes(tag))) return false;
}
if (filter.groupIds && filter.groupIds.length > 0) {
if (!ticket.assigneeGroupId || !filter.groupIds.includes(ticket.assigneeGroupId)) return false;
}
if (filter.assignee && filter.assignee.length > 0) {
const wanted = filter.assignee.flatMap((value) => {
if (value === 'me') return personId ? [personId] : [];
return [value];
});
const unassigned = filter.assignee.includes('unassigned');
if (ticket.assignee === null) {
if (!unassigned) return false;
} else if (!wanted.includes(ticket.assignee.id)) {
return false;
}
}
const from = since(filter.period);
if (from !== null && new Date(ticket.createdAt).getTime() < from) return false;
return true;
}
/** Popisek skupiny. Bez nej by v tabulce byla jen ID. */
function groupLabel(key: string, groupBy: WidgetGroupBy): string {
switch (groupBy) {
case 'assignee':
return key === '' ? 'Bez řešitele' : (findPerson(key)?.name ?? key);
case 'group':
return key === '' ? 'Bez skupiny' : (findGroup(key)?.name ?? key);
case 'type':
return key === '' ? 'Bez typu' : (findTicketType(key)?.name ?? key);
case 'status':
return statusLabels[key] ?? key;
case 'channel':
return channelLabels[key as keyof typeof channelLabels] ?? key;
case 'tag':
return key === '' ? 'Bez tagu' : key;
default:
return key;
}
}
function groupKeys(ticket: Ticket, groupBy: string): string[] {
switch (groupBy) {
case 'assignee':
return [ticket.assignee?.id ?? ''];
case 'group':
return [ticket.assigneeGroupId ?? ''];
case 'type':
return [ticket.typeId ?? ''];
case 'status':
return [ticket.status];
case 'channel':
return [ticket.channel];
case 'tag':
// Ticket s vic tagy se pocita do kazdeho. Jinak by soucet nesedel na nic.
return ticket.tags.length > 0 ? ticket.tags : [''];
default:
return [''];
}
}
function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue {
const source = widget.source;
if (source.kind === 'workload') {
const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds);
return {
kind: 'workload',
rows: workload.rows.map((row) => ({
name: row.person.name,
open: row.open,
overCapacity: row.overloaded,
})),
};
}
const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) =>
matches(ticket, source.filter, scope.personId),
);
if (source.kind === 'ticketCount') {
if (!source.groupBy) return { kind: 'number', value: tickets.length };
const counts = new Map<string, number>();
for (const ticket of tickets) {
for (const key of groupKeys(ticket, source.groupBy)) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return {
kind: 'groups',
rows: [...counts.entries()]
.map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value }))
.sort((a, b) => b.value - a.value),
};
}
if (source.kind === 'ticketList') {
return {
kind: 'tickets',
items: tickets
.slice(0, source.limit)
.map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
assignee: ticket.assignee?.name ?? null,
})),
};
}
// Casova rada. Prazdne dny se doplnuji, jinak by graf preskakoval.
const days = source.bucket === 'week' ? 12 * 7 : 14;
const points: Array<{ date: string; value: number }> = [];
for (let offset = days - 1; offset >= 0; offset -= 1) {
const day = new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
points.push({
date: day,
value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length,
});
}
return { kind: 'series', points };
}
widgetDataRouter.post('/', (req, res) => {
const scope = resolveScope(req.user!, {
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const parsed = requestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Pošlete seznam ID widgetů, nejvýš 24.',
});
}
const results: WidgetResult[] = parsed.data.widgetIds.map((id) => {
const widget = findCustomWidget(id);
if (!widget || !scope.tenantIds.includes(widget.tenantId)) {
return { id, ok: false, error: 'Widget neexistuje.' };
}
// Osobni widget vidi jen jeho autor.
if (widget.ownerId !== null && widget.ownerId !== req.user!.id) {
return { id, ok: false, error: 'Widget neexistuje.' };
}
try {
return { id, ok: true, value: computeWidget(widget, scope) };
} catch (err) {
// Jeden rozbity zdroj nesmi zhasnout cely prehled.
console.error(`[widgets] ${id} selhal:`, err);
return { id, ok: false, error: err instanceof Error ? err.message : 'Data se nepodařilo spočítat.' };
}
});
return res.json({ items: results });
});
/** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */
widgetDataRouter.get('/options', (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
return res.json({
types: listTicketTypes(scope.tenantIds).map((type) => ({ id: type.id, name: type.name })),
groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })),
statuses: Object.entries(statusLabels).map(([value, label]) => ({ value, label })),
channels: Object.entries(channelLabels).map(([value, label]) => ({ value, label })),
// Tagy se berou z toho, co je na ticketech - vlastni seznam tagu neexistuje.
tags: [...new Set(listTickets({ tenantIds: scope.tenantIds }).flatMap((ticket) => ticket.tags))].sort(),
});
});