diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index 55c9c6c..b463cf9 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -86,6 +86,14 @@ s tim, co uzivatel smi. Dvoji vypocet se jednou rozejde. firmu jako povinny argument. Clovek muze byt spravce v jedne firme a bezny uzivatel v druhe. +**Resitel je clenstvi uctu, ne vlastni zaznam.** Kazdy clen firmy muze mit +tickety u sebe a ID resitele je ID uctu. `Person` je jen pohled, ktery sklada +`personView` z uctu a jednoho jeho clenstvi (popisek, kapacita, externi ID +visi na clenstvi). Driv byl resitel zvlastni zaznam spojeny s uctem pres +e-mail; e-mail je ale prihlasovaci jmeno, ktere spravce meni, a vazba se +tise rozpadla. Technik bez uctu neexistuje - ucet dostane nahodne heslo +a nemusi se nikdy prihlasit. Podrobnosti v [06-tickety.md](06-tickety.md). + **Pravo se kontroluje u kazde route, za firmu zaznamu.** Clenstvi ve firme neni pravo. Konektor chce `connector.manage`, automatizace `automation.edit`, uzivatele `user.manage` a jen ve sve firme, firmy jen spravce platformy. @@ -184,7 +192,7 @@ prvni misto, kam se divat. | Misto | Co hrozi | | ---------------------------------- | ----------------------------------------------------------------------------------------------- | | ID operaci a poli v katalogu | odkazuji se na ne ulozene stromy, prejmenovani je rozbije | -| poradi v `bootstrapData` | tickety az po resitelich, nastroje MCP az po firmach | +| poradi v `bootstrapData` | tickety az po uctech, migrace resitelu (`migratePeople`) az po ticketech a automatizacich, nastroje MCP az po firmach | | overeni konektoru bez `verifyPath` | proxy vraci 200 s prazdnym telem i pro neexistujici aplikaci, takze test projde a nic to nerika | | migrace | nikdy se neupravuji zpetne, oprava je vzdy novy soubor | | BOM v JSONu | rozbije Node i Vite, zapisovat UTF-8 bez BOM | diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 3690c37..754bb5b 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -19,12 +19,12 @@ React aplikaci ze slozky `dist/public`. | Katalog sluzeb | hotovo | 34 sluzeb, 7 kategorii vcetne Obecne | | Builder automatizaci | hotovo | strom akci, vetveni podminkou | | Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data | -| Tickety na konkretni lidi | hotovo | resitel, filtr moje, prehled vytizeni tymu | +| Tickety na konkretni lidi | hotovo | resitel je clen firmy, filtr moje, prehled vytizeni | | Prijem udalosti do ticketu | hotovo | webhook na firmu, externi ID unikatni za firmu | | Udalosti na ticketu | hotovo | dalsi zprava se navesi na tentyz ticket | | Statistiky resitelu | hotovo | odbaveno, mediany casu, vracene, fronta | | Pohledy tabulka a dlazdice | hotovo | tickety i lide | -| Stranka Lide a detail osoby | hotovo | vykon a co ma u sebe | +| Stranka Lide a detail osoby | hotovo | sprava clenu firmy, vykon a co ma u sebe | | Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila | | Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece | | Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni | @@ -47,7 +47,7 @@ React aplikaci ze slozky `dist/public`. | 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 | +| Osoby a skupiny resitelu | hotovo | resitel je clenstvi uctu, ticket jde i na skupinu | | 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 | diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 2c1363a..13a64ce 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -53,7 +53,8 @@ image jen `dist`, takze staci jedna slozka. | `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` | | `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` | | `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni | -| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu | +| `src/data/people.ts` | resitele jako pohled na clenstvi uctu (`personView`), skupiny | +| `src/data/migratePeople.ts` | jednorazovy prevod starych zaznamu resitelu `ppl_` na ucty | | `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES | | `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal | | `src/data/widgets.ts` | katalog widgetu prehledu | @@ -138,9 +139,11 @@ ale nezkompiluje se. Podrobnosti v [07-firmy-a-prava.md](07-firmy-a-prava.md). s tim, co uzivatel smi. Kdyby si to klient pocital sam, pocitalo by se to na dvou mistech a jednou se to rozejde. -**Resitel neni uzivatel.** Uzivatel se prihlasuje do portalu, resitel ma u sebe -tickety. Technik muze mit tickety a ucet nikdy nemit. Spojka je e-mail, -podrobnosti v [06-tickety.md](06-tickety.md). +**Resitel je clenstvi uctu.** ID resitele je ID uctu, `Person` je jen pohled +na ucet a jedno jeho clenstvi ve firme (jmeno a e-mail z uctu, popisek, +kapacita a externi ID z clenstvi). Driv byl resitel vlastni zaznam spojeny +s uctem pres e-mail a zmena e-mailu vazbu tise rozbila. Stara uloziste +prevadi `migratePeople` pri startu. Podrobnosti v [06-tickety.md](06-tickety.md). **Filtrovani ticketu dela server.** Klient posila query parametry a dostane hotovy seznam. Kdyby filtroval sam, ukazoval by jina cisla nez prehled vytizeni. diff --git a/documentation/04-api.md b/documentation/04-api.md index a6fc652..e4ac0cc 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -97,9 +97,14 @@ Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni, uprava, mazani) na `/api/dashboard/settings/`, protoze ji dela jedna fabrika (`src/routes/crud.ts`): -`tenants`, `users`, `roles`, `people`, `groups`, `ticket-types`, `actions`, +`tenants`, `users`, `roles`, `groups`, `ticket-types`, `actions`, `widgets`, `features`. +`people` ma stejne cesty a stejne pravo (`people.manage`), ale vlastni +handlery v `settings.ts`: zaznam, ktery se meni, je ucet bez firmy +a odpoved je pohled za jednu firmu, coz fabrika neumi. Popis je nize +v sekci Lide. + ## Format chyb Jednotny pro cele API: @@ -239,13 +244,43 @@ v uz nactenem seznamu. 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. +ticket do fronty. `assigneeId` je ID uctu; kdo ve firme ticketu neni clenem, +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. +pravo `ticket.assign.others`. Kdo ve firme nema clenstvi (neni resitel), +dostane 400. + +## Lide (resitele) + +Resitel je clenstvi uctu ve firme, ne vlastni zaznam; ID resitele je ID uctu. +Duvody v [06-tickety.md](06-tickety.md). `Person` je pohled: `id` a `name`, +`email`, `enabled` z uctu, `tenantId`, `role` (popisek), `capacity`, +`externalIds` a `roleIds` z clenstvi. Tentyz clovek ve dvou firmach prijde +dvakrat se stejnym `id`. + +`GET /api/dashboard/people` vraci cleny zvolene firmy s povolenym uctem +a jejich skupiny (`items`, `groups`, `meId`). Sprava je na +`/api/dashboard/settings/people` pod pravem `people.manage`: + +| Volani | Telo | Co se stane | +| ----------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET people` | | vcetne vypnutych uctu | +| `POST people` | `{ name, email, password?, roleIds?, role?, capacity?, externalIds?, enabled? }` | zalozi ucet (heslo nahodne, kdyz chybi; role `role_agent`, kdyz chybi) s clenstvim ve firme, nebo prida clenstvi uctu, ktery s tim e-mailem uz je | +| `PATCH people/:id` | tataz pole, vsechna nepovinna | `name`, `email` (unikatni) a `enabled` meni ucet, ostatni clenstvi v teto firme | +| `DELETE people/:id` | | odebere jen clenstvi; ucet bez clenstvi, ktery neni spravce platformy, se vypne | + +Spravce firmy na spravce platformy nesaha, stejne jako u `/users`. Udalosti +jsou `person.created`, `person.updated`, `person.deleted` s payloadem +`{ id, person }`, u smazani `{ id }`. `enabled` je za clenstvi: vypne cloveka +jen v teto firme, ucet a ostatni clenstvi zustavaji. + +`/users` (spravce platformy) bere u clenstvi vedle `roleIds` i +`seesAllTenant`, `role`, `capacity` a `externalIds` a pri uprave je zachova, +takze zmena role v Nastaveni nesmaze kapacitu nastavenou v Lidech. ## Pozvanky do firmy @@ -269,7 +304,9 @@ 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. +kopirovat - relativni cesta se do zpravy vlepit neda. Pozvanka nese jen +`email` a `roleIds`; stary priznak `asPerson` se prijme a ignoruje, protoze +resitelem je kazdy clen firmy. ## Akce na ticketu @@ -300,7 +337,7 @@ v [07-firmy-a-prava.md](07-firmy-a-prava.md). | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `GET ares/companies?query=` | same cislice (1 az 8) hledaji IC presne, jinak nazev, nejvys 10. U firmy, ktera uz v portalu je, `existingTenantId` | | `GET ares/companies/{ico}/persons` | soucasni statutari a prokura z verejneho rejstriku, u kazdeho navrzeny e-mail `IC-poradi@placeholder.cz` | -| `POST ares/tenants` | zalozi firmu a ucty vybranych osob, vraci firmu a seznam uctu | +| `POST ares/tenants` | zalozi firmu a ucty vybranych osob (`role_admin`, popisek clenstvi z funkci v rejstriku, kapacita 8), vraci firmu a seznam uctu. Ucet je zaroven resitel | Chyba registru je `ares_error` s kodem podle toho, co ARES vratil - neni to chyba naseho API a nema se opakovat automaticky. Adresa registru je @@ -356,6 +393,7 @@ Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je | ----------------------------------------------- | --------------------------------------------------------------------- | | firmy CRUD, ARES | spravce platformy | | uzivatele CRUD | spravce platformy, nebo `user.manage` jen v ramci sve firmy | +| lide (`/settings/people`) | `people.manage` jen v ramci sve firmy | | pozvanky | `user.manage`, role jen z te firmy | | konektory create, update, delete, test | `connector.manage` | | automatizace create, update, delete, regenerate | `automation.edit` | diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 54b27a1..4523e47 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -99,17 +99,53 @@ neprirazeny. `src/data/people.ts` -Resitel je oddeleny od uzivatele. **Uzivatel** je ten, kdo se prihlasi do portalu, -**resitel** je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy - -technik muze mit tickety a do portalu se nikdy neprihlasit. +Resitel je **clenstvi uctu ve firme**, ne vlastni zaznam. Kazdy clen firmy +muze mit tickety u sebe a ID resitele je ID uctu (`usr_...`). `Person` +(`src/shared/people.ts`) je jen pohled pro API, sklada ho `personView`: -Spojka mezi obojim je e-mail. Podle ni funguje filtr "moje tickety" -(`?assignee=me`). Kdyz prihlaseny ucet zadnemu resiteli neodpovida, filtr se -v portalu nabidne jako nedostupny misto toho, aby vracel prazdno bez vysvetleni. +| Pole | Odkud | +| ------------------------- | -------------------------------------- | +| `id` | ID uctu | +| `tenantId` | firma clenstvi | +| `name`, `email` | ucet | +| `role` (popisek), `capacity`, `externalIds` | clenstvi (`Membership` v `src/shared/users.ts`) | +| `enabled` | stav uctu | +| `roleIds` | role clenstvi | -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. +Jeden pohled na jedno clenstvi: tentyz clovek ve dvou firmach je v seznamu +dvakrat, pokazde se stejnym `id` a jinym `tenantId`. Nic se tu neuklada, +vsechno se odvozuje z kopie uctu v pameti (`src/data/users.ts`). + +Driv to byly dve veci. **Uzivatel** se prihlasoval, **resitel** mel u sebe +tickety a spojka mezi nimi byl e-mail. To byla chyba: e-mail je prihlasovaci +jmeno, ktere spravce muze zmenit, a tim se vazba tise rozpadla - clovek +prestal videt "moje tickety" a nikdo nevedel proc. Prvni priznak byl, ze lide +zalozeni z ARES nebyli v Lidech videt. Spojovat pres ID misto e-mailu by +znamenalo dal drzet dva zaznamy o jednom cloveku, tak se slily do jednoho. + +**Technik bez uctu uz neexistuje.** Kdo ma mit tickety, dostane ucet. Kdyz +spravce nezada heslo, vygeneruje se nahodne a ucet se nemusi nikdy prihlasit; +az bude chtit, heslo si nastavi pres pozvanku nebo mu ho zmeni spravce. + +Filtr "moje tickety" (`?assignee=me`) tak nic nedohledava: `personIdFor` vrati +ID uctu, kdyz ma ve firme clenstvi, jinak `null`. Spravce platformy bez +clenstvi resitelem neni a filtr se mu v portalu nabidne jako nedostupny misto +toho, aby vracel prazdno bez vysvetleni. + +Kazdy resitel ma `capacity` (vychozi 8), 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. Kapacita i popisek visi +na clenstvi, ne na uctu: v jedne firme je clovek dispecer s kapacitou 6, +v druhe ucetni s kapacitou 3. + +Sprava je v zalozce **Lide** pod pravem `people.manage`. Spravce firmy tam +zaklada ucty (nebo prida clenstvi uctu, ktery uz s tim e-mailem existuje), +upravuje jmeno, e-mail, popisek, kapacitu, externi ID a role clenstvi +a odebira clenstvi. Ucet, ktery po odebrani nema zadne clenstvi a neni +spravce platformy, se vypne. Prepinac `enabled` v Lidech je **za clenstvi**: +vypnuty se v teto firme nenabizi k prirazeni, stare tickety mu zustanou +a v jinych firmach pracuje dal. Cely ucet vypina jen sprava uzivatelu. +`Person.enabled` je proto `ucet.enabled && clenstvi.enabled !== false`. ## Fronta skupiny a prevzeti @@ -145,9 +181,9 @@ zalozi ucet a posle heslo. Duvody: 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. +U pozvanky se rovnou rekne, jake role clovek dostane. Resitelem je kazdy +clen firmy, takze zadna dalsi volba neni potreba; stary priznak `asPerson` se +prijme a ignoruje, aby starsi klient dal fungoval. Sprava je v zalozce **Lide**, ne v nastaveni: pozvat kolegu je bezna denni prace. diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index 3c6b74b..494db5d 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -38,7 +38,7 @@ globalni, nesla by tahle situace vubec zapsat. | -------- | ------------------------ | -------------------------- | | `all` | napric vsemi firmami | jen `platformAdmin` | | `tenant` | cela jedna firma | kdokoliv, kdo do ni patri | -| `mine` | jen tickety prihlaseneho | kdo ma navazaneho resitele | +| `mine` | jen tickety prihlaseneho | kdo je clenem firmy | Posilaji se jako query: `?scope=tenant&tenantId=tnt_automia`. @@ -132,11 +132,16 @@ rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene. "tenants": [{ "id": "tnt_automia", "name": "Automia" }], "defaultTenantId": "tnt_automia", "canAssignOthers": true, - "personId": "ppl_uhlir", + "personId": "usr_1", "roleNames": ["Správce"] } ``` +`personId` je ID uctu: resitel je clenstvi uctu ve firme, ne vlastni zaznam +(viz [06-tickety.md](06-tickety.md)). `null` znamena, ze prihlaseny ve +zvolene firme clenstvi nema - typicky spravce platformy - a pohled `mine` +pak v seznamu neni. + Pristup se pocita **jednou na request** (`attachAccess` v `src/middleware/tenant.ts`, vysledek v `req.access`) a routy si z nej berou firmu pres `tenantOrDeny`. Kazda route, ktera si to pocitala sama, to delala @@ -217,6 +222,7 @@ a drzi se vsude, kde se neco zaklada: | firma | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings.ts` | | firma z registru ARES | jen spravce platformy | `src/routes/ares.ts` | | uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings.ts` | +| resitel (clen firmy, Lide) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | `src/routes/settings.ts` | | pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` | | konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` | | automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` | @@ -237,7 +243,10 @@ Zalozit firmu rucne znamena opsat nazev, IC, DIC a adresu a pak zvlast zakladat ucty lidem, kteri ji povedou. Proto `Nastaveni`, firma z ARES: spravce platformy zada IC nebo nazev, vybere firmu a z verejneho rejstriku dostane **soucasne cleny statutarniho organu a prokuru**. Vybrani dostanou -ucet s roli `role_admin` v nove firme a nahodnym heslem. +ucet s roli `role_admin` v nove firme a nahodnym heslem. Resitel je clenstvi +uctu, ne vlastni zaznam (viz [06-tickety.md](06-tickety.md)), takze jsou +v Lidech hned a jde jim prihodit ticket; funkce z rejstriku je popisek +clenstvi (`role`), kapacita je vychozich 8. Rejstrik e-maily nezna. Kazda osoba proto dostane zastupnou adresu `IC-poradi@placeholder.cz`, pokud spravce nezada skutecnou. **Zastupna adresa diff --git a/documentation/08-dashboard-widgety.md b/documentation/08-dashboard-widgety.md index fe06d5a..e743f4d 100644 --- a/documentation/08-dashboard-widgety.md +++ b/documentation/08-dashboard-widgety.md @@ -24,8 +24,9 @@ Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts` dostane vychozi rozlozeni a `custom: false`. Ulozene rozlozeni si drzi `createdAt` i po uprave. -Vychozi rozlozeni dostava stejne `hasPerson` jako katalog: kdo ve firme neni -veden jako resitel, nema v katalogu `list.myTickets`, a tak ho nesmi mit ani +Vychozi rozlozeni dostava stejne `hasPerson` jako katalog: `hasPerson` +znamena "je clenem firmy" (resitel je clenstvi uctu). Kdo clenstvi nema, +typicky spravce platformy, nema v katalogu `list.myTickets`, a tak ho nesmi mit ani ve vychozi sade (misto nej jsou nezarazene tickety pres celou sirku). Jinak by novy ucet videl jako prvni vec hlasku o widgetu, ktery "uz v katalogu neni". diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index 7319fcb..4cc70cb 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -158,6 +158,26 @@ Dve veci, na kterych to stoji: souboru znamena dve rozdilna schemata se stejnym cislem. Oprava je vzdy novy soubor. +### Stara kolekce `person` + +Kolekce `person` je **jen pozustatek**: resitel byval vlastni zaznam +(`ppl_...`) spojeny s uctem pres e-mail, dnes je resitel clenstvi uctu +(viz [06-tickety.md](06-tickety.md)) a nic se do ni nezapisuje. Prevod +nedela SQL migrace, ale `src/data/migratePeople.ts` pri startu +z `bootstrapData`, az po nacteni ticketu a automatizaci, a jen kdyz v kolekci +neco je - druhy start uz nic nedela. Plati pro vsechny tri rezimy uloziste. + +| Krok | +| ------------------------------------------------------------------------------------------------- | +| ke kazdemu zaznamu se najde ucet podle e-mailu, nebo se zalozi s nahodnym heslem a clenstvim `role_agent` | +| popisek, kapacita a externi ID se prenesou na clenstvi | +| stare ID se prepise na ID uctu v ticketech (`assigneeId`, `resolvedById`), stromech automatizaci, skupinach, telech akci a zdrojich vlastnich widgetu | +| prevedene zaznamy se smazou, do logu jde `[migrace] resitele -> ucty: ...` | + +Chyba jednoho zaznamu jen zaloguje, zaznam zustane a migrace se k nemu vrati +pri dalsim startu. Historicke udalosti v logu ticketu si stara ID nechavaji, +neprepisuji se. + ## Sifrovani pristupovych udaju `src/db/secretBox.ts`, AES-256-GCM. diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index b5e2f97..676cf09 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -56,7 +56,13 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. | | `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. | | `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. | -| `findPersonByExternalId(...)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. | +| `personView(user, membership)` | `src/data/people.ts` | Jediné místo, kde z účtu a jednoho členství vzniká pohled `Person`. Řešitel je členství, ID řešitele je ID účtu. | +| `listPeople(tenantIds)`, `listAllPeople(tenantIds)` | `src/data/people.ts` | Řešitelé vybraných firem, jeden záznam na členství; druhá i s vypnutými účty (správa týmu). | +| `findPerson(id, tenantId)` | `src/data/people.ts` | Řešitel v dané firmě, firma je povinná. Kdo v ní není členem, je `undefined`, i když účet existuje. | +| `personName(id)` | `src/data/people.ts` | Jméno účtu bez ohledu na firmu, pro popisky u záznamů, které už prošly filtrem na firmu. | +| `personIdFor(user, tenantId)` | `src/data/people.ts` | ID řešitele, kterým je uživatel ve firmě: ID účtu při členství, jinak `null`. Neptat se `user.id` přímo. | +| `findPersonByExternalId(value, tenantIds)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. Externí ID visí na členství. | +| `migratePeople()` | `src/data/migratePeople.ts` | Jednorázový převod starých záznamů řešitelů (`ppl_`) na účty při startu. Přepisuje odkazy přes `remapPersonIds` v `ticketStore.ts` a `automationStore.ts`. | | `notify(input)` | `src/data/notifications.ts` | Upozorní člověka. Nečeká se a nevyhazuje chyby, stejně jako audit. | | `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. | | `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. | diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md index 87b3729..5b54c59 100644 --- a/documentation/17-nastaveni-a-prava.md +++ b/documentation/17-nastaveni-a-prava.md @@ -51,6 +51,7 @@ byla, ale `viewer` mohl zalozit konektor nebo smazat automatizaci. Ted: | ---------------------------------------- | -------------------------------------------------------- | | firmy | jen spravce platformy | | uzivatele | spravce platformy, nebo `user.manage` jen ve sve firme | +| lide (resitele = clenove firmy) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | | pozvanky | `user.manage`, role jen z te firmy | | konektory (zalozeni, uprava, smazani, test) | `connector.manage` | | automatizace (zalozeni, uprava, smazani, novy token) | `automation.edit` | @@ -62,12 +63,25 @@ v jine firme, nesahne na spravce platformy a nesmaze cloveka, ktery je i v jine firme. Duvody a rozhodnuti "kdo koho zaklada" jsou v [07-firmy-a-prava.md](07-firmy-a-prava.md). +Zalozka Lide (`people.manage`) uz nespravuje zvlastni zaznam resitele: +**resitel je clenstvi uctu ve firme** a ID resitele je ID uctu, viz +[06-tickety.md](06-tickety.md). Zalozeni cloveka v Lidech zalozi ucet +(heslo nepovinne, bez nej nahodne) nebo prida clenstvi uctu, ktery uz s tim +e-mailem existuje. Upravit jde jmeno, e-mail, popisek, kapacita, externi ID +a role clenstvi; smazani odebere jen clenstvi. Sprava uctu v Nastaveni +(`/users`, spravce platformy) k clenstvi bere i `seesAllTenant`, `role`, +`capacity`, `externalIds` a `enabled` a pri uprave je zachova. Prepinac +`enabled` v Lidech je za clenstvi: vypne cloveka jen v teto firme, cely ucet +vypina jen sprava uzivatelu. + ### Firma z registru ARES Zalozka Firmy ma vedle rucniho zalozeni cestu pres ARES (`components/dashboard/AresTenantDialog.tsx`), jen pro spravce platformy: IC nebo nazev, vyber firmy, vyber statutaru, kteri dostanou ucet -s roli spravce a zastupnou adresou `IC-poradi@placeholder.cz`. Zastupne +s roli spravce a zastupnou adresou `IC-poradi@placeholder.cz`. Ucet je +zaroven resitel (resitel je clenstvi uctu), takze jsou hned v Lidech; funkce +z rejstriku je popisek clenstvi. Zastupne adresy se musi nahradit skutecnymi, jinak se ti lide neprihlasi. Firma pak nese `ico`, `dic`, `address` a `legalForm`, IC je unikatni. Endpointy jsou v [04-api.md](04-api.md). diff --git a/documentation/23-jazyky.md b/documentation/23-jazyky.md index 8fe4847..1d5a923 100644 --- a/documentation/23-jazyky.md +++ b/documentation/23-jazyky.md @@ -3,6 +3,12 @@ Mechanismus je hotovy a overeny. Verejny web je prelozeny cely, portal za prihlasenim jen ve spolecnych castech. +**Prepinani je docasne schovane** (rozhodnuti 2026-09-09, do rozhodnuti +o znacce): `MULTILANG_ENABLED = false` v `web/src/i18n/index.ts`. Prepinac +se nekresli, vzdy se pouzije cestina a ulozena volba `en` se ignoruje, aby +nikdo nezustal v anglictine bez moznosti prepnout zpet. Slovniky zustavaji, +zapnuti je zmena jedne konstanty. + ## Jak to funguje | Co | Kde | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 5995db9..5d6e957 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,92 @@ Nejnovejsi nahore. +## 2026-09-09 - Prepinani jazyku docasne schovane + +Do rozhodnuti o nove znacce je web jen cesky. `MULTILANG_ENABLED = false` +v `web/src/i18n/index.ts` schova prepinac v hlavicce i v mobilnim menu a +`detect()` vraci vzdy `cs`; ulozena volba `en` se ignoruje, jinak by nekdo +zustal v anglictine bez cesty zpet. Slovniky a `LanguageSwitch` zustavaji, +zapnuti je jedna konstanta. Navrh znacky je zatim jen artefakt "Navrh znacky", +do projektu se neaplikoval. + +## 2026-09-09 - Resitel je clenstvi uctu, ne vlastni zaznam + +Prvni priznak: osoby zalozene pres ARES dostaly ucet, ale v Lidech nebyl +nikdo, ani ten, za koho se spravce prepnul. Puvodni oprava chtela k uctu +dozakladat zaznam resitele. Pri ni se ukazalo, ze chyba je hloubeji: resitel +byl vlastni zaznam (`ppl_...`) spojeny s uctem **pres e-mail**, a e-mail je +prihlasovaci jmeno, ktere spravce muze zmenit. Zmena adresy vazbu tise +rozbila - clovek prestal videt "moje tickety" a nikdo nevedel proc. Kazde +misto, ktere zaklada ucet (pozvanka, ARES, Nastaveni), navic muselo pamatovat +na druhy zaznam a jedno vzdycky zapomnelo. + +Rozhodnuti: spojovat pres ID misto e-mailu by znamenalo dal drzet dva zaznamy +o jednom cloveku. Oba se proto slily: **resitel je clenstvi uctu ve firme**, +kazdy clen muze mit tickety u sebe, ID resitele je ID uctu. + +### Model + +`Membership` (`src/shared/users.ts`) ma navic `role` (popisek), `capacity` +(vychozi 8) a `externalIds`. `Person` (`src/shared/people.ts`) je jen pohled +na jedno clenstvi: `id` = ID uctu, `tenantId` = firma clenstvi, jmeno +a e-mail z uctu, `enabled` = stav uctu, `roleIds` = role clenstvi. Tentyz +clovek ve dvou firmach je dvakrat se stejnym `id`. + +`src/data/people.ts` uz nic neuklada: zmizely `personStore`, `seedPeople`, +`refreshPeople` a `findPersonByEmail`. Pohled sklada jedine misto, +`personView`; k tomu `listPeople(tenantIds)`, `listAllPeople`, +`findPerson(id, tenantId)` (neclen je `undefined`), `personName(id)`, +`findPersonByExternalId(value, tenantIds)` a `personIdFor(user, tenantId)`. +Skupiny (`personGroup`) zustavaji, clenove odkazuji na ID uctu. + +### Migrace starych dat + +`src/data/migratePeople.ts` bezi pri startu z `bootstrapData`, az po nacteni +ticketu a automatizaci, a jen kdyz ma stara kolekce `person` zaznamy. Ke +kazdemu najde ucet podle e-mailu, nebo ho zalozi (nahodne heslo, `enabled` +podle resitele, clenstvi `role_agent`), prenese popisek, kapacitu a externi +ID na clenstvi a prepise `ppl_x -> usr_y` v ticketech (`assigneeId`, +`resolvedById`), stromech automatizaci (nahrada v JSON), skupinach, telech +akci a zdrojich vlastnich widgetu. Prevedene zaznamy smaze a zaloguje +`[migrace] resitele -> ucty: ...`. Chyba jen loguje, zaznam se zkusi znovu +pri dalsim startu. Historicke udalosti v logu ticketu si stara ID nechavaji. + +### API a web + +`/api/dashboard/settings/people` ma stejne cesty a pravo `people.manage`, +ale pod nim jsou ucty: `POST` zalozi ucet (heslo nahodne, kdyz chybi, role +`role_agent`) s clenstvim, nebo prida clenstvi uctu s tim e-mailem; `PATCH` +meni jmeno, e-mail (unikatni) a `enabled` na uctu a popisek, kapacitu, +externi ID a role na clenstvi; `DELETE` odebere jen clenstvi a ucet bez +clenstvi bez `platformAdmin` vypne. Udalosti `person.*` nesou `{ id, person }`. +`/users` bere u clenstvi i `seesAllTenant`, `role`, `capacity`, `externalIds` +a pri uprave je zachova. Pozvanka prisla o `asPerson` (prijme se a ignoruje). +ARES zaklada ucet `role_admin` s popiskem clenstvi z funkci v rejstriku. + +V portalu spravce v Lidech upravuje cleny (pole vyse, role jako vyber vice +hodnot), `InvitePanel` ztratil prepinac "zalozit i jako resitele" a texty +o "resiteli bez uctu" a "spojce pres e-mail" jsou pryc. `hasPerson` pro +katalog widgetu a vychozi rozlozeni znamena "je clenem firmy". + +### Ukazkova data + +Ukazkovi resitele jsou ukazkove ucty (heslo `demo1234`): `usr_1` +admin@automia.cz (Vedouci tymu, 5), `usr_2` karel.vomacka@automia.cz +(Automia Servicedesk 8, Nordis Spravce servicedesku 8), `usr_3` +martin.kriz@automia.cz (Integrace a API, 6), `usr_novakova`, `usr_bartos`, +`usr_horakova`, `usr_kadlec`. Ukazkove tickety, automatizace a skupina +`grp_servicedesk` odkazuji na ne. + +### Ucet ve vic firmach + +Ucet muze byt ve vic firmach, proto se v Lidech odebira jen clenstvi +(`DELETE /settings/people/:id`), ne ucet. Ze stejneho duvodu je i `enabled` +na clenstvi: spravce firmy vypne cloveka u sebe, ne v jine firme, kde +pracuje dal. Cely ucet vypina jen sprava uzivatelu. `Person.enabled` je +`ucet.enabled && clenstvi.enabled !== false`; vypnute clenstvi se nenabizi +k prirazeni ani nehleda podle externiho ID. + ## 2026-09-09 - Novy ucet videl na dashboardu chybejici widget Kdo se prihlasil do nove firmy (nebo jako nove zalozeny ucet), videl jako diff --git a/src/data/access.ts b/src/data/access.ts index f0f3bbc..1b2b4da 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -15,7 +15,7 @@ * pocitalo na dvou mistech a jednou se rozejde. */ -import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js'; +import { groupMembers, groupsOfPerson, listGroups, personIdFor } from './people.js'; import { hasPermission, permissionsOf, rolesFor } from './permissions.js'; import { listActiveTenants, findTenant, type Tenant } from './tenants.js'; import { navFor } from './tenantFeatures.js'; @@ -60,16 +60,17 @@ export function visibilityFor(user: User, tenantId: string | null): Visibility { return { kind: 'all' }; } - const person = findPersonByEmail(user.email); - if (!person) return { kind: 'scoped', personIds: [], groupIds: [] }; + // Resitel je clenstvi: bez nej clovek ve firme nema ani svoje tickety. + const personId = personIdFor(user, tenantId); + if (!personId) return { kind: 'scoped', personIds: [], groupIds: [] }; - const groupIds = groupsOfPerson(person.id, [tenantId]) - .filter((group) => groupMembers(group).some((m) => m.personId === person.id && m.seesAll)) + const groupIds = groupsOfPerson(personId, [tenantId]) + .filter((group) => groupMembers(group).some((m) => m.personId === personId && m.seesAll)) .map((group) => group.id); // Lide z vedenych sekci: jejich tickety patri vedoucimu do dohledu i tehdy, // kdyz si je vzali na sebe a ze fronty skupiny tim zmizely. - const personIds = new Set([person.id]); + const personIds = new Set([personId]); for (const group of listGroups([tenantId])) { if (!groupIds.includes(group.id)) continue; for (const member of groupMembers(group)) personIds.add(member.personId); @@ -96,8 +97,6 @@ export function seesWholeTenant(user: User, tenantId: string | null): boolean { * konkretni firmu - modul si kupuje firma, ne clovek. */ export function accessFor(user: User, tenantId?: string | null): Access { - const person = findPersonByEmail(user.email); - const tenants: Tenant[] = user.platformAdmin ? listActiveTenants() : user.memberships @@ -108,14 +107,17 @@ export function accessFor(user: User, tenantId?: string | null): Access { }) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + const defaultTenantId = tenants[0]?.id ?? null; + const activeTenant = tenantId ?? defaultTenantId; + + // Resitel = clenstvi ve vybrane firme, ID je ID uctu. Spravce platformy + // bez clenstvi resitelem neni a "moje tickety" u nej nemaji co ukazat. + const personId = personIdFor(user, activeTenant); + const scopes: TicketScope[] = []; if (user.platformAdmin) scopes.push('all'); if (tenants.length > 0) scopes.push('tenant'); - // Bez navazaneho resitele nema "moje tickety" co ukazat. - if (person) scopes.push('mine'); - - const defaultTenantId = tenants[0]?.id ?? null; - const activeTenant = tenantId ?? defaultTenantId; + if (personId) scopes.push('mine'); const visibility = visibilityFor(user, activeTenant); const groups = activeTenant ? listGroups([activeTenant]) : []; @@ -138,7 +140,7 @@ export function accessFor(user: User, tenantId?: string | null): Access { defaultTenantId, // Zustava kvuli klientovi, ale uz se pocita z prava, ne z role. canAssignOthers: hasPermission(user, 'ticket.assign.others', activeTenant), - personId: person?.id ?? null, + personId, // Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme // a resitel v druhe, uvidi po prepnuti jen to, co smi tam. permissions: [...permissionsOf(user, activeTenant)].sort(), diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 77e8bc6..0923b8a 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -434,6 +434,31 @@ export async function initAutomations(): Promise { idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT')); } +/** + * Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet + * zmenenych automatizaci. + * + * Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku + * `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince, + * proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich - + * nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`, + * ne podretezec. + */ +export function remapPersonIds(map: Map): number { + let changed = 0; + for (const automation of store.values()) { + const before = JSON.stringify(automation.flow); + let after = before; + for (const [oldId, newId] of map) { + after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId)); + } + if (after === before) continue; + changed += 1; + save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow })); + } + return changed; +} + function nextId(): string { idCounter += 1; return `AUT-${String(idCounter).padStart(2, '0')}`; @@ -617,7 +642,7 @@ function seedDemoAutomations(): void { body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}', company: '{{customer}}', priority: 'high', - assigneeId: 'ppl_vomacka', + assigneeId: 'usr_2', }, }, { id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' }, @@ -880,7 +905,7 @@ function seedDemoAutomations(): void { kind: 'action', serviceId: 'ticket', operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' }, + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' }, }, ], no: [ @@ -896,7 +921,7 @@ function seedDemoAutomations(): void { kind: 'action', serviceId: 'ticket', operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' }, + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' }, }, ], no: [ @@ -905,7 +930,7 @@ function seedDemoAutomations(): void { kind: 'action', serviceId: 'ticket', operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' }, + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' }, }, ], }, diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 98782eb..8ab0bf3 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -18,7 +18,8 @@ import { tenantScriptStore } from './tenantScripts.js'; import { onTicket } from './ticketHooks.js'; import { onTicketEvent } from '../runtime/triggers.js'; import { initQueue } from '../runtime/queue.js'; -import { groupStore, personStore, seedGroups, seedPeople } from './people.js'; +import { groupStore, seedGroups } from './people.js'; +import { migratePeople } from './migratePeople.js'; import { refreshRoles, roleStore, @@ -50,7 +51,6 @@ const entities: Array<{ { store: tenantStore as EntityStore, seed: seedTenants }, { store: userStore as EntityStore, seed: seedUsers }, { store: roleStore as EntityStore, seed: systemRoles }, - { store: personStore as EntityStore, seed: seedPeople }, { store: groupStore as EntityStore, seed: seedGroups }, { store: featuresStore as EntityStore, seed: seedFeatures }, { store: ticketTypeStore as EntityStore, seed: seedTicketTypes }, @@ -157,8 +157,8 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis } /* - * Tickety az po resitelich a typech: `toTicket` dohledava resitele podle ID - * a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje. + * Tickety az po uctech a typech: `toTicket` dohledava resitele podle ID + * uctu a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje. */ for (const item of runtimeData) { try { @@ -172,6 +172,21 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis } console.info(`[data] nactena provozni data: ${runtimeData.map((i) => i.name).join(', ')}`); + /* + * Stare zaznamy resitelu (`ppl_...`) na ucty. Az po nacteni ticketu + * a automatizaci, protoze se prepisuji v jejich kopii v pameti, a pred + * registraci hooku, aby preznaceni nespoustelo automatizace. Neco se + * stane jen pri prvnim startu po zmene modelu, pak je uloziste prazdne. + */ + try { + await migratePeople(); + } catch (err) { + console.error( + '[data] migrace resitelu na ucty selhala:', + err instanceof Error ? err.message : err, + ); + } + /* * Zmena ticketu zaradi navazane automatizace. Registruje se az tady, aby * uloziste ticketu nemuselo vedet o runtime - jinak by vznikl kruh. diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 94ab191..5bb35f7 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -71,8 +71,8 @@ export async function initLayouts(): Promise { /** * Rozlozeni uzivatele, nebo vychozi. * - * `hasPerson` musi byt stejne, jako dostava katalog (`widgetCatalog`): kdo - * neni ve firme veden jako resitel, nema v katalogu `list.myTickets`, a tak + * `hasPerson` (= je clenem firmy) musi byt stejne, jako dostava katalog + * (`widgetCatalog`): kdo ve firme clenstvi nema, nema v katalogu `list.myTickets`, a tak * nesmi byt ani ve vychozim rozlozeni. Jinak novy ucet uvidel jako prvni vec * na dashboardu hlasku, ze widget "uz v katalogu neni". Misto nej dostane * nezarazene tickety pres celou sirku, aby radek nezustal poloprazdny. diff --git a/src/data/invites.ts b/src/data/invites.ts index 6594074..187ef34 100644 --- a/src/data/invites.ts +++ b/src/data/invites.ts @@ -35,12 +35,10 @@ export interface Invite extends TenantEntity { /** 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. + * Stary priznak "ma byt i resitel". Uz nic nerozhoduje: resitel je kazdy + * clen firmy (viz data/people.ts). Zustava jen kvuli ulozenym pozvankam. */ - asPerson: boolean; + asPerson?: boolean; expiresAt: string; usedAt: string | null; usedByUserId: string | null; @@ -62,7 +60,6 @@ export interface CreateInviteInput { email?: string; note?: string; roleIds: string[]; - asPerson?: boolean; createdByEmail: string; } @@ -75,7 +72,6 @@ export function newInvite(input: CreateInviteInput): Invite { 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, diff --git a/src/data/migratePeople.ts b/src/data/migratePeople.ts new file mode 100644 index 0000000..49f8c9a --- /dev/null +++ b/src/data/migratePeople.ts @@ -0,0 +1,213 @@ +/** + * Migrace resitelu na ucty. + * + * Resitel byval vlastni zaznam (`ppl_...`) spojeny s uctem pres e-mail. + * Dnes je resitel clenstvi uctu ve firme a jeho ID je ID uctu, viz + * data/people.ts. Uloziste, ktere vzniklo driv, ma ale stare zaznamy + * a tickety, skupiny i automatizace na ne odkazuji. + * + * Co se stane pri prvnim startu po zmene: + * 1. ke kazdemu staremu resiteli se najde ucet podle e-mailu; kdyz neni, + * zalozi se s nahodnym heslem (clovek si ho nastavi pres pozvanku + * nebo zmenu hesla), + * 2. ucet dostane clenstvi ve firme resitele, pokud ho jeste nema, + * a na clenstvi se prenese popisek, kapacita a externi ID, + * 3. vsude, kde je stare ID, se prepise na ID uctu: tickety (resitel, + * kdo vyresil), skupiny, stromy automatizaci, tela akci, vlastni widgety, + * 4. stare zaznamy se smazou. + * + * Bezi jen kdyz stare zaznamy existuji, takze druhy start uz nic nedela. + * Chyba se loguje a start pokracuje: kontejner, ktery nenastartuje, je + * rozbita sluzba, kdezto par nepreznacenych ticketu je jen varovani v logu. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import { remapPersonIds as remapAutomations } from './automationStore.js'; +import { customWidgetStore, refreshCustomWidgets } from './customWidgets.js'; +import { groupStore, refreshGroups, type PersonGroup } from './people.js'; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { findTenant } from './tenants.js'; +import { actionStore, refreshActions } from './ticketActions.js'; +import { remapPersonIds as remapTickets } from './ticketStore.js'; +import { + findStoredUser, + findStoredUserByEmail, + hashPassword, + refreshUsers, + userStore, + type StoredUser, +} from './users.js'; +import type { Membership } from '../types.js'; + +/** Stary tvar resitele. Jen tady, nikde jinde uz se nepouziva. */ +interface LegacyPerson extends TenantEntity { + tenantId: string; + name: string; + email: string; + role?: string; + capacity?: number; + enabled?: boolean; + externalIds?: string[]; +} + +const legacyStore = defineStore('person'); + +/** Volby zapisu bez omezeni na firmu: ucty firmu nemaji, skupiny se berou z map. */ +const anyTenant = { tenantIds: [] as string[], includeGlobal: true }; + +/** + * Najde nebo zalozi ucet pro stareho resitele a zajisti clenstvi ve firme. + * Vraci ID uctu a jestli ucet prave vznikl. + */ +async function accountFor(person: LegacyPerson): Promise<{ id: string; created: boolean }> { + const email = person.email.trim().toLowerCase(); + const membershipFields = { + role: person.role ?? '', + capacity: person.capacity ?? 8, + externalIds: person.externalIds ?? [], + }; + + if (!findTenant(person.tenantId)) { + console.warn(`[migrace] resitel ${person.id} patri do nezname firmy ${person.tenantId}`); + } + + const existing = email === '' ? undefined : findStoredUserByEmail(email); + if (!existing) { + const timestamp = nowIso(); + const created = await userStore.create({ + id: `usr_${randomUUID().slice(0, 8)}`, + tenantId: null, + // Bez e-mailu se nejde prihlasit, ale ucet musi vzniknout, jinak by + // tickety prisly o resitele. Spravce adresu doplni v Nastaveni. + email: email === '' ? `${person.id}@placeholder.cz` : email, + name: person.name, + passwordHash: await hashPassword(randomBytes(18).toString('base64url')), + platformAdmin: false, + enabled: person.enabled !== false, + memberships: [{ tenantId: person.tenantId, roleIds: ['role_agent'], ...membershipFields }], + createdAt: timestamp, + updatedAt: timestamp, + }); + // Do kopie v pameti, aby dalsi resitel se stejnym e-mailem nasel tenhle ucet. + await refreshUsers(); + return { id: created.id, created: true }; + } + + const current = findStoredUser(existing.id) ?? existing; + const membership = current.memberships.find((item) => item.tenantId === person.tenantId); + const memberships: Membership[] = membership + ? current.memberships.map((item) => + item.tenantId === person.tenantId ? { ...item, ...membershipFields } : item, + ) + : [...current.memberships, { tenantId: person.tenantId, roleIds: ['role_agent'], ...membershipFields }]; + + await userStore.update(current.id, { memberships } as Partial, anyTenant); + await refreshUsers(); + return { id: current.id, created: false }; +} + +/** Prepise ID v clenech skupiny. Vraci true, kdyz se neco zmenilo. */ +async function remapGroup(group: PersonGroup, map: Map): Promise { + const patch: Partial = {}; + let changed = false; + + if (group.members) { + const members = group.members.map((member) => + map.has(member.personId) ? { ...member, personId: map.get(member.personId)! } : member, + ); + if (members.some((member, index) => member !== group.members[index])) { + patch.members = members; + changed = true; + } + } + if (group.personIds) { + const personIds = group.personIds.map((id) => map.get(id) ?? id); + if (personIds.some((id, index) => id !== group.personIds![index])) { + patch.personIds = personIds; + changed = true; + } + } + + if (!changed) return false; + await groupStore.update(group.id, patch, { tenantIds: [group.tenantId], includeGlobal: true }); + return true; +} + +/** Nahradi cela ID v JSON podobe hodnoty. Vraci novou hodnotu, nebo null beze zmeny. */ +function remapJson(value: T, map: Map): T | null { + const before = JSON.stringify(value); + let after = before; + for (const [oldId, newId] of map) { + after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId)); + } + return after === before ? null : (JSON.parse(after) as T); +} + +export async function migratePeople(): Promise { + await legacyStore.init(); + const legacy = await legacyStore.listAll(); + if (legacy.length === 0) return; + + console.info(`[migrace] ${legacy.length} starych zaznamu resitelu, prevadim na ucty`); + + // Stare ID -> ID uctu. Jeden ucet muze pokryt vic starych resitelu + // (tentyz e-mail ve dvou firmach), mapa je proto z ID, ne z e-mailu. + const map = new Map(); + let created = 0; + for (const person of legacy) { + try { + const account = await accountFor(person); + map.set(person.id, account.id); + if (account.created) created += 1; + } catch (err) { + console.error( + `[migrace] resitel ${person.id} (${person.email}) se nepodaril prevest:`, + err instanceof Error ? err.message : err, + ); + } + } + + // Tickety a automatizace se prepisuji v kopii v pameti a ukladaji pres + // svoje zrcadlo. Skupiny, akce a widgety jdou pres uloziste a obnovu kopie. + const tickets = remapTickets(map); + const automations = remapAutomations(map); + + let groups = 0; + for (const group of await groupStore.listAll()) { + if (await remapGroup(group, map)) groups += 1; + } + if (groups > 0) await refreshGroups(); + + let actions = 0; + for (const action of await actionStore.listAll()) { + const body = remapJson(action.body, map); + if (!body) continue; + await actionStore.update(action.id, { body }, { tenantIds: [action.tenantId], includeGlobal: true }); + actions += 1; + } + if (actions > 0) await refreshActions(); + + let widgets = 0; + for (const widget of await customWidgetStore.listAll()) { + const source = remapJson(widget.source, map); + if (!source) continue; + await customWidgetStore.update( + widget.id, + { source }, + { tenantIds: [widget.tenantId], includeGlobal: true }, + ); + widgets += 1; + } + if (widgets > 0) await refreshCustomWidgets(); + + // Stare zaznamy pryc, jen ty prevedene. Neprevedeny zustane a migrace se + // k nemu pri dalsim startu vrati. + const removed = await legacyStore.removeMany([...map.keys()]); + await legacyStore.flush(); + + console.info( + `[migrace] resitele -> ucty: ${map.size} prevedeno (${created} novych uctu), ` + + `tickety ${tickets}, automatizace ${automations}, skupiny ${groups}, ` + + `akce ${actions}, widgety ${widgets}, smazano ${removed} starych zaznamu`, + ); +} diff --git a/src/data/notifications.ts b/src/data/notifications.ts index f7371d6..1f674b1 100644 --- a/src/data/notifications.ts +++ b/src/data/notifications.ts @@ -8,22 +8,22 @@ * a hlaska v portalu. Kdo ma portal zavreny, uvidi to pri prihlaseni, protoze * upozorneni jsou ulozena, ne jen poslana. * - * Spojka mezi resitelem a uctem je **e-mail**: resitel nemusi mit ucet - * (viz data/people.ts), a kdyz ho nema, upozorneni se zahodi. + * Resitel je clenstvi uctu (viz data/people.ts), takze ID resitele je rovnou + * ID uctu, kteremu upozorneni patri. Vypnuty ucet nema komu co ukazat, + * upozorneni se zahodi. */ import { randomUUID } from 'node:crypto'; import { publish } from '../events/bus.js'; -import { findPerson } from './people.js'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; -import { findUserByEmail } from './users.js'; +import { findUserById } from './users.js'; export type NotificationKind = 'ticket.assigned' | 'ticket.mentioned' | 'automation.failed'; export interface Notification extends TenantEntity { tenantId: string; - /** Komu to patri. ID uzivatelskeho uctu, ne resitele. */ + /** Komu to patri. ID uctu (= ID resitele). */ userId: string; kind: NotificationKind; title: string; @@ -45,8 +45,8 @@ export async function refreshNotifications(): Promise { export interface NotifyInput { tenantId: string; - /** Resitel, kteremu to patri. Ucet se dohleda pres e-mail. */ - personId: string; + /** Komu to patri. ID uctu, tedy totez co ID resitele. */ + userId: string; kind: NotificationKind; title: string; href?: string | null; @@ -60,12 +60,9 @@ export interface NotifyInput { * prirazeni ticketu - stejna dohoda jako u auditu. */ export function notify(input: NotifyInput): void { - const person = findPerson(input.personId); - if (!person) return; - - const user = findUserByEmail(person.email); + const user = findUserById(input.userId); if (!user) { - // Resitel bez uctu je bezna vec. Neni komu to ukazat, ale neni to chyba. + // Vypnuty nebo smazany ucet: neni komu to ukazat, ale neni to chyba. return; } diff --git a/src/data/people.ts b/src/data/people.ts index 18b6b6d..58d87a2 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -1,89 +1,134 @@ /** * Resitele ticketu - lide, kteri maji pozadavek "u sebe". * - * Zamerne oddelene od `users.ts`. Uzivatel je ten, kdo se prihlasi do portalu, - * resitel je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy: - * technik muze mit tickety a do portalu se nikdy neprihlasit. - * Spojka mezi obojim je e-mail. + * **Resitel je clenstvi uctu ve firme, ne vlastni zaznam.** Kazdy clen firmy + * muze mit tickety u sebe, takze ID resitele je ID uctu. Driv byl resitel + * zvlastni zaznam spojeny s uctem pres e-mail; e-mail je ale prihlasovaci + * jmeno, ktere spravce muze zmenit, a tim se vazba tise rozpadla - clovek + * najednou nevidel "moje tickety" a nikdo nevedel proc. * - * Resitel se hleda pri kazdem requestu (filtr "moje tickety"), proto se drzi - * kopie v pameti. Vzor je v `store/cached.ts`. + * `Person` zustava jako pohled pro API: jmeno a e-mail z uctu, popisek, + * kapacita, externi ID a role z clenstvi. Tentyz clovek ve dvou firmach je + * dvakrat, pokazde se stejnym `id` a jinym `tenantId`. Nic se tu neuklada, + * vsechno se odvozuje z kopie uctu v pameti (`users.ts`). */ import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; import type { Person } from '../shared/people.js'; +import type { Membership } from '../types.js'; +import { allStoredUsers, findStoredUser, type StoredUser } from './users.js'; /** Tvar resitele je sdileny s webem, viz src/shared/people.ts. */ export type { Person }; -export const personStore = defineStore('person'); -const cache = withCache(personStore); +/** Kolik nevyrizenych ticketu je zdrava zatez, kdyz clenstvi nerekne jinak. */ +export const DEFAULT_CAPACITY = 8; -export function seedPeople(): Person[] { - const timestamp = nowIso(); - const base = { enabled: true, externalIds: [], createdAt: timestamp, updatedAt: timestamp }; - - return [ - { ...base, id: 'ppl_vomacka', tenantId: 'tnt_automia', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Servicedesk', capacity: 8 }, - { ...base, id: 'ppl_uhlir', tenantId: 'tnt_automia', name: 'Jiří Uhlíř', email: 'admin@automia.cz', role: 'Vedoucí týmu', capacity: 5 }, - { ...base, id: 'ppl_kriz', tenantId: 'tnt_automia', name: 'Martin Kříž', email: 'martin.kriz@automia.cz', role: 'Integrace a API', capacity: 6 }, - { ...base, id: 'ppl_novakova', tenantId: 'tnt_automia', name: 'Eva Nováková', email: 'eva.novakova@automia.cz', role: 'Voiceboti', capacity: 6 }, - // Nordis ma vlastni tym. Karel Vomacka je jeho spravcem, ale resitele - // ma Nordis svoje - proto je videt, ze tenant neni jen stitek. - { ...base, id: 'ppl_bartos', tenantId: 'tnt_nordis', name: 'Lukáš Bartoš', email: 'lukas.bartos@nordis.cz', role: 'Podpora', capacity: 7 }, - { ...base, id: 'ppl_horakova', tenantId: 'tnt_nordis', name: 'Simona Horáková', email: 'simona.horakova@nordis.cz', role: 'Fakturace', capacity: 5 }, - { ...base, id: 'ppl_kadlec', tenantId: 'tnt_logitrans', name: 'Ondřej Kadlec', email: 'ondrej.kadlec@logitrans.cz', role: 'Dispečink', capacity: 6 }, - ]; +/** Pohled resitele z uctu a jednoho jeho clenstvi. Jedine misto, kde vznika. */ +export function personView(user: StoredUser, membership: Membership): Person { + return { + id: user.id, + tenantId: membership.tenantId, + name: user.name, + email: user.email, + role: membership.role ?? '', + capacity: membership.capacity ?? DEFAULT_CAPACITY, + // Vypnuty ucet je vypnuty vsude, vypnute clenstvi jen tady. + enabled: user.enabled && membership.enabled !== false, + externalIds: membership.externalIds ?? [], + roleIds: membership.roleIds, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; } -export async function refreshPeople(): Promise { - await cache.refresh(); +function membershipIn(user: { memberships: Membership[] }, tenantId: string): Membership | undefined { + return user.memberships.find((membership) => membership.tenantId === tenantId); +} + +/** + * ID resitele, kterym uzivatel ve firme je. `null` = neni jejim clenem. + * + * Je to ID uctu, ale volajici se ma ptat tudy a ne brat `user.id` primo: + * spravce platformy bez clenstvi ve firme resitelem neni a "moje tickety" + * u nej nemaji co ukazat. + */ +export function personIdFor( + user: { id: string; memberships: Membership[] }, + tenantId: string | null, +): string | null { + if (!tenantId) return null; + return membershipIn(user, tenantId) ? user.id : null; +} + +function viewsIn(users: StoredUser[], tenantIds: string[]): Person[] { + const views: Person[] = []; + for (const user of users) { + for (const membership of user.memberships) { + if (tenantIds.includes(membership.tenantId)) views.push(personView(user, membership)); + } + } + return views.sort((a, b) => a.name.localeCompare(b.name, 'cs')); } /** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ export function listPeople(tenantIds: string[]): Person[] { - return cache.listByTenant(tenantIds, 'name').filter((person) => person.enabled); + // Aktivni = aktivni ucet i aktivni clenstvi v te firme, viz personView. + return viewsIn(allStoredUsers(), tenantIds).filter((person) => person.enabled); } /** Vcetne vypnutych. Pro spravu tymu. */ export function listAllPeople(tenantIds: string[]): Person[] { - return cache.listByTenant(tenantIds, 'name'); + return viewsIn(allStoredUsers(), tenantIds); } /** * Resitel podle ID z cizi aplikace. * * Hleda se **jen ve vybranych firmach**: dve firmy mohou mit voicebota se - * stejnym ID a ticket nesmi skoncit u cizi firmy. + * stejnym ID a ticket nesmi skoncit u cizi firmy. Externi ID visi na + * clenstvi, takze tentyz clovek muze mit v kazde firme jine. */ export function findPersonByExternalId(value: string, tenantIds: string[]): Person | undefined { const needle = value.trim(); if (needle === '') return undefined; - return cache - .all() - .find( - (person) => - tenantIds.includes(person.tenantId) && - person.enabled !== false && - (person.externalIds ?? []).some((id) => id.trim() === needle), + for (const user of allStoredUsers()) { + if (!user.enabled) continue; + const membership = user.memberships.find( + (item) => + tenantIds.includes(item.tenantId) && + item.enabled !== false && + (item.externalIds ?? []).some((id) => id.trim() === needle), ); + if (membership) return personView(user, membership); + } + return undefined; } -export function findPerson(id: string): Person | undefined { - return cache.byId(id); +/** + * Resitel v dane firme, vcetne vypnuteho uctu. + * + * Firma je povinna: kdo v ni neni clenem, v ni resitelem neni, i kdyz ucet + * existuje. Diky tomu je kontrola "resitel z jine firmy" jen `undefined`. + */ +export function findPerson(id: string, tenantId: string): Person | undefined { + const user = findStoredUser(id); + if (!user) return undefined; + const membership = membershipIn(user, tenantId); + return membership ? personView(user, membership) : undefined; } -/** Spojka na prihlaseneho uzivatele - podle ni funguje filtr "moje tickety". */ -export function findPersonByEmail(email: string): Person | undefined { - const normalized = email.trim().toLowerCase(); - return cache.find((person) => person.email.toLowerCase() === normalized && person.enabled); -} - -/** Vsichni resitele bez ohledu na firmu. Jen pro nabidky v katalogu. */ -export function allPeople(): Person[] { - return cache.all(); +/** + * Jmeno cloveka bez ohledu na firmu. + * + * Jmeno je vlastnost uctu, ne clenstvi, takze tu firma neni potreba. Pro + * popisky (napr. sloupec "podle resitele" ve widgetu), kde uz ticket sam + * prosel filtrem na firmu. + */ +export function personName(id: string): string | undefined { + return findStoredUser(id)?.name; } // ------------------------------------------------------------------- skupiny @@ -103,6 +148,7 @@ export function allPeople(): Person[] { * nikdy nedokazala rict, ta je jedna na celou firmu. */ export interface GroupMember { + /** ID resitele, tedy ID uctu. Nazev pole zustava kvuli ulozenym zaznamum. */ personId: string; /** * true = vidi vsechny tickety skupiny, ne jen svoje. @@ -160,9 +206,10 @@ export function seedGroups(): PersonGroup[] { tenantId: 'tnt_automia', name: 'Servicedesk', // Vomacka sekci vede, takze vidi vsechno v ni. Kriz jen svoje. + // ID clenu jsou ID uctu, viz hlavicka souboru. members: [ - { personId: 'ppl_vomacka', seesAll: true }, - { personId: 'ppl_kriz', seesAll: false }, + { personId: 'usr_2', seesAll: true }, + { personId: 'usr_3', seesAll: false }, ], createdAt: timestamp, updatedAt: timestamp, diff --git a/src/data/refresh.ts b/src/data/refresh.ts index c533fd3..d1dbfc8 100644 --- a/src/data/refresh.ts +++ b/src/data/refresh.ts @@ -23,7 +23,8 @@ const routeKinds: Record = { tenants: 'tenant', users: 'user', roles: 'role', - people: 'person', + // Resitel je clenstvi uctu, zapis do lidi meni ucty. + people: 'user', groups: 'personGroup', features: 'tenantFeatures', 'ticket-types': 'ticketType', @@ -38,7 +39,7 @@ let queuedFull = false; /** * Obnovi kopie v pameti po zapisu. * - * `route` je cesta v routeru nastaveni, napr. `/people/ppl_1`. Podle ni se + * `route` je cesta v routeru nastaveni, napr. `/people/usr_1`. Podle ni se * pozna, co se zmenilo. Bez ni se obnovi vsechno. */ export function bootstrapDataRefresh(route?: string): Promise { diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index b64d9c9..4634a63 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -242,6 +242,33 @@ export async function initTickets(): Promise { traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter); } +/** + * Prepise ID resitelu podle mapy stare -> nove. Vraci pocet zmenenych ticketu. + * + * Jen pro migraci (data/migratePeople.ts): resitel byval vlastni zaznam + * `ppl_...`, dnes je to ID uctu. Meni se jen odkazy, `updatedAt` ani hooky + * se nespousteji - ticket se vecne nezmenil, jen se preznacil. + */ +export function remapPersonIds(map: Map): number { + let changed = 0; + for (const ticket of tickets) { + let touched = false; + if (ticket.assigneeId && map.has(ticket.assigneeId)) { + ticket.assigneeId = map.get(ticket.assigneeId)!; + touched = true; + } + if (ticket.resolvedById && map.has(ticket.resolvedById)) { + ticket.resolvedById = map.get(ticket.resolvedById)!; + touched = true; + } + if (touched) { + changed += 1; + persist(ticket); + } + } + return changed; +} + let ticketCounter = 4_821; let traceCounter = 0; @@ -351,7 +378,7 @@ function seedDemoTickets(): void { }, status: 'V řešení', priority: 'high', - assigneeId: 'ppl_novakova', + assigneeId: 'usr_novakova', automationId: 'AUT-02', createdAt: minutesAgo(310), updatedAt: minutesAgo(42), @@ -465,7 +492,7 @@ function seedDemoTickets(): void { }, status: 'Čeká na klienta', priority: 'normal', - assigneeId: 'ppl_kriz', + assigneeId: 'usr_3', automationId: 'AUT-03', createdAt: minutesAgo(1_180), updatedAt: minutesAgo(190), @@ -642,7 +669,7 @@ function seedDemoTickets(): void { }, status: 'V řešení', priority: 'low', - assigneeId: 'ppl_kadlec', + assigneeId: 'usr_kadlec', automationId: null, createdAt: minutesAgo(2_600), updatedAt: minutesAgo(420), @@ -702,7 +729,7 @@ function seedDemoTickets(): void { status: 'Vyřešeno', closed: true, priority: 'critical', - assigneeId: 'ppl_bartos', + assigneeId: 'usr_bartos', automationId: null, createdAt: minutesAgo(5_100), updatedAt: minutesAgo(1_500), @@ -764,10 +791,10 @@ function toTicket(stored: StoredTicket): Ticket { if (!assigneeId) return { ...base, assignee: null }; - const person = findPerson(assigneeId); + const person = findPerson(assigneeId, stored.tenantId); if (!person) { - // Resitel zmizel ze seznamu - ticket nesmi spadnout, ale chceme o tom vedet. - console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz neexistuje`); + // Resitel uz neni clenem firmy - ticket nesmi spadnout, ale chceme o tom vedet. + console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz ve firme neni`); return { ...base, assignee: null }; } return { ...base, assignee: { id: person.id, name: person.name } }; @@ -1288,7 +1315,7 @@ function applyValues(ticket: StoredTicket, apply: TicketApply | undefined): void // Stejne jako pri zalozeni: mrtvy odkaz na resitele radeji nez ulozit. if (apply.assigneeId) { - if (findPerson(apply.assigneeId)) ticket.assigneeId = apply.assigneeId; + if (findPerson(apply.assigneeId, ticket.tenantId)) ticket.assigneeId = apply.assigneeId; else console.warn(`[tickets] neznamy resitel ${apply.assigneeId}, ticket zustava jak byl`); } @@ -1491,7 +1518,7 @@ export function createTicket(input: CreateTicketInput): Ticket { // Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz. let assigneeId = input.assigneeId ?? null; - if (assigneeId && !findPerson(assigneeId)) { + if (assigneeId && !findPerson(assigneeId, input.tenantId)) { console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`); assigneeId = null; } @@ -1631,15 +1658,11 @@ export function assignTicket( return undefined; } - const person = assigneeId ? findPerson(assigneeId) : null; + // Resitel musi byt clenem firmy ticketu. Jinak by ticket zmizel z prehledu + // firmy a objevil se nekomu, kdo do ni nepatri. Necleny `findPerson` nevrati. + const person = assigneeId ? findPerson(assigneeId, ticket.tenantId) : null; if (assigneeId && !person) { - console.warn(`[tickets] ${id}: prirazeni na neznameho resitele ${assigneeId}`); - return undefined; - } - // Resitel musi byt z tehoz tymu. Jinak by ticket zmizel z prehledu firmy - // a objevil se nekomu, kdo do ni nepatri. - if (person && person.tenantId !== ticket.tenantId) { - console.warn(`[tickets] ${id}: resitel ${person.id} je z jine firmy`); + console.warn(`[tickets] ${id}: prirazeni na ${assigneeId}, ktery neni clenem firmy`); return undefined; } @@ -1653,7 +1676,7 @@ export function assignTicket( if (person && person.id !== previousAssignee) { notify({ tenantId: ticket.tenantId, - personId: person.id, + userId: person.id, kind: 'ticket.assigned', title: `Máte nový ticket ${ticket.id}: ${ticket.subject}`, href: `/dashboard/tickety/${ticket.id}`, @@ -1766,8 +1789,9 @@ export function claimTicket( const ticket = findWritable(id, tenantIds); if (!ticket) return undefined; - const person = findPerson(personId); - if (!person || person.tenantId !== ticket.tenantId) return undefined; + // Necleny firmy `findPerson` nevrati, takze tohle je i kontrola firmy. + const person = findPerson(personId, ticket.tenantId); + if (!person) return undefined; ticket.assigneeId = person.id; markResponded(ticket); diff --git a/src/data/users.ts b/src/data/users.ts index 56042e9..512287d 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -32,6 +32,10 @@ const DEMO_PASSWORD = 'demo1234'; * - platformni admin, ktery vidi napric firmami, * - clovek ve dvou firmach, v kazde s jinou roli, * - bezny resitel jedne firmy. + * + * Resitel je clenstvi, ne zvlastni zaznam (viz data/people.ts). Popisek + * a kapacita proto visi na clenstvi: Nordis a LogiTrans maji svuj tym, aby + * bylo videt, ze firma neni jen stitek. */ export function seedUsers(): StoredUser[] { const timestamp = nowIso(); @@ -50,7 +54,7 @@ export function seedUsers(): StoredUser[] { email: 'admin@automia.cz', name: 'Jiří Uhlíř', platformAdmin: true, - memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'] }], + memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'], role: 'Vedoucí týmu', capacity: 5 }], }, { ...base, @@ -60,8 +64,8 @@ export function seedUsers(): StoredUser[] { platformAdmin: false, // Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk. memberships: [ - { tenantId: 'tnt_automia', roleIds: ['agent'] }, - { tenantId: 'tnt_nordis', roleIds: ['admin'] }, + { tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Servicedesk', capacity: 8 }, + { tenantId: 'tnt_nordis', roleIds: ['admin'], role: 'Správce servicedesku', capacity: 8 }, ], }, { @@ -70,7 +74,39 @@ export function seedUsers(): StoredUser[] { email: 'martin.kriz@automia.cz', name: 'Martin Kříž', platformAdmin: false, - memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'] }], + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Integrace a API', capacity: 6 }], + }, + { + ...base, + id: 'usr_novakova', + email: 'eva.novakova@automia.cz', + name: 'Eva Nováková', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Voiceboti', capacity: 6 }], + }, + { + ...base, + id: 'usr_bartos', + email: 'lukas.bartos@nordis.cz', + name: 'Lukáš Bartoš', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'], role: 'Podpora', capacity: 7 }], + }, + { + ...base, + id: 'usr_horakova', + email: 'simona.horakova@nordis.cz', + name: 'Simona Horáková', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'], role: 'Fakturace', capacity: 5 }], + }, + { + ...base, + id: 'usr_kadlec', + email: 'ondrej.kadlec@logitrans.cz', + name: 'Ondřej Kadlec', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_logitrans', roleIds: ['agent'], role: 'Dispečink', capacity: 6 }], }, ]; } @@ -115,6 +151,27 @@ export function findUserById(id: string): User | undefined { return found && found.enabled ? toUser(found) : undefined; } +/** + * Zaznam uctu vcetne vypnuteho. + * + * Pro pohled resitele (data/people.ts): vypnuty clovek ma dal jmeno u svych + * starych ticketu, jen se nenabizi k prirazeni. + */ +export function findStoredUser(id: string): StoredUser | undefined { + return cache.byId(id); +} + +/** Ucet podle e-mailu vcetne vypnuteho. Pro kontrolu unikatnosti a migraci. */ +export function findStoredUserByEmail(email: string): StoredUser | undefined { + const normalized = email.trim().toLowerCase(); + return cache.find((user) => user.email.toLowerCase() === normalized); +} + +/** Vsechny ucty bez razeni. Pro odvozene pohledy, ktere si radi samy. */ +export function allStoredUsers(): StoredUser[] { + return cache.all(); +} + /** * Asynchronne zamerne. `hashSync` blokuje event loop na desitky milisekund * a po tu dobu nikdo jiny nedostane odpoved - u hesla se to opakuje pri diff --git a/src/data/widgets.ts b/src/data/widgets.ts index 64441d2..82cac04 100644 --- a/src/data/widgets.ts +++ b/src/data/widgets.ts @@ -202,9 +202,9 @@ export function widgetCatalog( ); /* - * Kdo neni veden jako resitel, nema "moje tickety" co ukazat: ticket se - * prirazuje resiteli, ne uctu. Nabidnout mu prazdnou dlazdici by znamenalo, - * ze uvidi prazdno navzdy a nedozvi se proc. + * `hasPerson` = je clenem firmy. Kdo v ni clenstvi nema (spravce platformy + * na cizi firme), nema "moje tickety" co ukazat. Nabidnout mu prazdnou + * dlazdici by znamenalo, ze uvidi prazdno navzdy a nedozvi se proc. */ const builtin = widgets.filter((widget) => hasPerson || widget.id !== 'list.myTickets'); diff --git a/src/openapi.ts b/src/openapi.ts index 98ccc9f..83f9aec 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -97,6 +97,7 @@ const settingsEntities = [ { path: 'tenants', label: 'firmy', permission: 'tenant.manage' }, { path: 'users', label: 'uzivatele', permission: 'user.manage' }, { path: 'roles', label: 'role a prava', permission: 'role.manage' }, + // Resitele maji vlastni popis v `additionalPaths`: pod endpointy jsou ucty. { path: 'people', label: 'resitele', permission: 'people.manage' }, { path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' }, { path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' }, @@ -149,6 +150,109 @@ const tooMany = { '429': { description: 'Prilis mnoho pokusu z jedne adresy, viz * Pridane pri kontrole uplnosti: kazda registrovana routa musi byt tady. */ const additionalPaths: Record = { + /* + * Resitel je clenstvi uctu ve firme, ID resitele je ID uctu. Endpointy + * a pravo zustavaji, ale zalozeni zaklada ucet (nebo prida clenstvi uz + * existujicimu) a smazani odebira clenstvi. Prepisuje obecny popis z + * `settingsEntities`, protoze telo je jine nez u ostatnich entit. + */ + '/api/dashboard/settings/people': { + get: { + tags: ['Nastaveni'], + summary: 'Seznam - resitele', + description: 'Clenove vybrane firmy vcetne vypnutych uctu, jeden pohled na clenstvi.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Resitele', { + type: 'object', + properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Person' } } }, + }), + }, + }, + post: { + tags: ['Nastaveni'], + summary: 'Vytvorit - resitele', + description: + 'Zalozi ucet s clenstvim ve vybrane firme. Kdyz ucet s tim e-mailem uz existuje ' + + 'a ve firme neni, prida se mu jen clenstvi (jmeno, heslo a zapnuti se neprepisuji). ' + + 'Bez hesla dostane nahodne. Role musi byt teto firmy nebo systemove, vychozi role_agent.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['name', 'email'], + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', format: 'password', description: 'Nepovinne, jinak nahodne.' }, + roleIds: { type: 'array', items: { type: 'string' }, default: ['role_agent'] }, + role: { type: 'string', description: 'Popisek, cim se v tymu zabyva.' }, + capacity: { type: 'integer', default: 8 }, + externalIds: { type: 'array', items: { type: 'string' } }, + enabled: { type: 'boolean', default: true }, + }, + }), + responses: { + '201': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '400': { description: 'Neplatny vstup, neznama role, nebo uz je clenem firmy' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + }, + }, + }, + '/api/dashboard/settings/people/{id}': { + get: { + tags: ['Nastaveni'], + summary: 'Detail - resitele', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '404': { description: 'Neni clenem firmy' }, + }, + }, + patch: { + tags: ['Nastaveni'], + summary: 'Upravit - resitele', + description: + 'Jmeno, e-mail a zapnuti meni ucet (plati ve vsech firmach), role, popisek, kapacita ' + + 'a externi ID meni clenstvi v teto firme. E-mail musi zustat unikatni.', + security: bearer, + parameters: [idParam, tenantParam], + requestBody: jsonBody({ + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + enabled: { type: 'boolean' }, + roleIds: { type: 'array', items: { type: 'string' } }, + role: { type: 'string' }, + capacity: { type: 'integer' }, + externalIds: { type: 'array', items: { type: 'string' } }, + }, + }), + responses: { + '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '400': { description: 'Neplatny vstup, neznama role, nebo obsazeny e-mail' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + '404': { description: 'Neni clenem firmy' }, + }, + }, + delete: { + tags: ['Nastaveni'], + summary: 'Smazat - resitele', + description: + 'Odebere clenstvi ve vybrane firme. Ucet zustava; bez jedineho clenstvi se vypne ' + + '(spravce platformy ne).', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '204': { description: 'Clenstvi odebrano' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + '404': { description: 'Neni clenem firmy' }, + }, + }, + }, '/api/dashboard/people/{id}': { get: { tags: ['Tickety'], @@ -231,7 +335,7 @@ const additionalPaths: Record = { parameters: [idParam, tenantParam], responses: { '200': jsonResponse('Prevzato', { $ref: '#/components/schemas/Ticket' }), - '400': { description: 'Volajici neni veden jako resitel' }, + '400': { description: 'Volajici neni clenem firmy' }, '403': { description: 'Chybi pravo ticket.assign.self, nebo ticket neni ve skupine volajiciho' }, '404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' }, '409': { description: 'Ticket uz nekdo resi' }, @@ -330,7 +434,7 @@ const additionalPaths: Record = { email: { type: 'string', description: 'Prazdne = komukoliv s odkazem.' }, note: { type: 'string' }, roleIds: { type: 'array', items: { type: 'string' } }, - asPerson: { type: 'boolean', default: true }, + asPerson: { type: 'boolean', description: 'Stary priznak, ignoruje se: resitel je kazdy clen firmy.' }, }, }), responses: { @@ -564,8 +668,9 @@ const additionalPaths: Record = { tags: ['Nastaveni'], summary: 'Zalozit firmu z ARES vcetne uctu', description: - 'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet se roli spravce firmy ' + - 'a nahodnym heslem; bez e-mailu dostanou nahradni IC-poradi@placeholder.cz.', + 'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet s roli spravce firmy ' + + 'a nahodnym heslem; funkce z rejstriku jde do popisku clenstvi. Bez e-mailu dostanou ' + + 'nahradni IC-poradi@placeholder.cz.', security: bearer, requestBody: jsonBody({ type: 'object', @@ -582,6 +687,11 @@ const additionalPaths: Record = { properties: { name: { type: 'string' }, email: { type: 'string', description: 'Prazdne = nahradni e-mail.' }, + roles: { + type: 'array', + items: { type: 'string' }, + description: 'Funkce z rejstriku, jde do popisku clenstvi.', + }, }, }, }, @@ -629,7 +739,7 @@ const additionalPaths: Record = { '/api/dashboard/settings/people-overview': { get: { tags: ['Nastaveni'], - summary: 'Resitele vcetne vypnutych', + summary: 'Resitele vcetne vypnutych (totez co seznam)', security: bearer, parameters: [tenantParam], responses: { '200': jsonResponse('Resitele') }, @@ -1041,16 +1151,26 @@ export function buildOpenApiDocument() { }, Person: { type: 'object', - description: 'Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail.', + description: + 'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' + + 'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.', properties: { - id: { type: 'string', example: 'ppl_vomacka' }, + id: { type: 'string', example: 'usr_2', description: 'ID uctu.' }, + tenantId: { type: 'string', example: 'tnt_automia' }, name: { type: 'string', example: 'Karel Vomacka' }, email: { type: 'string', format: 'email' }, - role: { type: 'string', example: 'Servicedesk' }, + role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' }, capacity: { type: 'integer', description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.', }, + enabled: { type: 'boolean', description: 'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.' }, + externalIds: { type: 'array', items: { type: 'string' } }, + roleIds: { + type: 'array', + items: { type: 'string' }, + description: 'Role clenstvi v teto firme.', + }, }, }, TicketCustomer: { @@ -1099,7 +1219,7 @@ export function buildOpenApiDocument() { nullable: true, description: 'Kdo ma ticket u sebe. null = ceka ve fronte.', properties: { - id: { type: 'string', example: 'ppl_vomacka' }, + id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' }, name: { type: 'string', example: 'Karel Vomacka' }, }, }, @@ -1494,8 +1614,8 @@ export function buildOpenApiDocument() { tags: ['Tickety'], summary: 'Seznam resitelu', description: - 'Lide, na ktere jde ticket priradit. `meId` je resitel odpovidajici ' + - 'prihlasenemu uzivateli, nebo null, pokud zadny neni.', + 'Clenove firmy, na ktere jde ticket priradit. `meId` je ID prihlaseneho uctu, ' + + 'nebo null, kdyz ve firme neni clenem.', security: [{ bearerAuth: [] }], responses: { '200': { @@ -1676,7 +1796,7 @@ export function buildOpenApiDocument() { type: 'object', required: ['assigneeId'], properties: { - assigneeId: { type: 'string', nullable: true, example: 'ppl_vomacka' }, + assigneeId: { type: 'string', nullable: true, example: 'usr_2', description: 'ID uctu clena firmy.' }, }, }, }, diff --git a/src/routes/ares.ts b/src/routes/ares.ts index 06ed737..de6c076 100644 --- a/src/routes/ares.ts +++ b/src/routes/ares.ts @@ -23,7 +23,7 @@ import { import { recordAudit } from '../data/audit.js'; import { nowIso } from '../data/store/index.js'; import { generateIntakeToken, listTenants, tenantStore, type Tenant } from '../data/tenants.js'; -import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js'; +import { hashPassword, listAllUsers, refreshUsers, userStore, type StoredUser } from '../data/users.js'; import { safeRouter } from '../middleware/asyncHandler.js'; import { requirePlatformAdmin } from '../middleware/auth.js'; import { validationError } from '../middleware/validation.js'; @@ -83,6 +83,8 @@ const createSchema = z.object({ name: z.string().trim().min(2, 'Jméno je moc krátké.').max(80), /** Prazdne = nahradni e-mail podle poradi. */ email: z.string().trim().email('Zadejte platný e-mail.').optional().or(z.literal('')), + /** Funkce z rejstriku (jednatel, clen predstavenstva). Jen popisek clenstvi. */ + roles: z.array(z.string().trim().max(80)).max(10).default([]), }), ) .max(50) @@ -90,7 +92,12 @@ const createSchema = z.object({ }); /** - * Zalozi firmu a ucty vybranych osob, vsechny jako spravce firmy. + * Zalozi firmu a ucty vybranych osob (vsechny jako spravce firmy). + * + * Resitel je clenstvi (viz data/people.ts), takze se novi lide objevi + * v Lidech rovnou a jde jim prihodit ticket - presne to, co spravce po + * zalozeni firmy udela jako prvni. Funkce z rejstriku jde do popisku + * clenstvi, nic jineho nerozhoduje. * * Udaje firmy se berou znovu z ARES, ne z klienta: co se ulozi k firme, ma * odpovidat registru, ne tomu, co prislo v tele. Heslo je nahodne a nikam se @@ -164,13 +171,23 @@ aresRouter.post('/tenants', async (req, res) => { name: person.name, passwordHash: await hashPassword(randomBytes(18).toString('base64url')), platformAdmin: false, - memberships: [{ tenantId: created.id, roleIds: ['role_admin'] }], + memberships: [ + { + tenantId: created.id, + roleIds: ['role_admin'], + role: person.roles.join(', '), + capacity: 8, + externalIds: [], + }, + ], enabled: true, createdAt: timestamp, updatedAt: timestamp, }; users.push(await userStore.create(user)); } + // Ucty se ctou z kopie v pameti, bez obnovy by nove lidi v Lidech nebyli. + if (users.length > 0) await refreshUsers(); recordAudit({ userId: req.user!.id, diff --git a/src/routes/helpdesk.ts b/src/routes/helpdesk.ts index 749d4f1..0cedbe3 100644 --- a/src/routes/helpdesk.ts +++ b/src/routes/helpdesk.ts @@ -28,7 +28,6 @@ import { z } from 'zod'; import { seesWholeTenant } from '../data/access.js'; import { recordAudit } from '../data/audit.js'; import { hasPermission } from '../data/permissions.js'; -import { findPersonByEmail } from '../data/people.js'; import { findTenant } from '../data/tenants.js'; import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js'; import { safeRouter } from '../middleware/asyncHandler.js'; @@ -153,7 +152,6 @@ helpdeskRouter.post('/', (req, res) => { if (!parsed.success) return validationError(res, parsed.error); const tenant = findTenant(tenantId); - const person = findPersonByEmail(req.user!.email); const ticket = createTicket({ // Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket. @@ -169,7 +167,8 @@ helpdeskRouter.post('/', (req, res) => { customer: { id: null, company: tenant?.name ?? '', - contact: person?.name ?? req.user!.email, + // Zadavatel je prihlaseny ucet, jmeno je jeho. + contact: req.user!.name, reply: req.user!.email, }, trace: [ @@ -224,8 +223,7 @@ helpdeskRouter.post('/:id/comment', (req, res) => { const parsed = commentSchema.safeParse(req.body); if (!parsed.success) return validationError(res, parsed.error); - const person = findPersonByEmail(req.user!.email); - const author = person?.name ?? req.user!.email; + const author = req.user!.name; const updated = addComment(req.params.id, author, parsed.data.text, [], [tenantId]); if (!updated) { diff --git a/src/routes/invites.ts b/src/routes/invites.ts index c8c8b0d..de6be13 100644 --- a/src/routes/invites.ts +++ b/src/routes/invites.ts @@ -25,7 +25,6 @@ import { refreshInvites, } from '../data/invites.js'; import { hasPermission, rolesFor, type Role } from '../data/permissions.js'; -import { personStore, refreshPeople, listPeople } from '../data/people.js'; import { findTenant } from '../data/tenants.js'; import { findUserByEmail, @@ -179,34 +178,10 @@ publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => { userId = created.id; } + // Resitel je clenstvi (viz data/people.ts), takze prijetim pozvanky je + // clovek rovnou i v Lidech. Zadny dalsi zaznam se nezaklada. 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({ @@ -276,7 +251,8 @@ 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), + /** Stary priznak, prijima se kvuli starsim klientum a ignoruje. */ + asPerson: z.boolean().optional(), }); inviteRouter.post('/', (req, res) => { @@ -304,7 +280,6 @@ inviteRouter.post('/', (req, res) => { email: parsed.data.email || undefined, note: parsed.data.note, roleIds: parsed.data.roleIds, - asPerson: parsed.data.asPerson, createdByEmail: req.user!.email, }); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 11e44d5..e99ca6b 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -10,7 +10,8 @@ * uzivatel ulozil roli a prava by se zmenila az po restartu. */ -import type { Request } from 'express'; +import { randomBytes, randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; import { z } from 'zod'; import { recordAudit } from '../data/audit.js'; import { bootstrapDataRefresh } from '../data/refresh.js'; @@ -21,7 +22,14 @@ import { validateWidget, type CustomWidget, } from '../data/customWidgets.js'; -import { groupStore, listAllPeople, personStore, type Person } from '../data/people.js'; +import { + DEFAULT_CAPACITY, + findPerson, + groupStore, + listAllPeople, + personView, + type Person, +} from '../data/people.js'; import { allPermissions, roleStore, @@ -54,7 +62,18 @@ import { validateAction, type TicketAction, } from '../data/ticketActions.js'; -import { hashPassword, listAllUsers, userStore, usersOfTenant, type StoredUser } from '../data/users.js'; +import { + findStoredUser, + findStoredUserByEmail, + hashPassword, + listAllUsers, + refreshUsers, + userStore, + usersOfTenant, + type StoredUser, +} from '../data/users.js'; +import { nowIso } from '../data/store/index.js'; +import type { Membership } from '../types.js'; import { hasPermission } from '../data/permissions.js'; import { publish } from '../events/bus.js'; import { safeRouter } from '../middleware/asyncHandler.js'; @@ -163,11 +182,30 @@ settingsRouter.use('/ares', aresRouter); // ---------------------------------------------------------------- uzivatele +/** + * Clenstvi nese i pole resitele (popisek, kapacita, externi ID, viditelnost). + * Prijimaji se nepovinne a pri uprave se **doplni z ulozeneho clenstvi**, viz + * `keepMembershipFields`: klient, ktery posila jen role, by je jinak smazal. + */ const membershipSchema = z.object({ tenantId: z.string().min(1), roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'), + seesAllTenant: z.boolean().optional(), + role: z.string().trim().max(60).optional(), + capacity: z.number().int().min(1).max(200).optional(), + externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + enabled: z.boolean().optional(), }); +/** Do prichozich clenstvi doplni pole, ktera klient neposlal, z ulozenych. */ +function keepMembershipFields(incoming: Membership[], existing?: StoredUser): Membership[] { + if (!existing) return incoming; + return incoming.map((membership) => { + const stored = existing.memberships.find((item) => item.tenantId === membership.tenantId); + return stored ? { ...stored, ...membership } : membership; + }); +} + const userCreate = z.object({ email: z.string().trim().email('Zadejte platný e-mail.'), name: z.string().trim().min(2).max(80), @@ -212,7 +250,11 @@ function prepareUserInput( input: UserInput | undefined, existing?: StoredUser, ): { ok: true; input: UserInput | undefined } | { ok: false; status: number; message: string } { - if (req.user!.platformAdmin) return { ok: true, input }; + if (req.user!.platformAdmin) { + return input?.memberships + ? { ok: true, input: { ...input, memberships: keepMembershipFields(input.memberships, existing) } } + : { ok: true, input }; + } if (existing?.platformAdmin) { return { ok: false, status: 403, message: 'Správce platformy upravuje jen správce platformy.' }; @@ -252,7 +294,13 @@ function prepareUserInput( } // Clenstvi jinde zustavaji, ta spravce firmy nevidi a nesmi je smazat. const others = (existing?.memberships ?? []).filter((m) => m.tenantId !== tenantId); - return { ok: true, input: { ...input, memberships: [...others, ...input.memberships] } }; + return { + ok: true, + input: { + ...input, + memberships: [...others, ...keepMembershipFields(input.memberships, existing)], + }, + }; } return { ok: true, input }; @@ -409,53 +457,262 @@ settingsRouter.get('/roles-available', (req, res) => { // ------------------------------------------------------------------ resitele -const personCreate = z.object({ - name: z.string().trim().min(2).max(80), - email: z.string().trim().email(), +/* + * Resitel je clenstvi uctu ve firme, ne vlastni zaznam (viz data/people.ts). + * Endpointy a pravo `people.manage` zustavaji, ale pod nimi jsou ucty: + * zalozeni resitele zalozi ucet (nebo prida clenstvi uz existujicimu), + * smazani odebere clenstvi. `crudRouter` tu nejde pouzit, protoze zaznam, + * ktery se meni, je ucet bez firmy a odpoved je pohled za jednu firmu. + * + * Sprava uctu (`/users`) zustava spravci platformy. Kdyz tam nekomu zmeni + * jmeno, v Lidech se to projevi samo - pohled se odvozuje. + */ + +const personFields = { + /** Cim se v tymu zabyva. Jen popisek. */ role: z.string().trim().max(60).optional(), capacity: z.number().int().min(1).max(200).optional(), /** ID, pod kterymi cloveka znaji cizi aplikace, napr. voicebotId. */ externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + /** Role clenstvi v teto firme. Vychozi je bezny resitel. */ + roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.').optional(), +}; + +const personCreate = z.object({ + name: z.string().trim().min(2).max(80), + email: z.string().trim().email('Zadejte platný e-mail.'), + /** Bez hesla dostane nahodne; clovek si ho nastavi pres zmenu hesla. */ + password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.').optional(), + enabled: z.boolean().optional(), + ...personFields, }); -settingsRouter.use( - '/people', - crudRouter, Partial>({ - store: personStore, - idPrefix: 'ppl', - event: 'person', - createSchema: personCreate, - updateSchema: z.object({ - name: z.string().trim().min(2).max(80).optional(), - email: z.string().trim().email().optional(), - role: z.string().trim().max(60).optional(), - capacity: z.number().int().min(1).max(200).optional(), - enabled: z.boolean().optional(), - externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), - }), - writePermission: 'people.manage', - build: (input) => ({ - name: input.name, - email: input.email.toLowerCase(), - role: input.role ?? '', - capacity: input.capacity ?? 8, - enabled: true, - externalIds: input.externalIds ?? [], - }), - validate: (person, all) => - all.some((other) => other.email.toLowerCase() === person.email.toLowerCase()) - ? [`Řešitel s e-mailem ${person.email} už v této firmě je.`] - : [], - }), -); +const personUpdate = z.object({ + name: z.string().trim().min(2).max(80).optional(), + email: z.string().trim().email('Zadejte platný e-mail.').optional(), + enabled: z.boolean().optional(), + ...personFields, +}); -/** Vcetne vypnutych. Pro spravu tymu. */ +/** Firma a pravo spravovat jeji lidi. Pri odepreni odpovi a vrati null. */ +function managedPeopleTenant(req: Request, res: Response): string | null { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return null; + if (!hasPermission(req.user!, 'people.manage', tenantId)) { + console.warn(`[crud] ${req.user!.email}: chybi pravo people.manage u person`); + res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); + return null; + } + return tenantId; +} + +/** Role z teto firmy nebo systemove. Vraci popis problemu, nebo null. */ +function unknownRoles(roleIds: string[], tenantId: string): string | null { + const known = rolesFor(tenantId); + const unknown = roleIds.filter((ref) => !known.some((role) => role.id === ref || role.key === ref)); + return unknown.length > 0 ? `Role ${unknown.join(', ')} v této firmě neexistuje.` : null; +} + +/** Ucet s clenstvim v teto firme. Cizi se chova jako neexistujici. */ +function memberAccount(id: string, tenantId: string): StoredUser | undefined { + const user = findStoredUser(id); + return user && memberOf(user, tenantId) ? user : undefined; +} + +/** + * Spravce firmy na spravce platformy nesaha, stejne jako u `/users`. Jinak by + * mu pres Lide mohl vzit clenstvi nebo vypnout ucet. + */ +function guardPlatformAdmin(req: Request, res: Response, target: StoredUser): boolean { + if (target.platformAdmin && !req.user!.platformAdmin) { + res.status(403).json({ error: 'forbidden', message: 'Správce platformy upravuje jen správce platformy.' }); + return false; + } + return true; +} + +/** Ohlasi zmenu do streamu, aby si portal seznam opravil na miste. */ +function announcePerson(verb: 'created' | 'updated' | 'deleted', person: Person): void { + publish( + `person.${verb}`, + `person ${verb}: ${person.name}`, + verb === 'deleted' ? { id: person.id } : { id: person.id, person }, + person.tenantId, + ); +} + +settingsRouter.get('/people', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: listAllPeople([tenantId]) }); +}); + +/** Vcetne vypnutych. Pro spravu tymu. Totez co seznam, zustava kvuli klientum. */ settingsRouter.get('/people-overview', (req, res) => { const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ items: listAllPeople([tenantId]) }); }); +settingsRouter.get('/people/:id', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + const person = findPerson(req.params.id, tenantId); + if (!person) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + return res.json(person); +}); + +settingsRouter.post('/people', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const parsed = personCreate.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + const input = parsed.data; + + const roleIds = input.roleIds ?? ['role_agent']; + const roleProblem = unknownRoles(roleIds, tenantId); + if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem }); + + const membership: Membership = { + tenantId, + roleIds, + role: input.role ?? '', + capacity: input.capacity ?? DEFAULT_CAPACITY, + externalIds: input.externalIds ?? [], + // Zapnuti je za clenstvi: spravce firmy rozhoduje o cloveku u sebe, ne jinde. + enabled: input.enabled ?? true, + }; + + const email = input.email.toLowerCase(); + const existing = findStoredUserByEmail(email); + let saved: StoredUser | undefined; + + if (existing) { + /* + * Ucet uz je: clovek z jine firmy, nebo nekdo, komu spravce platformy + * zalozil ucet driv. Prida se jen clenstvi; jmeno, heslo ani priznak + * zapnuti se neprepisuji, ty nejsou teto firmy. + */ + if (!guardPlatformAdmin(req, res, existing)) return; + if (memberOf(existing, tenantId)) { + return res.status(400).json({ + error: 'validation_error', + message: `Řešitel s e-mailem ${email} už v této firmě je.`, + }); + } + saved = await userStore.update( + existing.id, + { memberships: [...existing.memberships, membership] } as Partial, + { tenantIds: [], includeGlobal: true }, + ); + } else { + const timestamp = nowIso(); + saved = await userStore.create({ + id: `usr_${randomUUID().slice(0, 8)}`, + tenantId: null, + email, + name: input.name, + // Nahodne heslo se nikam neposila, clovek si nastavi svoje. + passwordHash: await hashPassword(input.password ?? randomBytes(18).toString('base64url')), + platformAdmin: false, + memberships: [membership], + enabled: true, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + if (!saved) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + const person = personView(saved, membership); + announcePerson('created', person); + return res.status(201).json(person); +}); + +settingsRouter.patch('/people/:id', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const existing = memberAccount(req.params.id, tenantId); + if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + if (!guardPlatformAdmin(req, res, existing)) return; + + const parsed = personUpdate.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + const input = parsed.data; + + if (input.roleIds) { + const roleProblem = unknownRoles(input.roleIds, tenantId); + if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem }); + } + + const email = input.email?.toLowerCase(); + if (email !== undefined) { + const taken = findStoredUserByEmail(email); + if (taken && taken.id !== existing.id) { + return res.status(400).json({ error: 'validation_error', message: `E-mail ${email} už někdo má.` }); + } + } + + // Clenstvi v jinych firmach zustavaji, jak jsou - nejsou teto firmy. + const memberships = existing.memberships.map((item): Membership => { + if (item.tenantId !== tenantId) return item; + return { + ...item, + ...(input.roleIds !== undefined ? { roleIds: input.roleIds } : {}), + ...(input.role !== undefined ? { role: input.role } : {}), + ...(input.capacity !== undefined ? { capacity: input.capacity } : {}), + ...(input.externalIds !== undefined ? { externalIds: input.externalIds } : {}), + // Vypnuti jen tady. Ucet jako celek vypina jen sprava uzivatelu. + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + }; + }); + + const patch: Partial = { + memberships, + ...(input.name !== undefined ? { name: input.name } : {}), + ...(email !== undefined ? { email } : {}), + }; + + const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true }); + if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + const person = personView(updated, memberships.find((item) => item.tenantId === tenantId)!); + announcePerson('updated', person); + return res.json(person); +}); + +/** + * Smazani resitele = odebrani clenstvi. Ucet zustava: muze byt i v jine + * firme a i kdyz neni, jeho tickety a historie se na nej dal odkazuji. + * Ucet bez jedineho clenstvi se vypne, aby se s nim neslo prihlasit + * do prazdna; spravce platformy se nevypina, ten firmu nepotrebuje. + */ +settingsRouter.delete('/people/:id', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const existing = memberAccount(req.params.id, tenantId); + if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + if (!guardPlatformAdmin(req, res, existing)) return; + + const before = personView(existing, existing.memberships.find((item) => item.tenantId === tenantId)!); + const memberships = existing.memberships.filter((item) => item.tenantId !== tenantId); + const patch: Partial = { + memberships, + ...(memberships.length === 0 && !existing.platformAdmin ? { enabled: false } : {}), + }; + + const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true }); + if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + announcePerson('deleted', before); + return res.status(204).end(); +}); + // ------------------------------------------------------------------- skupiny /** diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index 70015c6..42b1b6e 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -23,7 +23,7 @@ import { type WidgetTicketFilter, } from '../data/customWidgets.js'; import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; -import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js'; +import { findGroup, listGroups, listPeople, personName } from '../data/people.js'; import { getPath } from '../scripts/mapping.js'; import { runScript } from '../scripts/runner.js'; import { scriptIdFor } from '../scripts/lookup.js'; @@ -142,7 +142,8 @@ function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string | function groupLabel(key: string, groupBy: WidgetGroupBy): string { switch (groupBy) { case 'assignee': - return key === '' ? 'Bez řešitele' : (findPerson(key)?.name ?? key); + // Jmeno je vlastnost uctu, ticket uz prosel filtrem na firmu. + return key === '' ? 'Bez řešitele' : (personName(key) ?? key); case 'group': return key === '' ? 'Bez skupiny' : (findGroup(key)?.name ?? key); case 'type': diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 21d5ad5..a61c62b 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -478,12 +478,13 @@ const handlers: Record = { const assigneeId = inputs.assigneeId?.trim(); if (!assigneeId) return missing('řešitele'); - const person = findPerson(assigneeId); - if (!person || person.tenantId !== context.tenantId) { + // Necleny firmy `findPerson` nevrati, cizi clovek je tedy "neexistuje". + const person = findPerson(assigneeId, context.tenantId); + if (!person) { return { ok: false, summary: `řešitel ${assigneeId} neexistuje`, - detail: 'Zkontrolujte ID řešitele v kroku. Řešitelé se spravují v Nastavení.', + detail: 'Zkontrolujte ID řešitele v kroku. Řešitelé jsou členové firmy, spravují se v Nastavení.', outputs: {}, }; } @@ -503,7 +504,7 @@ const handlers: Record = { * Preda ticket cloveku podle ID z cizi aplikace. * * Typicky pripad: voicebot posle `voicebotId` a ticket ma skoncit u toho, - * komu ten voicebot patri. Vazba je u resitele (`externalIds`), takze pri + * komu ten voicebot patri. Vazba je u clenstvi (`externalIds`), takze pri * zmene cloveka se meni na jednom miste, ne v kazde automatizaci. * * Kdyz se nikdo nenajde, muze se pouzit nahradni skupina - jinak by ticket diff --git a/src/shared/access.ts b/src/shared/access.ts index 94c5b7f..f9f26cb 100644 --- a/src/shared/access.ts +++ b/src/shared/access.ts @@ -38,7 +38,7 @@ export interface Access { defaultTenantId: string | null; /** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */ canAssignOthers: boolean; - /** ID resitele odpovidajiciho uzivateli, nebo null. */ + /** ID resitele = ID uctu, kdyz je clenem vybrane firmy. Jinak null. */ personId: string | null; /** Efektivni prava. Klient podle nich kresli tlacitka. */ permissions: string[]; diff --git a/src/shared/people.ts b/src/shared/people.ts index 8147897..853cd49 100644 --- a/src/shared/people.ts +++ b/src/shared/people.ts @@ -4,7 +4,14 @@ import type { TenantEntity } from './entities.js'; -/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */ +/** + * Resitel = clen firmy, jak ho vidi API. + * + * Neni to vlastni zaznam: `id` je ID uctu a `tenantId` firma, ve ktere ma + * clenstvi. Jmeno a e-mail jsou z uctu, `role`, `capacity` a `externalIds` + * z clenstvi (viz `Membership` v users.ts). Tentyz clovek ve dvou firmach je + * tedy dvakrat, pokazde se stejnym `id` a jinym `tenantId`. + */ export interface Person extends TenantEntity { /** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */ tenantId: string; @@ -18,6 +25,13 @@ export interface Person extends TenantEntity { enabled: boolean; /** ID, pod kterymi cloveka znaji cizi aplikace (voicebot, ustredna, chat). */ externalIds: string[]; + /** + * Role clenstvi v teto firme (ID nebo klic systemove role). + * + * Sprava lidi je ukazuje ve sloupci Role a nechava je menit. Nepovinne, + * aby starsi klient bez sloupce dal fungoval. + */ + roleIds?: string[]; } /** Skupina resitelu tak, jak ji vidi bezny uzivatel: jen jmeno. */ diff --git a/src/shared/users.ts b/src/shared/users.ts index 16f791b..35926e7 100644 --- a/src/shared/users.ts +++ b/src/shared/users.ts @@ -31,6 +31,27 @@ export interface Membership { * kazdemu spravci zmizela vetsina ticketu. */ seesAllTenant?: boolean; + /** + * Resitel je clenstvi, ne zvlastni zaznam. + * + * Kazdy clen firmy muze mit tickety u sebe, proto tyhle tri veci visi na + * clenstvi: v jedne firme je clovek dispecer s kapacitou 6, v druhe ucetni + * s kapacitou 3. ID resitele je ID uctu. Driv byl resitel vlastni zaznam + * spojeny s uctem pres e-mail, a zmena e-mailu tu vazbu rozbila. + */ + /** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */ + role?: string; + /** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. Vychozi 8. */ + capacity?: number; + /** ID, pod kterymi cloveka znaji cizi aplikace teto firmy (voicebot, ustredna). */ + externalIds?: string[]; + /** + * false = v teto firme se nenabizi k prirazeni, stare tickety mu zustavaji. + * + * Je to za clenstvi, ne za ucet: spravce firmy smi vypnout cloveka u sebe, + * ne v jine firme, kde ten clovek pracuje dal. `undefined` = aktivni. + */ + enabled?: boolean; } /** Verze uzivatele bezpecna pro odeslani na klienta. */ diff --git a/web/src/components/dashboard/AresTenantDialog.tsx b/web/src/components/dashboard/AresTenantDialog.tsx index 6cf10a9..79c3755 100644 --- a/web/src/components/dashboard/AresTenantDialog.tsx +++ b/web/src/components/dashboard/AresTenantDialog.tsx @@ -131,7 +131,7 @@ export function AresTenantDialog({ ico: company.ico, name: name.trim() || undefined, note: note.trim() || undefined, - persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim() })), + persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim(), roles: row.roles })), }, }); setCreated(result); @@ -161,7 +161,9 @@ export function AresTenantDialog({

