Log rekne co zpusobilo jakou zmenu, prevzeti ticketu a pozvanky

Nalezeno na bezicim serveru: TK-4946 mel 177 udalosti a 620 radku logu,
pritom se skoro nic nestalo. Zmereno proti fronte: ve stejnem okne vzniklo
presne tolik behu, kolik prislo udalosti (22 a 22), kazdy s jednim pokusem.
Fronta nenasobi nic, odesilatel poslal 177 POSTu. Nase vina byla, ze to
z historie neslo poznat.

- Data udalosti se ukladaji. Kdyz krok nema vlastni, ulozi se to, cim beh
  zacal - u webhooku prijate telo. Prazdna udalost je horsi nez zadna.
- Shodna udalost se pocita (repeats, lastAt), nezaklada dalsi radek. Ticket
  se pritom nemeni, takze duplikat nerozblika dashboard ani nespusti
  automatizaci na zmenu ticketu. Zahodit ji nejde, jinak by nikdo nezjistil,
  ze proti nam neco tluce.
- Zmeny se radi pod udalost, ktera je zpusobila, a u udalosti stoji jmeno
  automatizace. Log se cte jako "prislo tohle -> zmenilo to tohle".
- Poznamka o stavu jen kdyz se stav zmenil. "z in-progress na in-progress"
  u kazde zpravy byl zdroj tech 620 radku.
- runsToday konecne znamena dnes: behy po dnech, k tomu vcera a celkem.
  Dosud to byl citac od zalozeni automatizace, jen se jmenoval "dnes".

Vedle toho prace, o kterou slo predtim:
- Prevzeti ticketu ze skupiny (POST /tickets/:id/claim) a krok Predat skupine
  s prepinacem automatickeho prideleni nejvolnejsimu.
- Pozvanky do firmy: odkaz s nahodnym kodem, heslo si nastavi pozvany.
- Resitele, skupiny a pozvanky presunuty z Nastaveni do zalozky Lide, cleny
  skupiny se vybiraji klikanim.
