diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 6099b04..f3ed90a 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -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 | diff --git a/documentation/04-api.md b/documentation/04-api.md index 011c5a9..90cd594 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -48,8 +48,14 @@ Vyzaduji `Authorization: Bearer `: | 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). diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 71ab628..746ec8e 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -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: diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 569f944..46f0d1f 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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 diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 1449c63..9e3ce76 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -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); diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 568fe1c..93c4550 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -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 }, { store: notificationStore as EntityStore }, + { store: inviteStore as EntityStore }, ]; /** @@ -93,6 +95,7 @@ export async function refreshCaches(): Promise { refreshActions(), refreshCustomWidgets(), refreshNotifications(), + refreshInvites(), ]); // Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni. diff --git a/src/data/invites.ts b/src/data/invites.ts new file mode 100644 index 0000000..040ba3f --- /dev/null +++ b/src/data/invites.ts @@ -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'); +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 { + 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 { + await inviteStore.update( + invite.id, + { usedAt: nowIso(), usedByUserId: userId }, + { tenantIds: [invite.tenantId], includeGlobal: true }, + ); + await cache.refresh(); +} diff --git a/src/data/people.ts b/src/data/people.ts index 6e684f4..2387527 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -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'); const groupCache = withCache(groupStore); diff --git a/src/data/services.ts b/src/data/services.ts index 22e43be..23e7c47 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -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í.', }, { diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 6dca92a..978f170 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -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; 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 { 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 | 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, diff --git a/src/index.ts b/src/index.ts index a770a24..2138677 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/openapi.ts b/src/openapi.ts index dbcb21c..9ad239e 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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' }, diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 71db5a4..7ba0cdc 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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); diff --git a/src/routes/invites.ts b/src/routes/invites.ts new file mode 100644 index 0000000..ac00e9e --- /dev/null +++ b/src/routes/invites.ts @@ -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 }; diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 14a6273..b955a7c 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -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; } export interface StepOutcome { @@ -129,8 +136,21 @@ const handlers: Record = { 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 = { 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 = { }; }, + /** + * 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. * diff --git a/src/runtime/context.ts b/src/runtime/context.ts index 12d7aca..c908fc0 100644 --- a/src/runtime/context.ts +++ b/src/runtime/context.ts @@ -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(); diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index f2248c9..143c371 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -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; } 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) { diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts index c9833e6..dbffd22 100644 --- a/src/runtime/worker.ts +++ b/src/runtime/worker.ts @@ -106,7 +106,13 @@ async function runOne(item: QueueItem): Promise { * 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, diff --git a/web/src/App.tsx b/web/src/App.tsx index 441dcc9..8f73498 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 */} } /> + {/* Pozvanka do firmy. Verejna: kdo sem prijde, jeste ucet mit nemusi. */} + } /> + {/* Klientsky portal */} }> ; +} + +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('/api/dashboard/invites'); + + const [email, setEmail] = useState(''); + const [note, setNote] = useState(''); + const [roleIds, setRoleIds] = useState([]); + const [asPerson, setAsPerson] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(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 ( +
+
+

Pozvat do firmy

+

+ 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. +

+ +
+ + + +
+ +
+ Role ve firmě +
+ {roles.map((role) => { + const on = roleIds.includes(role.id); + return ( + + ); + })} +
+
+ + + + {error &&

{error}

} + + + {roleIds.length === 0 && ( + Vyberte aspoň jednu roli. + )} +
+ + +
+ + + + + + + + + + {items.map((invite) => ( + + + + + + + ))} + +
Pro kohoOdkazStav +
+ + {invite.email || 'kdokoliv s odkazem'} + + {invite.note && ( + {invite.note} + )} + + + + {invite.usedAt ? ( + použitá {formatDateTime(invite.usedAt)} + ) : invite.problem ? ( + {invite.problem} + ) : ( + + platí do {formatDateTime(invite.expiresAt)} + + )} + + +
+
+
+ +

+ + Odkaz je jediná ochrana, proto má náhodný kód a jde kdykoliv zrušit. Po použití + přestane platit sám. +

+
+ ); +} diff --git a/web/src/components/dashboard/TicketEvents.tsx b/web/src/components/dashboard/TicketEvents.tsx index f18ffd4..8b58276 100644 --- a/web/src/components/dashboard/TicketEvents.tsx +++ b/web/src/components/dashboard/TicketEvents.tsx @@ -18,7 +18,7 @@ export function TicketEvents({ events }: { events: TicketEvent[] }) { if (events.length === 0) { return (

- Žá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ě.

); } @@ -49,11 +49,24 @@ function EventRow({ event }: { event: TicketEvent }) { - {event.label} + + {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 && ( + + {event.repeats}x beze změny + + )} + {event.type} {event.source} {formatDateTime(event.at)} + {event.repeats > 1 && naposledy {formatDateTime(event.lastAt)}} diff --git a/web/src/components/home/References.tsx b/web/src/components/home/References.tsx index 89cb467..47a250c 100644 --- a/web/src/components/home/References.tsx +++ b/web/src/components/home/References.tsx @@ -47,7 +47,7 @@ function ReferenceCard({ reference }: { reference: Reference }) {
- „{reference.quote}“ + "{reference.quote}"
diff --git a/web/src/data/products.ts b/web/src/data/products.ts index bb26f00..3edb15f 100644 --- a/web/src/data/products.ts +++ b/web/src/data/products.ts @@ -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' }, diff --git a/web/src/pages/Invite.tsx b/web/src/pages/Invite.tsx new file mode 100644 index 0000000..a32e6fe --- /dev/null +++ b/web/src/pages/Invite.tsx @@ -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(null); + const [loadError, setLoadError] = useState(null); + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + useEffect(() => { + apiFetch(`/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 ( +
+ +
+ {loadError || (info && !info.valid) ? ( + <> +

+ + {info?.problem ?? loadError} +

+

+ Požádejte o novou pozvánku toho, kdo vám poslal odkaz. +

+ + Zpět na úvod + + + ) : done ? ( +

+ + Hotovo, přesměrovávám do portálu. +

+ ) : ( + <> +

+ + Pozvánka do firmy +

+

+ {info?.tenant ?? 'Načítám...'} +

+

+ {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.'} +

+ +
+ + + + + + + {error && ( +

+ + {error} +

+ )} + + +
+ + )} +
+
+
+ ); +} diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx index 20940f6..d31efc4 100644 --- a/web/src/pages/dashboard/AutomationDetail.tsx +++ b/web/src/pages/dashboard/AutomationDetail.tsx @@ -403,6 +403,8 @@ export default function AutomationDetail() { + +
diff --git a/web/src/pages/dashboard/Automations.tsx b/web/src/pages/dashboard/Automations.tsx index 47c35c9..6e777d0 100644 --- a/web/src/pages/dashboard/Automations.tsx +++ b/web/src/pages/dashboard/Automations.tsx @@ -132,7 +132,11 @@ function AutomationCard({ automation }: { automation: Automation }) {
- +
{label}
{value}
+ {/* Samo cislo za dnesek nic nerika, dokud neni s cim srovnat. */} + {note &&
{note}
} ); } diff --git a/web/src/pages/dashboard/People.tsx b/web/src/pages/dashboard/People.tsx index 7837c38..429fb91 100644 --- a/web/src/pages/dashboard/People.tsx +++ b/web/src/pages/dashboard/People.tsx @@ -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('table'); + const [tab, setTab] = useState('prehled'); + const access = useApiQuery('/api/dashboard/access'); const people = useApiQuery('/api/dashboard/people'); const workload = useApiQuery('/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 (
@@ -38,9 +71,31 @@ export default function People() { Kdo řeší tickety, co má u sebe a jak dlouho mu to leží.

- + {tab === 'prehled' && } + {tabs.length > 1 && ( +
+ {tabs.map((item) => ( + + ))} +
+ )} + + {tab === 'prehled' && ( + <> {workload.data && (
@@ -174,6 +229,92 @@ export default function People() {
)} + + )} + + {tab === 'resitele' && ( + 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 ? ( + vypnutý + ) : ( + aktivní + ), + }, + ]} + 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' && ( + 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' && } ); } diff --git a/web/src/pages/dashboard/Settings.tsx b/web/src/pages/dashboard/Settings.tsx index 9fb4c48..4ac107e 100644 --- a/web/src/pages/dashboard/Settings.tsx +++ b/web/src/pages/dashboard/Settings.tsx @@ -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() {

Nastavení

- 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.

@@ -116,71 +116,6 @@ export default function Settings() { {tab === 'ucet' && } - {tab === 'people' && ( - 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 ? vypnutý : aktivní, - }, - ]} - 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' && ( - 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' && ( + {/* + 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 && ( + + )} + diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index c749b79..9e55002 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -79,6 +79,10 @@ export interface TicketEvent { label: string; payload: Record; 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;