{created.users.length > 0 ? (
-

Založené účty, všechny jako správce firmy:

+

+ Založené účty, všechny jako správce firmy. Najdete je v záložce Firma: +

    {created.users.map((user) => (
  • diff --git a/web/src/components/dashboard/GroupPanel.tsx b/web/src/components/dashboard/GroupPanel.tsx index d80d18a..3871fc5 100644 --- a/web/src/components/dashboard/GroupPanel.tsx +++ b/web/src/components/dashboard/GroupPanel.tsx @@ -265,7 +265,7 @@ export function GroupPanel() { {persons.length === 0 && (

    - Ve firmě zatím nikdo není. Nejdřív přidejte řešitele. + Ve firmě zatím nikdo není. Nejdřív přidejte lidi v záložce Řešitelé nebo je pozvěte.

    )}
diff --git a/web/src/components/dashboard/InvitePanel.tsx b/web/src/components/dashboard/InvitePanel.tsx index f99f16d..a3d4e4d 100644 --- a/web/src/components/dashboard/InvitePanel.tsx +++ b/web/src/components/dashboard/InvitePanel.tsx @@ -28,7 +28,6 @@ interface InviteRow { email: string; note: string; roleIds: string[]; - asPerson: boolean; expiresAt: string; usedAt: string | null; createdByEmail: string; @@ -47,7 +46,6 @@ export function InvitePanel() { const [email, setEmail] = useState(''); const [note, setNote] = useState(''); const [roleIds, setRoleIds] = useState([]); - const [asPerson, setAsPerson] = useState(true); const [copied, setCopied] = useState(null); const roles = invites.data?.roles ?? []; @@ -56,7 +54,7 @@ export function InvitePanel() { const creation = useSubmit(async () => { await apiFetch('/api/dashboard/invites', { method: 'POST', - body: { email, note, roleIds, asPerson }, + body: { email, note, roleIds }, }); setEmail(''); setNote(''); @@ -141,22 +139,7 @@ export function InvitePanel() { - - + {/* Kazdy clen firmy je zaroven resitel, zadna volba "zalozit i jako resitele" uz neni. */} {error &&

{error}

}