- Ctyri AI znaky, ktere zbyvaly v kodu, pryc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-17 15:54:34 +02:00
co-authored by Claude Opus 5
parent fbe5b8ff6f
commit f1e8253169
30 changed files with 1839 additions and 107 deletions
+3 -1
View File
@@ -37,10 +37,12 @@ React aplikaci ze slozky `dist/public`.
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury |
| Sprava clenstvi z portalu | hotovo | uzivatele, firmy a role v Nastaveni |
| Sprava clenstvi z portalu | hotovo | firmy a role v Nastaveni, lide a pozvanky v Lidech |
| Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu |
| Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta |
| Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka |
| Prevzeti ticketu ze skupiny | hotovo | kdo ma cas, si praci vezme sam |
| Pozvanky do firmy | hotovo | odkaz s kodem, heslo si nastavi pozvany |
| Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou |
| Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript |
| Vlastni widgety | hotovo | vcetne zdroje z konektoru a vykonu resitelu |
+36
View File
@@ -48,8 +48,14 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/dashboard/tickets/:id/type` |
| POST | `/api/dashboard/tickets/:id/tags` |
| POST | `/api/dashboard/tickets/:id/group` |
| POST | `/api/dashboard/tickets/:id/claim` |
| GET | `/api/dashboard/tickets/:id/actions` |
| POST | `/api/dashboard/tickets/:id/actions/:actionId` |
| GET | `/api/dashboard/invites` |
| POST | `/api/dashboard/invites` |
| DELETE | `/api/dashboard/invites/:id` |
| GET | `/api/invites/:kod` |
| POST | `/api/invites/:kod/accept` |
| GET | `/api/dashboard/incidents` |
| GET | `/api/dashboard/storage` |
| GET | `/api/dashboard/services` |
@@ -196,6 +202,36 @@ toho, co ktera volana sluzba vratila.
`POST /api/dashboard/tickets/:id/assign` s telem `{"assigneeId": null}` vrati
ticket do fronty. Neznamy resitel vraci 404, ne tiche odpojeni.
`POST /api/dashboard/tickets/:id/claim` je **prevzeti prace**, ne prehozeni:
volajici si bere ticket sam a telo je prazdne. Smi to u ticketu bez resitele
a u ticketu ve skupine, ve ktere je. Kdyz uz ticket nekdo resi, vraci 409, resp.
403 u cizi skupiny - vzit nekomu rozdelanou praci je jine rozhodnuti a chce to
pravo `ticket.assign.others`. Kdo neni vedeny jako resitel, dostane 400.
## Pozvanky do firmy
`/api/invites/:kod` je **verejne**, protoze kdo prijde za odkazem, jeste ucet
mit nemusi. Autorizuje kod v adrese, proto je nahodny a dlouhy - stejne jako
u webhooku. Odpoved je zamerne uzka: nazev firmy, jestli pozvanka plati, a kdyz
uz adresu zname, tak ji, at ji clovek nemusi psat. Nic o tom, kdo ve firme je.
`POST /api/invites/:kod/accept` s telem `{"name", "email", "password"}`:
| Situace | Co se stane |
| --- | --- |
| ucet neexistuje | zalozi se a pripoji k firme |
| ucet existuje, heslo sedi | jen se pripoji k firme |
| ucet existuje, heslo nesedi | 401 |
| pozvanka je na jinou adresu | 403 |
| pozvanka uz byla pouzita nebo vyprsela | 409 s konkretnim duvodem |
Overeni hesla u existujiciho uctu neni formalita: bez nej by kdokoliv s odkazem
pripojil cizi adresu ke sve firme a videl by jeji data.
Sprava pozvanek (`/api/dashboard/invites`) chce pravo `user.manage`. Seznam
vraci u kazde pozvanky **celou adresu** vcetne prefixu proxy, aby slo rovnou
kopirovat - relativni cesta se do zpravy vlepit neda.
## Akce na ticketu
Popis modelu je v [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
+77
View File
@@ -80,6 +80,47 @@ Kazdy resitel ma `capacity`, tedy pocet nevyrizenych ticketu, ktery je pro nej
jeste zdrava zatez. Neni to limit, nic se podle nej neodmita - jen se v prehledu
oznaci, kdo je nad ni.
## Fronta skupiny a prevzeti
Ticket nemusi mit resitele hned. Muze lezet **u skupiny** a cekat, az si ho
nekdo vezme - `POST /tickets/:id/claim`. Kdo je ve skupine, si ho vezme sam,
prace se nerozdava shora.
Proc obojí vedle sebe:
| Situace | Co se hodi |
| --- | --- |
| havarie, musi to nekdo hned resit | automat prideli nejvolnejsimu |
| bezny dotaz, lidi maji ruzne dny | necha se ve fronte skupiny |
Proto je prepinac **Priradit rovnou nejvolnejsimu** na kroku automatizace
(`ticket/assign-group`), ne na skupine: tataz skupina potrebuje obe chovani,
jen u jineho typu prace.
Vzit si ticket, ktery uz nekdo resi, neni prevzeti, ale prehozeni. Chce to pravo
`ticket.assign.others` a jde to jen pres prirazeni, ne pres prevzeti.
## Pozvanky do firmy
`src/data/invites.ts`
Novy clovek se do firmy dostane odkazem s nahodnym kodem, ne tim, ze mu nekdo
zalozi ucet a posle heslo. Duvody:
- **heslo nikdy nikam neposilame.** Kdo si ho nastavi sam za odkazem, ma ho jen
on,
- pozvanka **vyprsi** (tyden) a da se zrusit, ucet ne,
- kdo uz ucet ma, se jen pripoji k dalsi firme misto zakladani druheho - ale
musi zadat svoje heslo, jinak by kdokoliv s odkazem pripojil cizi adresu ke
sve firme.
U pozvanky se rovnou rekne, jake role clovek dostane a jestli z nej ma byt
i **resitel**. Uzivatel a resitel nejsou totez, viz vyse - proto se to pta
misto hadani.
Sprava je v zalozce **Lide**, ne v nastaveni: pozvat kolegu je bezna denni
prace.
## Prehled nad firmou
`GET /api/dashboard/tickets/workload` vraci pres cely tym: kolik ma kdo
@@ -123,6 +164,42 @@ do korene a zaloguje - ztratit radek logu je horsi nez ho ukazat spatne zanoreny
Komentare jsou taky zaznamy logu (`kind: 'note'`). Diky tomu je vsechno na jedne
casove ose a nemusi se nikde skladat dohromady dva ruzne seznamy.
## Opakovana udalost
Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Kdyz prijde
**presne totez co posledne** (stejny typ, zdroj, popisek a stejna data), nezaklada
se dalsi radek: pricte se k pocitadlu u te predchozi.
| Co se stane | Proc |
| --- | --- |
| `repeats` +1, `lastAt` = ted | osmdesat radku znamena, ze v historii nikdo nic nenajde |
| ticket se **nemeni** | jinak by duplikat rozblikal dashboard a spustil automatizaci na zmenu ticketu |
| do logu se nezapisuje nic | log ma ukazovat zmeny, ne to, ze se nezmenilo nic |
| udalost se **nezahazuje** | bez pocitadla by nikdo nezjistil, ze proti nam neco tluce ve smycce |
Porovnava se jen s posledni udalosti. "Objednavka pripravena" muze legitimne
prijit znovu za hodinu, kdyz se mezitim stalo neco jineho - to je novy fakt.
## Co je v logu videt
Log se cte jako **prislo tohle a zpusobilo to tuhle zmenu**. Zmeny, ktere udela
beh automatizace, se radi pod jeho radek udalosti (`parentId`), takze nejde
zamenit, co zpusobilo co:
```
Prijata udalost: stav completed (automatizace TEST) 13:13:27
stav in-progress -> completed 13:13:27
stitky +hovor 13:13:27
Prijata udalost: stav completed 22x beze zmeny 13:13:34
```
U udalosti stoji, **kdo ji prinesl**. Prvni otazka nad zmenenym ticketem je
"kdo mi do toho sahl" a odpoved `webhook` na ni neodpovida.
Data udalosti se ukladaji cela. Kdyz krok nema vlastni, ulozi se to, cim beh
zacal - u webhooku prijate telo. Prazdna udalost je horsi nez zadna: tvari se,
ze se neco stalo, a nerekne co.
## Konektor Tickety
Kategorie `servicedesk`, driv byl pod Nastroji. Ma obe strany:
+95
View File
@@ -2,6 +2,101 @@
Nejnovejsi nahore.
## 2026-08-17 - log rekne, co zpusobilo jakou zmenu
Nalezeno na bezicim serveru: ticket TK-4946 mel 177 prichozich udalosti
a 620 radku v logu, pritom se nestalo skoro nic. Zmereno proti behum ve fronte:
ve stejnem okne vzniklo **presne tolik behu, kolik prislo udalosti** (22 a 22),
kazdy s jednim pokusem. Fronta ani worker nic nenasobi - odesilatel poslal
177 POSTu. Nasi vinou bylo, ze to z historie neslo poznat.
### Opraveno
- **Data udalosti se konecne ukladaji.** `payload` byl u kazde udalosti prazdny,
protoze krok `ticket/upsert` ukladal jen to, co si clovek vyplni v poli Data.
Kdyz je prazdne, ulozi se **to, cim beh zacal** - u webhooku cele prijate telo.
Prazdna udalost je horsi nez zadna: tvari se, ze se neco stalo, a nerekne co.
- **Shodna udalost se pocita, nezaklada dalsi radek.** Kdyz prijde presne totez
co posledne, pricte se k pocitadlu (`repeats`, `lastAt`) a v portalu se u radku
ukaze "22x beze zmeny, naposledy ...". Ticket se pritom **nemeni**: neprepise
se `updatedAt` ani se nerozesle zmena, takze duplikat nerozbliká dashboard
a nespusti automatizaci navazanou na zmenu ticketu.
- Zahodit duplikat nejde. Bez pocitadla by nikdo nezjistil, ze proti nam neco
tluce ve smycce.
- Porovnava se **jen s posledni** udalosti. "Objednavka pripravena" muze
legitimne prijit znovu za hodinu - to je novy fakt.
- **Zmeny se radi pod udalost, ktera je zpusobila.** Log uz mel strom
(`parentId`), ale nikdo ho nepouzival. Zapisy behu se ted radi pod jeho radek
udalosti, takze log se cte jako "prislo tohle -> zmenilo to tohle".
- **U udalosti stoji, kdo ji prinesl**: `Prijata udalost: stav completed
(automatizace TEST)`. Prvni otazka nad zmenenym ticketem je "kdo mi do toho
sahl" a `webhook` na ni neodpovida.
- **Poznamka o stavu jen kdyz se stav zmenil.** Predtim se u kazde udalosti
zapsalo `Stav zmenen z in-progress na in-progress`, tedy tri radky logu na
jednu zpravu, ktera nic nezmenila. Odtud 620 radku.
- **Popisek udalosti uz neni porad "Udalost".** Bere se popisek, predmet, stav,
externi ID - v tomhle poradi. Casova osa, kde je na kazdem radku totez, nerika
nic.
- Typograficke uvozovky z popisku v logu pryc, plus ctyri AI znaky, ktere
zbyvaly v kodu (`web/src/data/products.ts`, `References.tsx`,
`automationStore.ts`).
### `runsToday` konecne znamena dnes
Bylo to pocitadlo od zalozeni automatizace, jen se jmenovalo "dnes" - na serveru
ukazovalo 373 za automatizaci, ktera bezi tri mesice.
- Automatizace si drzi **behy po dnech** (`days`, poslednich 14 dni). Po pulnoci
je "dnes" nula, dokud opravdu neco nebezi.
- Vedle toho `runsYesterday` a `runsTotal`. Puvodni cislo se neztratilo, jen se
spravne jmenuje.
- Uspesnost se pocita z dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni
den, kdy byly - nula procent u automatizace, ktera dnes nemela co delat, by
vypadala jako porucha.
- V seznamu je pod dnesnim cislem vcerejsek, na detailu dnes, vcera i celkem.
## 2026-08-17 - prevzeti ticketu ze skupiny a pozvanky do firmy
### Pridano
- **Prevzeti ticketu.** `POST /tickets/:id/claim`. Kdo je ve skupine, ktera ma
ticket u sebe, si ho vezme sam. Prace se nerozdava shora, lidi si ji beru
podle toho, kdo ma cas.
- Vzit ticket, ktery uz nekdo resi, je neco jineho: to je prehozeni a chce
to pravo `ticket.assign.others`.
- Ticket bez resitele si vezme kdokoli, kdo je vedeny jako resitel.
- **Krok `ticket/assign-group`** (Predat skupine) s prepinacem
**Priradit rovnou nejvolnejsimu**. Prazdne nebo Ne = ticket zustane ve fronte
skupiny. Prepinac je na kroku automatizace, ne na skupine: tataz skupina
potrebuje u havarie okamzite prideleni a u bezneho dotazu ne.
- **Pozvanky do firmy.** Spravce vytvori odkaz s nahodnym kodem
(`/pozvanka/:kod`), posle ho, jak chce. Kdo ho otevre, vyplni jmeno a heslo
a je uvnitr.
- **Heslo se nikdy neposila.** Nastavuje si ho sam clovek az za odkazem.
- Kdyz uz ucet ma, zada k nemu svoje heslo a jen se pripoji k dalsi firme.
Bez overeni hesla by kdokoliv s odkazem pripojil cizi adresu ke sve firme
a videl by jeji data.
- Pozvanka plati tyden, da se zrusit a po pouziti prestane platit sama.
- Volitelne z cloveka rovnou udela resitele. Ucetni muze mit pristup do
portalu, aniz by kdy resila ticket, proto se to pta.
### Zmeneno
- **Resitele, skupiny a pozvanky jsou na strance Lide**, ne v nastaveni.
Pozvat kolegu je bezna denni prace, ne nastaveni portalu - dokud to bylo
schovane v nastaveni, nikdo to nenasel.
- **Cleny skupiny se vybiraji klikanim** ze seznamu lidi. Predtim se opisovala
ID `ppl_xxx` oddelena carkou, coz je preklep cekajici na sve misto.
### Nove soubory
| Soubor | Co dela |
| --- | --- |
| `src/data/invites.ts` | Entita pozvanky, kod, platnost. |
| `src/routes/invites.ts` | Verejne cesty (prohlednuti a prijeti) a sprava. |
| `web/src/pages/Invite.tsx` | Stranka za odkazem: jmeno, e-mail, heslo. |
| `web/src/components/dashboard/InvitePanel.tsx` | Sprava pozvanek v zalozce Lide. |
## 2026-08-13 - vyrizeno je vyslovny priznak, ne hadani ze stavu
### Zmeneno
+107 -9
View File
@@ -87,7 +87,7 @@ export type FlowStep =
/** id parametru z trigger.fields */
fieldId: string;
operator: ConditionOperator;
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue…). */
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
value?: string;
yes: FlowStep[];
no: FlowStep[];
@@ -111,7 +111,19 @@ export interface Automation {
name: string;
kind: AutomationKind;
enabled: boolean;
/**
* Behy za **dnesni den**. Po pulnoci zacina od nuly.
*
* Driv to byl citac od zalozeni automatizace, jen se jmenoval "dnes".
* U automatizace, ktera bezi tri mesice, to rikalo cislo, ktere s dneskem
* nemelo nic spolecneho.
*/
runsToday: number;
/** Behy za vcerejsi den, at je s cim srovnat. */
runsYesterday: number;
/** Behy od zalozeni. Puvodni citac se neztratil, jen se spravne jmenuje. */
runsTotal: number;
/** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */
successRate: number;
avgDurationMs: number;
lastRunAt: string;
@@ -138,6 +150,15 @@ interface StoredAutomation {
name: string;
kind: AutomationKind;
enabled: boolean;
/**
* Behy po dnech, nejnovejsi nakonec. Drzi se poslednich `KEEP_DAYS`.
*
* Proc historie a ne jen citac pro dnesek: bez ni po pulnoci neni s cim
* srovnat a nikdo nepozna, jestli je dnesnich devet malo nebo hodne.
*/
days?: Array<{ day: string; runs: number; ok: number }>;
/** Behy od zalozeni. */
runsTotal?: number;
runsToday: number;
successRate: number;
avgDurationMs: number;
@@ -147,6 +168,53 @@ interface StoredAutomation {
updatedAt: string;
}
/** Kolik dni zpatky se drzi. Dva tydny staci na "je to dnes jinak nez obvykle". */
const KEEP_DAYS = 14;
/** Dnesni den jako `2026-08-17`. Podle nej se pozna prelom dne. */
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function dayBefore(day: string): string {
return new Date(new Date(`${day}T00:00:00Z`).getTime() - 86_400_000).toISOString().slice(0, 10);
}
/**
* Statistika za dnesek z ulozene historie.
*
* Kdyz dnes jeste nic nebezelo, vraci nulu - **ne vcerejsi cislo**. Ukazat
* vcerejsi pocet jako dnesni je to, co bylo spatne.
*/
function statsOf(stored: StoredAutomation): {
runsToday: number;
runsYesterday: number;
runsTotal: number;
successRate: number;
} {
const days = stored.days ?? [];
const now = today();
const mine = days.find((entry) => entry.day === now);
const before = days.find((entry) => entry.day === dayBefore(now));
/*
* Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni den, kdy
* byly - nula procent u automatizace, ktera dnes jen nemela co delat, by
* vypadala jako porucha.
*/
const source = mine ?? [...days].reverse().find((entry) => entry.runs > 0);
return {
runsToday: mine?.runs ?? 0,
runsYesterday: before?.runs ?? 0,
runsTotal: stored.runsTotal ?? stored.runsToday,
successRate:
source && source.runs > 0
? Math.round((source.ok / source.runs) * 1000) / 10
: stored.successRate,
};
}
function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
@@ -374,7 +442,7 @@ function seed(
*/
function seedDemoAutomations(): void {
seed({
name: 'Objednávka → sklad → fakturace',
name: 'Objednávka, sklad a fakturace',
enabled: true,
runsToday: 428,
successRate: 99.3,
@@ -888,9 +956,20 @@ if (!config.seedWebhookToken) {
}
function toSummary(stored: StoredAutomation): Automation {
const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored;
const {
flow: _flow,
createdAt: _createdAt,
updatedAt: _updatedAt,
days: _days,
...rest
} = stored;
return {
...rest,
/*
* Cisla se pocitaji z historie po dnech, ne z ulozeneho citace. Po pulnoci
* je "dnes" nula, dokud opravdu neco nebezi.
*/
...statsOf(stored),
stepCount: countSteps(stored.flow.steps),
configured: stored.flow.trigger !== null,
issues: collectFlowIssues(stored.flow),
@@ -1077,15 +1156,34 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati
return undefined;
}
const runs = stored.runsToday + 1;
// Klouzavy prumer uspesnosti pres dnesni behy, ne prepisovani na 0 nebo 100.
const previousOk = (stored.successRate / 100) * stored.runsToday;
const successRate = runs === 0 ? 100 : ((previousOk + (ok ? 1 : 0)) / runs) * 100;
/*
* Zapisuje se do dnesniho dne. Po pulnoci vznikne novy zaznam, takze citac
* nepokracuje pres den - to byla puvodni chyba.
*/
const now = today();
const days = [...(stored.days ?? [])];
const index = days.findIndex((entry) => entry.day === now);
if (index === -1) {
days.push({ day: now, runs: 1, ok: ok ? 1 : 0 });
} else {
days[index] = {
day: now,
runs: days[index].runs + 1,
ok: days[index].ok + (ok ? 1 : 0),
};
}
if (days.length > KEEP_DAYS) days.splice(0, days.length - KEEP_DAYS);
const total = (stored.runsTotal ?? stored.runsToday) + 1;
const dayEntry = days.find((entry) => entry.day === now)!;
const updated: StoredAutomation = {
...stored,
runsToday: runs,
successRate: Math.round(successRate * 10) / 10,
days,
runsTotal: total,
runsToday: dayEntry.runs,
successRate: Math.round((dayEntry.ok / dayEntry.runs) * 1000) / 10,
lastRunAt: new Date().toISOString(),
};
save(updated);
+3
View File
@@ -13,6 +13,7 @@ import { refreshActions, actionPermissions, actionStore, seedActions } from './t
import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { auditStore } from './audit.js';
import { notificationStore, refreshNotifications } from './notifications.js';
import { inviteStore, refreshInvites } from './invites.js';
import { onTicket } from './ticketHooks.js';
import { onTicketEvent } from '../runtime/triggers.js';
import { initQueue } from '../runtime/queue.js';
@@ -61,6 +62,7 @@ const entities: Array<{
// Audit vychozi sadu nema, zaznamy vznikaji az provozem.
{ store: auditStore as EntityStore<TenantEntity> },
{ store: notificationStore as EntityStore<TenantEntity> },
{ store: inviteStore as EntityStore<TenantEntity> },
];
/**
@@ -93,6 +95,7 @@ export async function refreshCaches(): Promise<void> {
refreshActions(),
refreshCustomWidgets(),
refreshNotifications(),
refreshInvites(),
]);
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
+121
View File
@@ -0,0 +1,121 @@
/**
* Pozvanky do firmy.
*
* Spravce firmy vytvori odkaz s neodhadnutelnym kodem a posle ho, jak chce -
* mailem, na Slacku, diktatem do telefonu. Kdo odkaz otevre, vyplni jmeno
* a heslo a je uvnitr.
*
* Proc odkaz a ne "zaloz uzivatele a posli mu heslo":
* - **heslo nikdy nikam neposilame**. Kdo si ho nastavi sam, ma ho jen on.
* - pozvanka **vyprsi** a da se zrusit, ucet ne,
* - kdo uz ucet ma, se jen pripoji k dalsi firme misto zakladani druheho.
*
* Kod je jedina ochrana, proto je dlouhy a nahodny - stejne jako u webhooku.
*/
import { randomBytes, randomUUID } from 'node:crypto';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
export interface Invite extends TenantEntity {
/** Firma, do ktere pozvanka zve. */
tenantId: string;
/** Neodhadnutelny kod v odkazu. */
code: string;
/**
* Komu je urcena. Prazdne = komukoliv, kdo odkaz dostane.
*
* Kdyz je vyplnena, musi souhlasit s tim, co clovek zada. Bez toho by odkaz
* poslany dal fungoval komukoliv, coz u pozvanky do firmy neni v poradku.
*/
email: string;
/** Jmeno napovedou, at spravce v seznamu pozna, komu ji poslal. */
note: string;
/** Jake role dostane v te firme. */
roleIds: string[];
/**
* Ma se z nej stat i resitel, tedy nekdo, komu jde prehodit ticket?
*
* Uzivatel a resitel nejsou totez: ucetni muze mit pristup do portalu, aniz
* by kdy resila ticket. Proto se to pta, misto aby se hadalo.
*/
asPerson: boolean;
expiresAt: string;
usedAt: string | null;
usedByUserId: string | null;
createdByEmail: string;
}
export const inviteStore = defineStore<Invite>('invite');
const cache = withCache(inviteStore);
/** Jak dlouho pozvanka plati. Tyden je dost na dovolenou a malo na zapomenuti. */
const VALID_DAYS = 7;
export async function refreshInvites(): Promise<void> {
await cache.refresh();
}
export interface CreateInviteInput {
tenantId: string;
email?: string;
note?: string;
roleIds: string[];
asPerson?: boolean;
createdByEmail: string;
}
export function newInvite(input: CreateInviteInput): Invite {
const timestamp = nowIso();
return {
id: `inv_${randomUUID().slice(0, 12)}`,
tenantId: input.tenantId,
code: randomBytes(24).toString('base64url'),
email: (input.email ?? '').trim().toLowerCase(),
note: input.note ?? '',
roleIds: input.roleIds,
asPerson: input.asPerson ?? true,
expiresAt: new Date(Date.now() + VALID_DAYS * 86_400_000).toISOString(),
usedAt: null,
usedByUserId: null,
createdByEmail: input.createdByEmail,
createdAt: timestamp,
updatedAt: timestamp,
};
}
/** Pozvanka podle kodu. Nezkoumá platnost, na to je `inviteProblem`. */
export function findByCode(code: string): Invite | undefined {
if (code.length < 16) return undefined;
return cache.find((invite) => invite.code === code);
}
/**
* Proc pozvanku nejde pouzit. `null` = je v poradku.
*
* Duvody jsou zamerne konkretni: "uz byla pouzita" a "vyprsela" vedou k jine
* reakci a schovat je za "neplatna pozvanka" znamena, ze clovek nevi, co dal.
*/
export function inviteProblem(invite: Invite): string | null {
if (invite.usedAt) return 'Tahle pozvánka už byla použitá.';
if (new Date(invite.expiresAt).getTime() < Date.now()) {
return 'Pozvánka vypršela, požádejte o novou.';
}
return null;
}
export function listInvites(tenantIds: string[]): Invite[] {
return cache
.all()
.filter((invite) => tenantIds.includes(invite.tenantId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function markUsed(invite: Invite, userId: string): Promise<void> {
await inviteStore.update(
invite.id,
{ usedAt: nowIso(), usedByUserId: userId },
{ tenantIds: [invite.tenantId], includeGlobal: true },
);
await cache.refresh();
}
+7
View File
@@ -127,6 +127,13 @@ export interface PersonGroup extends TenantEntity {
personIds: string[];
}
/** Skupiny, jejichz clenem ten clovek je. */
export function groupsOfPerson(personId: string, tenantIds: string[]): PersonGroup[] {
return groupCache
.all()
.filter((group) => tenantIds.includes(group.tenantId) && group.personIds.includes(personId));
}
export const groupStore = defineStore<PersonGroup>('personGroup');
const groupCache = withCache(groupStore);
+37
View File
@@ -610,6 +610,41 @@ export const services: Service[] = [
{ id: 'created', name: 'created', type: 'boolean', required: true },
],
},
{
id: 'assign-group',
name: 'Předat skupině',
description:
'Ticket se objeví ve frontě skupiny a kdo má čas, si ho převezme. ' +
'Když zaškrtnete rovnou přiřadit, dostane ho hned ten, kdo má nejmíň práce - ' +
'to se hodí tam, kde se čeká na rychlou reakci.',
implementation: 'script',
inputs: [
{
id: 'groupId',
label: 'Skupina',
kind: 'choice',
required: true,
options: [],
optionsFrom: 'groups',
},
{
id: 'autoAssign',
label: 'Rovnou přiřadit nejvolnějšímu',
kind: 'choice',
required: false,
options: [
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne, nechat ve frontě skupiny' },
],
hint: 'Prázdné = nechat ve frontě, ať si to lidé vezmou sami.',
},
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
outputFields: [
{ id: 'groupId', name: 'groupId', type: 'string', required: true },
{ id: 'groupName', name: 'groupName', type: 'string', required: true },
],
},
{
id: 'assign-least-busy',
name: 'Předat nejvolnějšímu ze skupiny',
@@ -623,6 +658,8 @@ export const services: Service[] = [
label: 'Skupina',
kind: 'choice',
required: true,
options: [],
optionsFrom: 'groups',
hint: 'Například sklad nebo IT. Skupiny se spravují v Nastavení.',
},
{
+178 -14
View File
@@ -18,6 +18,7 @@
import { config } from '../config.js';
import { publish } from '../events/bus.js';
import { currentRun } from '../runtime/context.js';
import { onTicketChanged } from './ticketHooks.js';
import { notify } from './notifications.js';
import { findPerson, type Person } from './people.js';
@@ -112,6 +113,17 @@ export interface TicketEvent {
/** Cela prijata data. Nic se nezahazuje, at jde dohledat, co presne prislo. */
payload: Record<string, unknown>;
at: string;
/**
* Kolikrat prislo totež. Vetsinou 1.
*
* Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Zalozit
* osmdesat radku znamena, ze v historii nikdo nic nenajde - a pritom se
* nestalo nic noveho. Proto se shodna udalost **pocita** u te predchozi.
* Zahodit ji nejde: bez pocitadla by nikdo nezjistil, ze odesilatel tluce.
*/
repeats: number;
/** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */
lastAt: string;
}
export interface Ticket {
@@ -312,7 +324,18 @@ export async function initTickets(): Promise<void> {
const { trace, events: rowEvents, ...stored } = row;
tickets.push(stored);
traces.set(row.id, trace ?? []);
events.set(row.id, rowEvents ?? []);
/*
* Pocitadlo opakovani pribylo pozdeji. Ulozene udalosti ho nemaji, takze
* se doplni pri nacteni - jinak by se v portalu ukazovalo "undefinedx".
*/
events.set(
row.id,
(rowEvents ?? []).map((event) => ({
...event,
repeats: event.repeats ?? 1,
lastAt: event.lastAt ?? event.at,
})),
);
}
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety
@@ -386,7 +409,12 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
return 0;
}
const before = existing.length;
flattenTrace(inputs, null, existing);
/*
* Kdyz zapis patri behu automatizace, radi se pod jeho radek udalosti.
* Diky tomu je v logu videt "prislo tohle -> zmenilo to tohle" misto dvou
* vet vedle sebe, u kterych se jen hada, jestli spolu souvisi.
*/
flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing);
// Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu.
const ticket = tickets.find((item) => item.id === ticketId);
@@ -1030,6 +1058,13 @@ export interface IntakeResult {
/** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */
created: boolean;
event: TicketEvent;
/**
* true = prislo presne totez co posledne, takze se jen pricetlo k pocitadlu.
* Ticket se **nemenil**: nesmi se kvuli tomu prepsat `updatedAt`, ani
* rozeslat zmena - jinak by kazdy duplikat rozblikal dashboard a mohl
* spustit automatizaci navazanou na zmenu ticketu.
*/
repeated: boolean;
}
/**
@@ -1053,6 +1088,8 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
label: input.label ?? input.type,
payload: input.payload ?? {},
at: timestamp,
repeats: 1,
lastAt: timestamp,
};
const existing = input.externalId
@@ -1062,6 +1099,18 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
: undefined;
if (existing) {
/*
* Prislo presne totez co posledne? Pak se to jen pricte. Zadny novy radek,
* zadny zapis do logu, zadna zmena ticketu - nic se totiz nestalo.
*/
const repeat = sameAsLast(existing.id, event);
if (repeat) {
repeat.repeats += 1;
repeat.lastAt = event.at;
persist(existing);
return { ticket: toTicket(existing), created: false, event: repeat, repeated: true };
}
appendEvent(existing, event);
if (input.fields) existing.fields = { ...(existing.fields ?? {}), ...input.fields };
@@ -1070,15 +1119,31 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
}
touch(existing);
/*
* Rodic musi byt prazdny, jinak by se radek udalosti zaradil pod udalost
* predchozi - `appendTrace` doplnuje rodice z behu.
*/
const run = currentRun();
const parent = run?.traceParent;
if (parent) parent.id = null;
appendTrace(existing.id, [
{ kind: 'trigger', status: 'info', label: `Přijata událost: ${event.label}`, response: describePayload(event.payload) },
{
kind: 'trigger',
status: 'info',
label: `Přijata událost: ${event.label}${originOf(run, event)}`,
response: describePayload(event.payload),
},
]);
// Dalsi zapisy tohohle behu uz patri pod tenhle radek.
if (parent) parent.id = lastTraceId(existing.id);
publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, {
ticketId: existing.id,
externalId: existing.externalId,
});
return { ticket: toTicket(existing), created: false, event };
return { ticket: toTicket(existing), created: false, event, repeated: false };
}
const created = createTicket({
@@ -1101,7 +1166,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
{
kind: 'trigger',
status: 'info',
label: `Ticket vznikl z události: ${event.label}`,
label: `Ticket vznikl z události: ${event.label}${originOf(currentRun(), event)}`,
response: describePayload(event.payload),
},
],
@@ -1113,7 +1178,16 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
persist(stored);
}
return { ticket: toTicket(stored ?? created as unknown as StoredTicket), created: true, event };
// Zmeny, ktere beh udela dal, patri pod radek o vzniku ticketu.
const parent = currentRun()?.traceParent;
if (parent) parent.id = traces.get(created.id)?.[0]?.id ?? null;
return {
ticket: toTicket(stored ?? (created as unknown as StoredTicket)),
created: true,
event,
repeated: false,
};
}
/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */
@@ -1127,6 +1201,28 @@ function appendEvent(ticket: StoredTicket, event: TicketEvent): void {
events.set(ticket.id, list);
}
/**
* Je to totez, co prislo naposledy?
*
* Porovnava se **jen s posledni** udalosti, ne s celou historii. "Objednavka
* pripravena" muze legitimne prijit znovu za hodinu, kdyz se mezitim stalo
* neco jineho - to je novy fakt. Dvacet stejnych zprav v rade uz ne.
*
* Data se srovnavaji pres JSON, protoze na poradi klicu v prijate zprave
* nezalezi jen vyjimecne a levnejsi porovnani neni potreba.
*/
function sameAsLast(ticketId: string, event: TicketEvent): TicketEvent | null {
const list = events.get(ticketId);
const last = list && list.length > 0 ? list[list.length - 1] : undefined;
if (!last) return null;
if (last.type !== event.type || last.source !== event.source || last.label !== event.label) {
return null;
}
if (JSON.stringify(last.payload) !== JSON.stringify(event.payload)) return null;
return last;
}
let eventCounter = 0;
function nextEventId(): string {
@@ -1134,6 +1230,24 @@ function nextEventId(): string {
return `tev_${eventCounter.toString(36)}`;
}
/**
* Kdo udalost prinesl.
*
* V logu je to to nejdulezitejsi: kdyz se ticket zmenil, prvni otazka je "kdo
* mi do toho sahl". Jmeno automatizace na to odpovi, `webhook` uz ne.
*/
function originOf(run: { automationName: string } | undefined, event: TicketEvent): string {
if (run) return ` (automatizace ${run.automationName})`;
if (event.source && event.source !== 'webhook') return ` (${event.source})`;
return '';
}
/** ID posledniho radku logu. Pod nej se radi zmeny, ktere z udalosti plynou. */
function lastTraceId(ticketId: string): string | null {
const list = traces.get(ticketId);
return list && list.length > 0 ? list[list.length - 1].id : null;
}
/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */
function describePayload(payload: Record<string, unknown>): string | null {
const keys = Object.keys(payload);
@@ -1370,13 +1484,20 @@ export function updateTicketStatus(
markResponded(ticket);
touch(ticket);
appendTrace(id, [
{
kind: 'note',
label: `Stav změněn z „${previous}" na „${status}".`,
status: 'info',
},
]);
/*
* Poznamka jen kdyz se neco zmenilo. "Stav zmenen z completed na completed"
* je rada, ktera se ctenari pise do historie u kazde prichozi zpravy, a po
* dvaceti takovych se v logu neda nic najit.
*/
const statusChanged = previous !== status;
const closedChanged = closed !== undefined && closed !== wasClosed;
if (statusChanged || closedChanged) {
const parts: string[] = [];
if (statusChanged) parts.push(`stav ${previous} -> ${status}`);
if (closedChanged) parts.push(ticket.closed ? 'oznaceno jako vyrizene' : 'znovu otevreno');
appendTrace(id, [{ kind: 'note', label: parts.join(', '), status: 'info' }]);
}
if (ticket.closed) {
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
@@ -1499,12 +1620,14 @@ export function assignTicketGroup(
id: string,
groupId: string | null,
tenantIds: string[],
/** false = nechat resitele, jak je. Pouziva to prevzeti ticketu. */
clearAssignee = true,
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
ticket.assigneeGroupId = groupId;
if (groupId) ticket.assigneeId = null;
if (groupId && clearAssignee) ticket.assigneeId = null;
touch(ticket);
appendTrace(id, [
@@ -1518,6 +1641,47 @@ export function assignTicketGroup(
return toTicket(ticket);
}
/**
* Prevzeti ticketu.
*
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Smi to
* jen u ticketu **ze sve skupiny**, nebo u takoveho, ktery nema nikoho -
* brat cizi rozdelanou praci by znamenalo, ze o ni prijde ten, kdo ji resi.
*
* Vraci `undefined`, kdyz to nejde; duvod rekne volajici, ktery zna kontext.
*/
export function claimTicket(
id: string,
personId: string,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
const person = findPerson(personId);
if (!person || person.tenantId !== ticket.tenantId) return undefined;
ticket.assigneeId = person.id;
markResponded(ticket);
touch(ticket);
appendTrace(id, [
{ kind: 'note', status: 'info', label: `${person.name} si ticket převzal` },
]);
publish('ticket.assigned', `${person.name} si vzal ticket ${ticket.id}`, {
ticketId: ticket.id,
assigneeId: person.id,
});
return toTicket(ticket);
}
/** Ma ticket uz nekoho? Pro rozhodnuti, jestli jde prevzit. */
export function ticketAssignee(id: string, tenantIds: string[]): string | null | undefined {
const ticket = tickets.find((item) => item.id === id);
if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined;
return ticket.assigneeId;
}
export function addComment(
id: string,
author: string,
+3
View File
@@ -14,6 +14,7 @@ import { buildOpenApiDocument } from './openapi.js';
import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard.js';
import { publicInviteRouter } from './routes/invites.js';
import { webhookRouter } from './routes/webhook.js';
import { bootstrapData } from './data/bootstrap.js';
import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js';
@@ -114,6 +115,8 @@ api.use(
api.use('/api/auth', authRouter);
api.use('/api/dashboard', dashboardRouter);
// Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny.
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
api.use('/webhook', webhookRouter);
+2
View File
@@ -634,6 +634,8 @@ export function buildOpenApiDocument() {
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
+75 -1
View File
@@ -42,9 +42,10 @@ import { collectScopes } from '../data/flowScope.js';
import { widgetCatalog } from '../data/widgets.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
import { findPersonByEmail, groupsOfPerson, listGroups, listPeople } from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
import { listNotifications, markRead, unreadCount } from '../data/notifications.js';
import { inviteRouter } from './invites.js';
import { queueStats, recentRuns } from '../runtime/queue.js';
import { recordAudit } from '../data/audit.js';
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
@@ -52,6 +53,8 @@ import { listTicketTypes } from '../data/ticketTypes.js';
import {
addComment,
assignTicket,
claimTicket,
ticketAssignee,
getAgentStats,
getTicket,
getWorkload,
@@ -541,6 +544,74 @@ dashboardRouter.get('/tickets', (req, res) => {
});
});
/**
* Prevzeti ticketu.
*
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
* vezme.
*
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
* `ticket.assign.others`.
*/
dashboardRouter.post('/tickets/:id/claim', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const access = accessFor(req.user!);
if (!access.personId) {
return res.status(400).json({
error: 'not_a_person',
message: 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.',
});
}
const tenantIds = writableTenants(req);
const current = ticketAssignee(req.params.id, tenantIds);
if (current === undefined) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
const ticket = getTicket(req.params.id, tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
if (current !== null && current !== access.personId) {
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
if (!hasPermission(req.user!, 'ticket.assign.others')) {
return res.status(409).json({
error: 'already_assigned',
message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
});
}
}
const mine = groupsOfPerson(access.personId, tenantIds).map((group) => group.id);
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
if (!inMyGroup && current !== null && current !== access.personId) {
return res.status(403).json({
error: 'forbidden',
message: 'Tenhle ticket není ve vaší skupině.',
});
}
const updated = claimTicket(req.params.id, access.personId, tenantIds);
if (!updated) {
return res.status(400).json({ error: 'failed', message: 'Převzetí se nepodařilo.' });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: updated.tenantId,
action: 'ticket.claim',
target: updated.id,
});
return res.json(updated);
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
dashboardRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
@@ -665,6 +736,9 @@ dashboardRouter.use('/connectors', connectorsRouter);
// Nastaveni vsech entit. Cele stoji na fabrice crudRouter.
dashboardRouter.use('/settings', settingsRouter);
// Pozvanky do firmy.
dashboardRouter.use('/invites', inviteRouter);
// Data pro vlastni widgety. Jeden request na cely prehled.
dashboardRouter.use('/widget-data', widgetDataRouter);
+334
View File
@@ -0,0 +1,334 @@
/**
* Pozvanky do firmy.
*
* Dva druhy cest v jednom souboru, protoze patri k sobe:
* - **za prihlasenim**: spravce firmy pozvanku vytvori, vidi seznam a muze ji
* zrusit,
* - **verejne**: kdo dostal odkaz, si prohledne, kam ho zve, a prijme ji.
*
* Verejna cast je zamerne uzka: rekne jen nazev firmy a jestli je pozvanka
* platna. Nic o tom, kdo ve firme je, ani co obsahuje - kod v adrese je jedina
* ochrana a odkaz se snadno dostane, kam nema.
*/
import { Router } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import { publicBaseUrl } from '../config.js';
import {
findByCode,
inviteProblem,
inviteStore,
listInvites,
markUsed,
newInvite,
refreshInvites,
} from '../data/invites.js';
import { rolesFor, type Role } from '../data/permissions.js';
import { personStore, refreshPeople, listPeople } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import bcrypt from 'bcryptjs';
import {
findUserByEmail,
hashPassword,
listAllUsers,
refreshUsers,
userStore,
} from '../data/users.js';
import { nowIso } from '../data/store/index.js';
import { requireAuth } from '../middleware/auth.js';
import { currentTenant } from './crud.js';
/** Verejne cesty. Bez prihlaseni, autorizuje kod v adrese. */
export const publicInviteRouter = Router();
/** Cesty za prihlasenim: sprava pozvanek. */
export const inviteRouter = Router();
// ------------------------------------------------------------------ verejne
/**
* Co je za timhle odkazem.
*
* Vraci minimum: nazev firmy a jestli to jde prijmout. Kdyz uz e-mail znamy,
* rekne ho, aby ho clovek nemusel psat - ale nic navic.
*/
publicInviteRouter.get('/:code', (req, res) => {
const invite = findByCode(req.params.code);
if (!invite) {
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
}
const problem = inviteProblem(invite);
const tenant = findTenant(invite.tenantId);
return res.json({
tenant: tenant?.name ?? 'Neznámá firma',
email: invite.email || null,
/** Uz ucet ma? Pak staci heslo, ne registrace. */
knownUser: invite.email ? findUserByEmail(invite.email) !== undefined : false,
valid: problem === null,
problem,
});
});
const acceptSchema = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email(),
password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'),
});
/**
* Prijeti pozvanky.
*
* Tri pripady:
* - ucet neexistuje: zalozi se a pripoji k firme,
* - ucet existuje a heslo sedi: jen se pripoji k firme,
* - ucet existuje a heslo nesedi: odmitnuto.
*
* Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady.
*/
publicInviteRouter.post('/:code/accept', (req, res) => {
const invite = findByCode(req.params.code);
if (!invite) {
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
}
const problem = inviteProblem(invite);
if (problem) return res.status(409).json({ error: 'invalid_invite', message: problem });
const parsed = acceptSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
const email = parsed.data.email.toLowerCase();
/*
* Kdyz je pozvanka na konkretni adresu, musi souhlasit. Bez toho by odkaz
* poslany dal fungoval komukoliv, coz u pristupu do firmy neni v poradku.
*/
if (invite.email && invite.email !== email) {
return res.status(403).json({
error: 'wrong_email',
message: 'Tahle pozvánka je pro jinou e-mailovou adresu.',
});
}
const existing = findUserByEmail(email);
return void (async () => {
let userId: string;
if (existing) {
// Ucet uz je. Pripojime ho k firme, ale jen kdyz prokaze, ze je jeho.
/*
* Ucet uz existuje, takze musi prokazat, ze je jeho. Bez toho by kdokoliv
* s odkazem pripojil cizi adresu ke sve firme a videl by jeji data.
*/
const stored = listAllUsers().find((item) => item.id === existing.id);
const passwordOk =
stored !== undefined && (await bcrypt.compare(parsed.data.password, stored.passwordHash));
if (!passwordOk) {
return res.status(401).json({
error: 'wrong_password',
message: 'S touhle adresou už účet existuje. Zadejte k němu svoje heslo.',
});
}
const already = existing.memberships.some(
(membership) => membership.tenantId === invite.tenantId,
);
if (!already) {
await userStore.update(
existing.id,
{
memberships: [
...existing.memberships,
{ tenantId: invite.tenantId, roleIds: invite.roleIds },
],
},
{ tenantIds: [], includeGlobal: true },
);
}
userId = existing.id;
} else {
const timestamp = nowIso();
const created = await userStore.create({
id: `usr_${Math.random().toString(36).slice(2, 10)}`,
tenantId: null,
email,
name: parsed.data.name,
passwordHash: hashPassword(parsed.data.password),
platformAdmin: false,
enabled: true,
memberships: [{ tenantId: invite.tenantId, roleIds: invite.roleIds }],
createdAt: timestamp,
updatedAt: timestamp,
});
userId = created.id;
}
await refreshUsers();
/*
* Resitel je nekdo, komu jde prehodit ticket. Neni to totez co ucet:
* ucetni muze mit pristup do portalu, aniz by kdy resila ticket.
*/
if (invite.asPerson) {
const alreadyPerson = listPeople([invite.tenantId]).some(
(person) => person.email.toLowerCase() === email,
);
if (!alreadyPerson) {
const timestamp = nowIso();
await personStore.create({
id: `ppl_${Math.random().toString(36).slice(2, 10)}`,
tenantId: invite.tenantId,
name: parsed.data.name,
email,
role: '',
capacity: 8,
enabled: true,
externalIds: [],
createdAt: timestamp,
updatedAt: timestamp,
});
await refreshPeople();
}
}
await markUsed(invite, userId);
recordAudit({
userId,
userEmail: email,
tenantId: invite.tenantId,
action: 'invite.accept',
target: invite.id,
detail: { pozval: invite.createdByEmail },
});
console.info(`[pozvanky] ${email} prijal pozvanku do ${invite.tenantId}`);
return res.status(201).json({ ok: true, email });
})().catch((err: unknown) => {
console.error('[pozvanky] prijeti selhalo:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo přijmout.' });
});
});
// ----------------------------------------------------------------- za prihlasenim
inviteRouter.use(requireAuth);
/** Kdo smi zvat. Zvat lidi do firmy je totez co spravovat uzivatele. */
const PERMISSION = 'user.manage';
inviteRouter.get('/', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
const base = `${publicBaseUrl()}/pozvanka/`;
return res.json({
items: listInvites([tenantId]).map((invite) => ({
...invite,
/** Cela adresa, at ji jde rovnou zkopirovat a poslat. */
url: `${base}${invite.code}`,
problem: inviteProblem(invite),
})),
roles: rolesFor(tenantId).map((role: Role) => ({
id: role.id,
key: role.key,
name: role.name,
})),
});
});
const createSchema = z.object({
email: z.string().trim().email().optional().or(z.literal('')),
note: z.string().trim().max(120).optional(),
roleIds: z.array(z.string().min(1)).min(1, 'Vyberte aspoň jednu roli.'),
asPerson: z.boolean().default(true),
});
inviteRouter.post('/', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
const parsed = createSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
const invite = newInvite({
tenantId,
email: parsed.data.email || undefined,
note: parsed.data.note,
roleIds: parsed.data.roleIds,
asPerson: parsed.data.asPerson,
createdByEmail: req.user!.email,
});
return void inviteStore
.create(invite)
.then(async () => {
await refreshInvites();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'invite.create',
target: invite.id,
detail: { email: invite.email || '(komukoliv)' },
});
return res.status(201).json({
...invite,
url: `${publicBaseUrl()}/pozvanka/${invite.code}`,
});
})
.catch((err: unknown) => {
console.error('[pozvanky] zalozeni selhalo:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo vytvořit.' });
});
});
/** Zruseni pozvanky. Odkaz okamzite prestane platit. */
inviteRouter.delete('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
if (!tenantId) return;
return void inviteStore
.remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false })
.then(async (removed) => {
if (!removed) {
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
}
await refreshInvites();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'invite.delete',
target: req.params.id,
});
return res.status(204).end();
})
.catch((err: unknown) => {
console.error('[pozvanky] zruseni selhalo:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo zrušit.' });
});
});
export { PERMISSION as invitePermission };
+85 -2
View File
@@ -30,6 +30,13 @@ export interface StepContext {
tenantId: string;
/** Ticket, ke kteremu beh patri. Nekdy vznikne az behem nej. */
ticketId: string | null;
/**
* Data, kterymi beh zacal - u webhooku cele prijate telo.
*
* Ulozi se do udalosti ticketu, kdyz krok nema vlastni. Bez toho by v historii
* bylo prazdno a na otazku "co nam vlastne prislo" by neslo odpovedet.
*/
trigger?: Record<string, unknown>;
}
export interface StepOutcome {
@@ -129,8 +136,21 @@ const handlers: Record<string, Handler> = {
externalId: inputs.externalId?.trim() || null,
externalSource: inputs.source?.trim() || 'automatizace',
type: inputs.event?.trim() || 'automation',
label: inputs.label?.trim() || subject || 'Událost',
payload: inputs.payload ? safeJson(inputs.payload) : {},
/*
* Popisek do casove osy. "Udalost" je posledni moznost: kdyz v nem stoji
* u vsech radku totez, casova osa nerika nic.
*/
label:
inputs.label?.trim() ||
subject ||
(inputs.status?.trim() ? `stav ${inputs.status.trim()}` : '') ||
inputs.externalId?.trim() ||
'Událost',
/*
* Bez vlastnich dat se ulozi to, cim beh zacal. Prazdna udalost je horsi
* nez zadna: v historii se tvari, ze se neco stalo, a nerekne co.
*/
payload: inputs.payload ? safeJson(inputs.payload) : (context.trigger ?? {}),
fields,
create: {
subject: subject || inputs.externalId || 'Bez předmětu',
@@ -180,6 +200,26 @@ const handlers: Record<string, Handler> = {
if (closed !== undefined) note += closed ? ', vyřízený' : ', nevyřízený';
}
/*
* Odesilatel poslal totez co posledne. Neni to chyba, ale musi to byt videt,
* jinak nikdo nepozna, ze proti nam neco tluce ve smycce.
*/
if (result.repeated) {
return {
ok: true,
summary: `bez změny, ${result.ticket.id} už tuhle zprávu dostal (${result.event.repeats}x)`,
detail: null,
ticketId: result.ticket.id,
outputs: {
ticketId: result.ticket.id,
created: false,
externalId: result.ticket.externalId,
repeated: true,
repeats: result.event.repeats,
},
};
}
return {
ok: true,
summary:
@@ -194,6 +234,49 @@ const handlers: Record<string, Handler> = {
};
},
/**
* Preda ticket skupine.
*
* Zaskrtnute "rovnou prirad" znamena, ze ho system hned da nejvolnejsimu.
* Nezaskrtnute nechava ticket ve **fronte skupiny** a kdo ma cas, si ho
* vezme sam - to je pro vetsinu tymu spravne, protoze clovek sam vi, kdy
* na dalsi praci ma. Automat se hodi tam, kde se ceka na rychlou reakci
* a nikdo nema cas koukat do fronty.
*/
'ticket/assign-group': (inputs, context) => {
const ticketId = inputs.ticketId?.trim() || context.ticketId;
if (!ticketId) return missing('ticket');
const groupId = inputs.groupId?.trim();
if (!groupId) return missing('skupina');
const group = findGroup(groupId);
if (!group || group.tenantId !== context.tenantId) {
return { ok: false, summary: `skupina ${groupId} neexistuje`, outputs: {} };
}
const updated = assignTicketGroup(ticketId, groupId, [context.tenantId]);
if (!updated) return { ok: false, summary: 'přiřazení skupině se nepodařilo', outputs: {} };
if (boolInput(inputs.autoAssign) !== true) {
return {
ok: true,
summary: `${group.name}, čeká ve frontě skupiny`,
detail: null,
outputs: { groupId: group.id, groupName: group.name },
};
}
// Zaskrtnuto: rovnou tomu, kdo ma nejmene prace.
const handler = handlers['ticket/assign-least-busy'];
const outcome = handler({ ticketId, groupId }, context) as StepOutcome;
return {
...outcome,
summary: `${group.name}: ${outcome.summary}`,
outputs: { groupId: group.id, groupName: group.name, ...outcome.outputs },
};
},
/**
* Preda ticket **nejvolnejsimu** cloveku ze skupiny.
*
+10
View File
@@ -15,7 +15,17 @@ import { AsyncLocalStorage } from 'node:async_hooks';
export interface RunMarker {
runId: string;
automationId: string;
/** Jmeno, ne ID. V logu ticketu ho cte clovek, ne stroj. */
automationName: string;
tenantId: string;
/**
* Radek logu, pod ktery patri zmeny tohohle behu.
*
* Log ma byt citelny jako "prislo tohle a zpusobilo to tuhle zmenu". Bez
* spolecneho rodice je z nej seznam nesouvisejicich vet a nikdo nepozna, co
* zpusobilo co. Menitelna schranka proto, ze radek vznika az v prubehu behu.
*/
traceParent: { id: string | null };
}
const storage = new AsyncLocalStorage<RunMarker>();
+10
View File
@@ -47,6 +47,13 @@ export interface RunOptions {
idempotencyKey: string | null;
/** Kolik kroku se nejvys vykona. Pojistka proti nechtene dlouhemu stromu. */
maxSteps?: number;
/**
* Data, kterymi beh zacal. Doplni si je `runFlow` samo.
*
* Kroky si je predavaji jako kontext, ale ten se cestou plni vystupy. Pro
* zapis do udalosti ticketu je potreba puvodni prijata zprava, ne smes.
*/
trigger?: Record<string, unknown>;
}
export interface StepResult {
@@ -100,6 +107,8 @@ export async function runFlow(
const startedAt = Date.now();
const results: StepResult[] = [];
const working: RunContext = { ...context };
// Puvodni data se drzi zvlast: `working` se cestou plni vystupy kroku.
options.trigger = { ...context };
const limit = options.maxSteps ?? MAX_STEPS;
let error: string | null = null;
@@ -182,6 +191,7 @@ async function runAction(
const outcome = await builtin(inputsForStep, {
tenantId: options.tenantId,
ticketId: options.ticketId,
trigger: options.trigger,
});
if (outcome.ticketId && !options.ticketId) {
+7 -1
View File
@@ -106,7 +106,13 @@ async function runOne(item: QueueItem): Promise<void> {
* ticketu ji spusti, a tak porad.
*/
const result = await withRun(
{ runId: item.id, automationId: item.automationId, tenantId: item.tenantId },
{
runId: item.id,
automationId: item.automationId,
automationName: automation.name,
tenantId: item.tenantId,
traceParent: { id: null },
},
() =>
runFlow(automation.flow.steps, item.payload, {
tenantId: item.tenantId,
+4
View File
@@ -9,6 +9,7 @@ import { Spinner } from '@/components/ui/Spinner';
import Home from '@/pages/Home';
import About from '@/pages/About';
import Contact from '@/pages/Contact';
import Invite from '@/pages/Invite';
import Login from '@/pages/Login';
import NotFound from '@/pages/NotFound';
import Services from '@/pages/Services';
@@ -48,6 +49,9 @@ export default function App() {
{/* Prihlaseni - vlastni layout bez hlavicky a paticky */}
<Route path="/prihlaseni" element={<Login />} />
{/* Pozvanka do firmy. Verejna: kdo sem prijde, jeste ucet mit nemusi. */}
<Route path="/pozvanka/:code" element={<Invite />} />
{/* Klientsky portal */}
<Route element={<RequireAuth />}>
<Route
@@ -0,0 +1,265 @@
import { Check, Copy, Link2, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
/**
* Pozvanky do firmy.
*
* Spravce vytvori odkaz, posle ho, jak chce, a clovek si za nim vyplni jmeno
* a heslo. Heslo se **nikdy neposila** - nastavuje si ho sam az na konci.
*
* Odkaz se ukazuje cely a s tlacitkem kopirovat: relativni cesta se do zpravy
* vlepit neda a rucni prepisovani kodu o 32 znacich je omyl cekajici na sve
* misto.
*/
interface InviteRow {
id: string;
code: string;
url: string;
email: string;
note: string;
roleIds: string[];
asPerson: boolean;
expiresAt: string;
usedAt: string | null;
createdByEmail: string;
createdAt: string;
problem: string | null;
}
interface InvitesResponse {
items: InviteRow[];
roles: Array<{ id: string; key: string; name: string }>;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function InvitePanel() {
const invites = useApiQuery<InvitesResponse>('/api/dashboard/invites');
const [email, setEmail] = useState('');
const [note, setNote] = useState('');
const [roleIds, setRoleIds] = useState<string[]>([]);
const [asPerson, setAsPerson] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const roles = invites.data?.roles ?? [];
const items = invites.data?.items ?? [];
async function create() {
setBusy(true);
setError(null);
try {
await apiFetch('/api/dashboard/invites', {
method: 'POST',
body: { email, note, roleIds, asPerson },
});
setEmail('');
setNote('');
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo vytvořit.');
} finally {
setBusy(false);
}
}
async function remove(id: string) {
try {
await apiFetch(`/api/dashboard/invites/${id}`, { method: 'DELETE' });
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo zrušit.');
}
}
async function copy(url: string, id: string) {
try {
await navigator.clipboard.writeText(url);
setCopied(id);
window.setTimeout(() => setCopied(null), 1600);
} catch (err) {
console.warn('[pozvanky] schranka neni dostupna:', err);
}
}
return (
<div className="space-y-5">
<section className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Pozvat do firmy</h2>
<p className="mt-1 text-sm text-white/50">
Vznikne odkaz s jednorázovým kódem. Pošlete ho, jak chcete. Kdo ho otevře, vyplní
si jméno a heslo a je uvnitř. Platí týden.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="nepovinné"
className={inputClass}
/>
<span className="mt-1 block text-xs text-white/35">
Když ho vyplníte, odkaz přijme jen tahle adresa. Prázdné = kdokoliv s odkazem.
</span>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">Poznámka</span>
<input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="komu jsem to poslal"
className={inputClass}
/>
</label>
</div>
<fieldset className="mt-4">
<legend className="mb-1 text-sm text-white/70">Role ve firmě</legend>
<div className="flex flex-wrap gap-2">
{roles.map((role) => {
const on = roleIds.includes(role.id);
return (
<button
key={role.id}
type="button"
onClick={() =>
setRoleIds((prev) =>
on ? prev.filter((id) => id !== role.id) : [...prev, role.id],
)
}
className={
on
? 'rounded-lg border border-brand-400/60 bg-brand-500/15 px-3 py-1.5 text-sm text-brand-100'
: 'rounded-lg border border-ink-600/70 px-3 py-1.5 text-sm text-white/60 hover:text-white'
}
>
{role.name}
</button>
);
})}
</div>
</fieldset>
<label className="mt-4 flex items-start gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={asPerson}
onChange={(event) => setAsPerson(event.target.checked)}
className="mt-0.5 size-4 accent-brand-400"
/>
<span>
Založit i jako řešitele
<span className="mt-0.5 block text-xs text-white/35">
Řešitel je ten, komu jde přehodit ticket. Účetní může mít přístup do portálu,
aniž by kdy ticket řešila.
</span>
</span>
</label>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<Button
onClick={() => void create()}
disabled={busy || roleIds.length === 0}
className="mt-4"
>
{busy ? 'Vytvářím...' : 'Vytvořit odkaz'}
</Button>
{roleIds.length === 0 && (
<span className="ml-3 text-xs text-white/35">Vyberte aspoň jednu roli.</span>
)}
</section>
<DataState
loading={invites.loading}
error={invites.error}
onRetry={invites.reload}
empty={items.length === 0}
emptyLabel="Zatím žádné pozvánky."
>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full min-w-150 border-collapse text-sm">
<thead>
<tr className="border-b border-ink-600/50 text-left text-xs tracking-wide text-white/35 uppercase">
<th className="px-4 py-3 font-medium">Pro koho</th>
<th className="px-4 py-3 font-medium">Odkaz</th>
<th className="px-4 py-3 font-medium">Stav</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{items.map((invite) => (
<tr key={invite.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2.5">
<span className="font-medium text-white">
{invite.email || 'kdokoliv s odkazem'}
</span>
{invite.note && (
<span className="mt-0.5 block text-xs text-white/40">{invite.note}</span>
)}
</td>
<td className="px-4 py-2.5">
<button
type="button"
onClick={() => void copy(invite.url, invite.id)}
className="flex max-w-90 items-center gap-2 text-left text-xs text-white/45 transition-colors hover:text-brand-200"
title={invite.url}
>
{copied === invite.id ? (
<Check className="size-3.5 shrink-0 text-ok-400" />
) : (
<Copy className="size-3.5 shrink-0" />
)}
<span className="truncate">{invite.url}</span>
</button>
</td>
<td className="px-4 py-2.5">
{invite.usedAt ? (
<Badge tone="ok">použitá {formatDateTime(invite.usedAt)}</Badge>
) : invite.problem ? (
<Badge tone="warn">{invite.problem}</Badge>
) : (
<span className="text-white/45">
platí do {formatDateTime(invite.expiresAt)}
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => void remove(invite.id)}
title="Zrušit pozvánku"
className="text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</DataState>
<p className="flex items-start gap-2 text-xs text-white/35">
<Link2 className="mt-0.5 size-3.5 shrink-0" />
Odkaz je jediná ochrana, proto má náhodný kód a jde kdykoliv zrušit. Po použití
přestane platit sám.
</p>
</div>
);
}
+15 -2
View File
@@ -18,7 +18,7 @@ export function TicketEvents({ events }: { events: TicketEvent[] }) {
if (events.length === 0) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Žádné příchozí události. Ticket vznikl v portálu nebo ze simulace.
Žádné příchozí události. Ticket vznikl v portálu nebo ručně.
</p>
);
}
@@ -49,11 +49,24 @@ function EventRow({ event }: { event: TicketEvent }) {
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-white/80">{event.label}</span>
<span className="flex flex-wrap items-center gap-2 text-sm text-white/80">
{event.label}
{/*
Opakovani jako informace u radku, ne jako dalsi radek. Odesilatel
umi poslat totez osmdesatkrat a osmdesat radku znamena, ze
v historii nikdo nic nenajde.
*/}
{event.repeats > 1 && (
<span className="rounded-md bg-warn-400/15 px-1.5 py-0.5 text-xs font-semibold text-warn-300">
{event.repeats}x beze změny
</span>
)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/35">
<code className="text-brand-300/80">{event.type}</code>
<span>{event.source}</span>
<span>{formatDateTime(event.at)}</span>
{event.repeats > 1 && <span>naposledy {formatDateTime(event.lastAt)}</span>}
</span>
</span>
+1 -1
View File
@@ -47,7 +47,7 @@ function ReferenceCard({ reference }: { reference: Reference }) {
</div>
<blockquote className="mt-5 flex-1 text-[0.975rem] leading-relaxed text-white/70">
„{reference.quote}“
"{reference.quote}"
</blockquote>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-ink-600/60 pt-4">
+1 -1
View File
@@ -62,7 +62,7 @@ export const products: Product[] = [
name: 'Dashboardy a reporting',
tagline: 'Jedna obrazovka místo pěti exportů',
description:
'Data z různých zdrojů na jednom místě — v reálném čase, s historií a s alerty, když se něco vymkne.',
'Data z různých zdrojů na jednom místě, v reálném čase, s historií a s alerty, když se něco vymkne.',
icon: BarChart3,
features: ['Metriky v reálném čase', 'Automatické reporty e-mailem', 'Přístupy podle rolí'],
metric: { value: '5 s', label: 'aktualizace dat' },
+179
View File
@@ -0,0 +1,179 @@
import { AlertCircle, Building2, CheckCircle2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Button } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
/**
* Prijeti pozvanky do firmy.
*
* Verejna stranka: kdo sem prijde, jeste neni prihlaseny. Kod v adrese je
* jedina ochrana, proto se tu neukazuje nic o firme krome jmena.
*
* Kdyz uz clovek ucet ma, zada k nemu svoje heslo a jen se pripoji k dalsi
* firme. Heslo se nikdy neposila mailem - nastavuje si ho sam tady.
*/
interface InviteInfo {
tenant: string;
email: string | null;
knownUser: boolean;
valid: boolean;
problem: string | null;
}
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
export default function Invite() {
const { code = '' } = useParams();
const navigate = useNavigate();
const { login } = useAuth();
usePageMeta({ title: 'Pozvánka - portál Automia' });
const [info, setInfo] = useState<InviteInfo | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
apiFetch<InviteInfo>(`/api/invites/${code}`, { auth: false })
.then((data) => {
setInfo(data);
if (data.email) setEmail(data.email);
})
.catch((err: unknown) => {
setLoadError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo načíst.');
});
}, [code]);
async function accept() {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/invites/${code}/accept`, {
method: 'POST',
auth: false,
body: { name, email, password },
});
setDone(true);
// Rovnou prihlasit: clovek uz heslo zadal, druhy formular by byl obtez.
await login(email, password);
navigate('/dashboard', { replace: true });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo přijmout.');
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-screen items-center py-16">
<Container>
<div className="glass mx-auto max-w-md rounded-card p-6 sm:p-8">
{loadError || (info && !info.valid) ? (
<>
<p className="flex items-start gap-2 text-danger-400">
<AlertCircle className="mt-0.5 size-5 shrink-0" />
<span>{info?.problem ?? loadError}</span>
</p>
<p className="mt-4 text-sm text-white/50">
Požádejte o novou pozvánku toho, kdo vám poslal odkaz.
</p>
<Link
to="/"
className="mt-4 inline-block text-sm font-medium text-brand-300 hover:text-brand-200"
>
Zpět na úvod
</Link>
</>
) : done ? (
<p className="flex items-center gap-2 text-ok-400">
<CheckCircle2 className="size-5" />
Hotovo, přesměrovávám do portálu.
</p>
) : (
<>
<p className="flex items-center gap-2 text-sm text-white/45">
<Building2 className="size-4 text-brand-300" />
Pozvánka do firmy
</p>
<h1 className="mt-1 text-2xl font-bold text-white">
{info?.tenant ?? 'Načítám...'}
</h1>
<p className="mt-2 text-sm text-white/55">
{info?.knownUser
? 'S touhle adresou už účet máte. Zadejte k němu svoje heslo a připojíte se k firmě.'
: 'Vyplňte jméno a heslo. Účet vznikne hned a rovnou vás přihlásíme.'}
</p>
<div className="mt-6 space-y-4">
<label className="block">
<span className="mb-1 block text-sm text-white/70">Jméno a příjmení</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/* Kdyz je pozvanka na konkretni adresu, nejde ji zmenit. */
readOnly={Boolean(info?.email)}
autoComplete="email"
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-white/70">
{info?.knownUser ? 'Vaše heslo' : 'Nové heslo'}
</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={info?.knownUser ? 'current-password' : 'new-password'}
className={inputClass}
/>
{!info?.knownUser && (
<span className="mt-1 block text-xs text-white/35">Aspoň 8 znaků.</span>
)}
</label>
{error && (
<p className="flex items-start gap-2 text-sm text-danger-400">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
)}
<Button
onClick={() => void accept()}
disabled={busy || name.trim().length < 2 || password.length < 8}
className="w-full justify-center"
>
{busy ? 'Odesílám...' : 'Přijmout pozvánku'}
</Button>
</div>
</>
)}
</div>
</Container>
</main>
);
}
@@ -403,6 +403,8 @@ export default function AutomationDetail() {
<Row label="Vytvořeno" value={formatDateTime(automation.data.createdAt)} />
<Row label="Poslední úprava" value={formatDateTime(automation.data.updatedAt)} />
<Row label="Spuštění dnes" value={String(automation.data.runsToday)} />
<Row label="Spuštění včera" value={String(automation.data.runsYesterday)} />
<Row label="Spuštění celkem" value={String(automation.data.runsTotal)} />
<Row label="Úspěšnost" value={`${automation.data.successRate} %`} />
</dl>
</div>
+8 -2
View File
@@ -132,7 +132,11 @@ function AutomationCard({ automation }: { automation: Automation }) {
</div>
<dl className="mt-5 grid grid-cols-2 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-4">
<Metric label="Dnes" value={formatNumber(automation.runsToday)} />
<Metric
label="Dnes"
value={formatNumber(automation.runsToday)}
note={`včera ${formatNumber(automation.runsYesterday)}`}
/>
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
<Metric
label="Prům. běh"
@@ -144,11 +148,13 @@ function AutomationCard({ automation }: { automation: Automation }) {
);
}
function Metric({ label, value }: { label: string; value: string }) {
function Metric({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<div>
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm font-semibold text-white tabular-nums">{value}</dd>
{/* Samo cislo za dnesek nic nerika, dokud neni s cim srovnat. */}
{note && <dd className="text-xs text-white/30 tabular-nums">{note}</dd>}
</div>
);
}
+144 -3
View File
@@ -1,13 +1,21 @@
import { Mail, Users } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { EntityAdmin } from '@/components/dashboard/EntityAdmin';
import { InvitePanel } from '@/components/dashboard/InvitePanel';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { PeopleResponse, Workload } from '@/types/dashboard';
import type { Access, PeopleResponse, Workload } from '@/types/dashboard';
interface NamedRecord {
id: string;
name?: string;
[key: string]: unknown;
}
/**
* Lide firmy: kdo resi tickety a jak na tom je.
@@ -19,6 +27,8 @@ export default function People() {
usePageMeta({ title: 'Lidé - portál Automia' });
const [view, setView] = useState<ListView>('table');
const [tab, setTab] = useState('prehled');
const access = useApiQuery<Access>('/api/dashboard/access');
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
@@ -29,6 +39,29 @@ export default function People() {
const byPerson = new Map(rows.map((row) => [row.person.id, row]));
const items = people.data?.items ?? [];
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
return (permission: string) => owned.has(permission);
}, [access.data]);
/** Cleny skupiny se vybiraji klikanim. Opisovat ID je zdroj preklepu. */
const personOptions = useMemo(
() => items.map((person) => ({ value: person.id, label: person.name })),
[items],
);
/*
* Vsechno o lidech je na jedne strance, ne v nastaveni. Pozvat kolegu
* a zalozit skupinu neni nastaveni portalu, je to bezna denni prace - a dokud
* to bylo schovane v nastaveni, nikdo to nenasel.
*/
const tabs = [
{ key: 'prehled', label: 'Přehled', show: true },
{ key: 'resitele', label: 'Řešitelé', show: can('people.manage') },
{ key: 'skupiny', label: 'Skupiny', show: can('group.manage') },
{ key: 'pozvanky', label: 'Pozvánky', show: can('user.manage') },
].filter((item) => item.show);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
@@ -38,9 +71,31 @@ export default function People() {
Kdo řeší tickety, co má u sebe a jak dlouho mu to leží.
</p>
</div>
<ViewSwitch view={view} onChange={setView} />
{tab === 'prehled' && <ViewSwitch view={view} onChange={setView} />}
</header>
{tabs.length > 1 && (
<div className="flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setTab(item.key)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
tab === item.key
? 'bg-brand-500/15 text-brand-100'
: 'text-white/50 hover:text-white',
)}
>
{item.label}
</button>
))}
</div>
)}
{tab === 'prehled' && (
<>
{workload.data && (
<div className="flex flex-wrap gap-3 text-sm">
<Stat label="Nevyřízených celkem" value={workload.data.openTotal} />
@@ -174,6 +229,92 @@ export default function People() {
</div>
</section>
)}
</>
)}
{tab === 'resitele' && (
<EntityAdmin
title="Řešitelé"
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
onChanged={people.reload}
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{
label: 'Kapacita',
narrow: true,
render: (person: NamedRecord) => String(person.capacity ?? ''),
},
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? (
<Badge tone="warn">vypnutý</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
),
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{
name: 'role',
label: 'Zaměření',
kind: 'text',
hint: 'Jen popisek, nic nerozhoduje.',
},
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{
name: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'skupiny' && (
<EntityAdmin
title="Skupiny řešitelů"
description="Ticket může ležet ve frontě skupiny. Kdo je ve skupině, si ho převezme sám."
endpoint="/api/dashboard/settings/groups"
onChanged={people.reload}
columns={[
{ label: 'Název', render: (group: NamedRecord) => String(group.name ?? '') },
{
label: 'Členů',
narrow: true,
render: (group: NamedRecord) =>
String((group.personIds as string[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'personIds',
label: 'Členové',
kind: 'multiselect',
options: personOptions,
hint: 'Automatické přidělování se nastavuje v kroku automatizace, ne tady.',
},
]}
/>
)}
{tab === 'pozvanky' && <InvitePanel />}
</div>
);
}
+5 -70
View File
@@ -17,7 +17,9 @@ import type { Access } from '@/types/dashboard';
* jeden zaznam v `tabs` nize.
*
* Akce a widgety tady **nejsou**: jsou to definice toho, co aplikace umi,
* stejna uroven jako automatizace, a maji vlastni zalozku.
* stejna uroven jako automatizace, a maji vlastni zalozku. Resitele, skupiny
* a pozvanky taky ne - patri k lidem, protoze pozvat kolegu je denni prace,
* ne nastaveni portalu.
*/
interface CatalogItem {
@@ -74,8 +76,6 @@ export default function Settings() {
const tabs = useMemo(() => {
const all: Array<{ key: string; label: string; show: boolean }> = [
{ key: 'ucet', label: 'Můj účet', show: true },
{ key: 'people', label: 'Řešitelé', show: can('people.manage') },
{ key: 'groups', label: 'Skupiny', show: can('group.manage') },
{ key: 'roles', label: 'Role a práva', show: can('role.manage') },
{ key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') },
{ key: 'users', label: 'Uživatelé', show: platformAdmin },
@@ -91,8 +91,8 @@ export default function Settings() {
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">
Firmy, lidé, role a typy ticketů. Akce a widgety mají vlastní záložku,
jsou to definice, ne nastavení.
Firmy, role a typy ticketů. Řešitele, skupiny a pozvánky najdete
v záložce Lidé, akce a widgety mají vlastní záložku.
</p>
</header>
@@ -116,71 +116,6 @@ export default function Settings() {
{tab === 'ucet' && <MyAccount />}
{tab === 'people' && (
<EntityAdmin
title="Řešitelé"
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{ label: 'Kapacita', narrow: true, render: (person: NamedRecord) => String(person.capacity ?? '') },
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? <Badge tone="warn">vypnutý</Badge> : <Badge tone="ok">aktivní</Badge>,
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{ name: 'role', label: 'Zaměření', kind: 'text', hint: 'Jen popisek, nic nerozhoduje.' },
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{
name: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'groups' && (
<EntityAdmin
title="Skupiny řešitelů"
description="Aby šlo říct: tohle je pro účetní, bez řešení, kdo z nich má dovolenou."
endpoint="/api/dashboard/settings/groups"
columns={[
{ label: 'Název', render: (group: NamedRecord) => String(group.name ?? '') },
{
label: 'Členů',
narrow: true,
render: (group: NamedRecord) => String((group.personIds as string[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'personIds',
label: 'Členové',
kind: 'tags',
hint: 'ID řešitelů oddělená čárkou. Najdete je v záložce Řešitelé.',
},
]}
/>
)}
{tab === 'roles' && (
<EntityAdmin
title="Role a práva"
+16
View File
@@ -278,6 +278,22 @@ export default function TicketDetail() {
{ticket.data.assignee ? ticket.data.assignee.name : 'Ve frontě, bez řešitele'}
</p>
{/*
Prevzeti: kdyz ticket lezi ve fronte skupiny, vezme si ho
clovek sam. Je to jina vec nez prehazovani prace shora, proto
tlacitko a ne polozka v selectu.
*/}
{people.data?.meId && ticket.data.assignee?.id !== people.data.meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {}, 'Převzetí se nepodařilo.')}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
+9
View File
@@ -79,6 +79,10 @@ export interface TicketEvent {
label: string;
payload: Record<string, unknown>;
at: string;
/** Kolikrat prislo totez. Vic nez 1 znamena, ze odesilatel posila opakovane. */
repeats: number;
/** Kdy naposledy. Same jako `at`, dokud se neopakuje. */
lastAt: string;
}
export interface Ticket {
@@ -208,7 +212,12 @@ export interface Automation {
name: string;
kind: AutomationKind;
enabled: boolean;
/** Behy za dnesni den. Po pulnoci zacina od nuly. */
runsToday: number;
/** Behy za vcerejsek, at je s cim srovnat. */
runsYesterday: number;
/** Behy od zalozeni automatizace. */
runsTotal: number;
successRate: number;
avgDurationMs: number;
lastRunAt: string;