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