Resitel je clenstvi uctu, firma z ARES, prepinani jazyku schovane
Resitel uz neni vlastni zaznam spojeny s uctem pres e-mail: je to clenstvi uctu ve firme a jeho ID je ID uctu. Popisek, kapacita, externi ID a zapnuti visi na clenstvi, takze clovek ve dvou firmach je v kazde jinak a spravce firmy ho vypne jen u sebe. Odebrani z firmy odebere jen clenstvi. Stara data se pri startu jednou prevedou (migratePeople.ts), vcetne odkazu v ticketech, skupinach, automatizacich, akcich a widgetech. Sprava lidi v zalozce Lide zaklada ucty, pozvanka uz nema volbu resitele. Zalozeni firmy z registru ARES: hledani podle IC nebo nazvu, dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz. Vychozi rozlozeni dashboardu bez resitele neobsahuje list.myTickets. Prepinani jazyku je docasne schovane (MULTILANG_ENABLED), web je cesky. Dokumentace aktualizovana. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6eed909a0d
commit
bc6508e1f3
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+43
-5
@@ -97,9 +97,14 @@ Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni,
|
||||
uprava, mazani) na `/api/dashboard/settings/<entita>`, 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` |
|
||||
|
||||
+48
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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í. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-14
@@ -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(),
|
||||
|
||||
@@ -434,6 +434,31 @@ export async function initAutomations(): Promise<void> {
|
||||
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<string, string>): 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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+19
-4
@@ -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<TenantEntity>, seed: seedTenants },
|
||||
{ store: userStore as EntityStore<TenantEntity>, seed: seedUsers },
|
||||
{ store: roleStore as EntityStore<TenantEntity>, seed: systemRoles },
|
||||
{ store: personStore as EntityStore<TenantEntity>, seed: seedPeople },
|
||||
{ store: groupStore as EntityStore<TenantEntity>, seed: seedGroups },
|
||||
{ store: featuresStore as EntityStore<TenantEntity>, seed: seedFeatures },
|
||||
{ store: ticketTypeStore as EntityStore<TenantEntity>, 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.
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function initLayouts(): Promise<void> {
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+3
-7
@@ -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,
|
||||
|
||||
@@ -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<LegacyPerson>('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<StoredUser>, 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<string, string>): Promise<boolean> {
|
||||
const patch: Partial<PersonGroup> = {};
|
||||
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<T>(value: T, map: Map<string, string>): 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<void> {
|
||||
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<string, string>();
|
||||
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`,
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+95
-48
@@ -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>('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<void> {
|
||||
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,
|
||||
|
||||
+3
-2
@@ -23,7 +23,8 @@ const routeKinds: Record<string, string> = {
|
||||
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<void> {
|
||||
|
||||
+44
-20
@@ -242,6 +242,33 @@ export async function initTickets(): Promise<void> {
|
||||
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<string, string>): 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);
|
||||
|
||||
+61
-4
@@ -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
|
||||
|
||||
+3
-3
@@ -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');
|
||||
|
||||
|
||||
+132
-12
@@ -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<string, unknown> = {
|
||||
/*
|
||||
* 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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
'/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.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+20
-3
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-29
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+296
-39
@@ -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<Person, z.infer<typeof personCreate>, Partial<Person>>({
|
||||
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<StoredUser>,
|
||||
{ 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<StoredUser> = {
|
||||
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<StoredUser> = {
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -478,12 +478,13 @@ const handlers: Record<string, Handler> = {
|
||||
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<string, Handler> = {
|
||||
* 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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+15
-1
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
{created.users.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-white/60">Založené účty, všechny jako správce firmy:</p>
|
||||
<p className="text-sm text-white/60">
|
||||
Založené účty, všechny jako správce firmy. Najdete je v záložce Firma:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{created.users.map((user) => (
|
||||
<li key={user.id} className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -265,7 +265,7 @@ export function GroupPanel() {
|
||||
|
||||
{persons.length === 0 && (
|
||||
<p className="mt-4 text-sm text-white/40">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [asPerson, setAsPerson] = useState(true);
|
||||
const [copied, setCopied] = useState<string | null>(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() {
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="mt-4 flex items-start gap-2 text-sm text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asPerson}
|
||||
onChange={(event) => setAsPerson(event.target.checked)}
|
||||
className="mt-0.5 size-4 accent-brand-400"
|
||||
/>
|
||||
<span>
|
||||
Založit i jako řešitele
|
||||
<span className="mt-0.5 block text-xs text-white/35">
|
||||
Řešitel je ten, komu jde přehodit ticket. Účetní může mít přístup do portálu,
|
||||
aniž by kdy ticket řešila.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Kazdy clen firmy je zaroven resitel, zadna volba "zalozit i jako resitele" uz neni. */}
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Logo } from '@/components/layout/Logo';
|
||||
import { ButtonLink } from '@/components/ui/Button';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { mainNav } from '@/data/navigation';
|
||||
import { useT } from '@/i18n';
|
||||
import { MULTILANG_ENABLED, useT } from '@/i18n';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Navbar() {
|
||||
@@ -63,7 +63,7 @@ export function Navbar() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<LanguageSwitch />
|
||||
{MULTILANG_ENABLED && <LanguageSwitch />}
|
||||
{user ? (
|
||||
<ButtonLink to="/dashboard" size="sm">
|
||||
<LayoutDashboard className="size-4" />
|
||||
@@ -111,7 +111,7 @@ export function Navbar() {
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<LanguageSwitch variant="full" className="mt-3 px-1" />
|
||||
{MULTILANG_ENABLED && <LanguageSwitch variant="full" className="mt-3 px-1" />}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{user ? (
|
||||
|
||||
+13
-1
@@ -27,6 +27,13 @@ export const locales: Array<{ code: Locale; label: string; short: string }> = [
|
||||
|
||||
const STORAGE_KEY = 'automia.locale';
|
||||
|
||||
/**
|
||||
* Jeden prepinac pro cely web. `false` = prepinac jazyka se nekresli
|
||||
* a vzdy se pouzije cestina. Az bude preklad hotovy a znacka rozhodnuta,
|
||||
* staci prepnout tady.
|
||||
*/
|
||||
export const MULTILANG_ENABLED = false;
|
||||
|
||||
const dictionaries: Record<Locale, Partial<Record<MessageKey, string>>> = { cs, en };
|
||||
|
||||
/**
|
||||
@@ -36,6 +43,11 @@ const dictionaries: Record<Locale, Partial<Record<MessageKey, string>>> = { cs,
|
||||
* az na druhem miste - kdo si jednou prepnul, mysli to vazne.
|
||||
*/
|
||||
function detect(): Locale {
|
||||
// Prepinani jazyku je docasne schovane (rozhodnuti 2026-09-09): web je jen
|
||||
// cesky, dokud se nerozhodne o znacce. Slovniky zustavaji, at se preklad
|
||||
// neztrati; ulozena volba `en` se ignoruje, jinak by nekdo zustal v anglictine
|
||||
// bez moznosti prepnout zpet.
|
||||
if (!MULTILANG_ENABLED) return 'cs';
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'cs' || stored === 'en') return stored;
|
||||
@@ -57,7 +69,7 @@ export function getLocale(): Locale {
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale): void {
|
||||
if (locale === active) return;
|
||||
if (!MULTILANG_ENABLED || locale === active) return;
|
||||
active = locale;
|
||||
|
||||
// Kvuli cteckam a prohlizecum, ktere podle toho nabizeji preklad stranky.
|
||||
|
||||
@@ -226,59 +226,7 @@ export default function People() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'resitele' && (
|
||||
<EntityAdmin
|
||||
title="Řešitelé"
|
||||
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
|
||||
endpoint="/api/dashboard/settings/people"
|
||||
listEndpoint="/api/dashboard/settings/people-overview"
|
||||
onChanged={people.reload}
|
||||
columns={[
|
||||
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
|
||||
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
|
||||
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
|
||||
{
|
||||
label: 'Kapacita',
|
||||
narrow: true,
|
||||
render: (person: NamedRecord) => String(person.capacity ?? ''),
|
||||
},
|
||||
{
|
||||
label: 'Stav',
|
||||
narrow: true,
|
||||
render: (person: NamedRecord) =>
|
||||
person.enabled === false ? (
|
||||
<Badge tone="warn">vypnutý</Badge>
|
||||
) : (
|
||||
<Badge tone="ok">aktivní</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fields={[
|
||||
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
|
||||
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Zaměření',
|
||||
kind: 'text',
|
||||
hint: 'Jen popisek, nic nerozhoduje.',
|
||||
},
|
||||
{
|
||||
name: 'capacity',
|
||||
label: 'Kapacita',
|
||||
kind: 'number',
|
||||
initial: 8,
|
||||
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
|
||||
},
|
||||
{
|
||||
name: 'externalIds',
|
||||
label: 'ID z cizích aplikací',
|
||||
kind: 'tags',
|
||||
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
|
||||
},
|
||||
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{tab === 'resitele' && <PeopleAdmin onChanged={people.reload} />}
|
||||
|
||||
{tab === 'skupiny' && <GroupPanel />}
|
||||
|
||||
@@ -291,6 +239,140 @@ export default function People() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Role firmy, jak je vraci `/settings/roles-available`. Odkaz v clenstvi smi byt ID i klic. */
|
||||
interface RoleOption {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprava resitelu.
|
||||
*
|
||||
* Resitel neni vlastni zaznam, je to clen firmy: ID resitele je ID uctu.
|
||||
* Zalozeni tady proto zaklada ucet (nebo pridava clenstvi uz existujicimu)
|
||||
* a smazani jen rusi clenstvi. Driv byl resitel zvlastni zaznam spojeny
|
||||
* s uctem pres e-mail a zmena e-mailu tu vazbu rozbila.
|
||||
*
|
||||
* Vlastni komponenta, aby se role nacitaly az pri otevreni zalozky.
|
||||
*/
|
||||
function PeopleAdmin({ onChanged }: { onChanged: () => void }) {
|
||||
const roles = useApiQuery<{ items: RoleOption[] }>('/api/dashboard/settings/roles-available');
|
||||
const roleItems = useMemo(() => roles.data?.items ?? [], [roles.data]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() => roleItems.map((role) => ({ value: role.id, label: role.name })),
|
||||
[roleItems],
|
||||
);
|
||||
// Vychozi role noveho cloveka: agent. Bez role by nemel v portalu co delat.
|
||||
const agentRoleId = roleItems.find((role) => role.key === 'agent')?.id;
|
||||
|
||||
/** Jmena roli podle odkazu v clenstvi (ID role nebo klic systemove role). */
|
||||
function roleNames(ids: unknown): string {
|
||||
if (!Array.isArray(ids)) return '-';
|
||||
return (ids as string[])
|
||||
.map((id) => roleItems.find((role) => role.id === id || role.key === id)?.name ?? id)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityAdmin
|
||||
title="Řešitelé"
|
||||
description="Lidé ve firmě. Každý člen má účet do portálu a může mít u sebe tickety. Smazání jen odebere členství ve firmě, účet zůstává."
|
||||
endpoint="/api/dashboard/settings/people"
|
||||
listEndpoint="/api/dashboard/settings/people-overview"
|
||||
refetchOn={['person.created', 'person.updated', 'person.deleted']}
|
||||
onChanged={onChanged}
|
||||
columns={[
|
||||
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
|
||||
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
|
||||
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
|
||||
{
|
||||
label: 'Kapacita',
|
||||
narrow: true,
|
||||
render: (person: NamedRecord) => String(person.capacity ?? ''),
|
||||
},
|
||||
{ label: 'Role', render: (person: NamedRecord) => roleNames(person.roleIds) },
|
||||
{
|
||||
label: 'Stav',
|
||||
narrow: true,
|
||||
render: (person: NamedRecord) =>
|
||||
person.enabled === false ? (
|
||||
<Badge tone="warn">vypnutý</Badge>
|
||||
) : (
|
||||
<Badge tone="ok">aktivní</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fields={[
|
||||
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
|
||||
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Heslo',
|
||||
kind: 'text',
|
||||
createOnly: true,
|
||||
hint: 'Prázdné = náhodné heslo, člověk si ho nastaví přes pozvánku nebo mu ho změní správce.',
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: 'Role ve firmě',
|
||||
kind: 'multiselect',
|
||||
options: roleOptions,
|
||||
initial: agentRoleId ? [agentRoleId] : [],
|
||||
// Bez roli v zaznamu by prazdny vyber pri uprave role tise smazal.
|
||||
visible: (person) => person === null || Array.isArray(person.roleIds),
|
||||
hint: 'Co smí v portálu dělat. Nový člověk je agent.',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Zaměření',
|
||||
kind: 'text',
|
||||
hint: 'Jen popisek, nic nerozhoduje.',
|
||||
},
|
||||
{
|
||||
name: 'capacity',
|
||||
label: 'Kapacita',
|
||||
kind: 'number',
|
||||
initial: 8,
|
||||
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
|
||||
},
|
||||
{
|
||||
name: 'externalIds',
|
||||
label: 'ID z cizích aplikací',
|
||||
kind: 'tags',
|
||||
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
label: 'Aktivní v této firmě',
|
||||
kind: 'checkbox',
|
||||
initial: true,
|
||||
hint: 'Vypnutý se nenabízí k přiřazení, staré tickety mu zůstanou. Účet v jiných firmách to neovlivní.',
|
||||
},
|
||||
]}
|
||||
toBody={(values, entity) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
role: values.role,
|
||||
capacity: values.capacity,
|
||||
externalIds: values.externalIds,
|
||||
enabled: values.enabled !== false,
|
||||
};
|
||||
// Role se posilaji jen kdyz je bylo videt: pri zalozeni vzdy, pri uprave
|
||||
// jen kdyz je server v zaznamu vratil.
|
||||
if (!entity || Array.isArray(entity.roleIds)) body.roleIds = values.roleIds;
|
||||
// Prazdne heslo se neposila, server si vygeneruje nahodne.
|
||||
if (!entity && typeof values.password === 'string' && values.password !== '') {
|
||||
body.password = values.password;
|
||||
}
|
||||
return body;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function Tickets() {
|
||||
] as const).filter((item) => item.show);
|
||||
|
||||
/*
|
||||
* Kdo neni veden jako resitel, nema zalozku Moje. Vychozi stav ji ale
|
||||
* Kdo nesmi videt svoje tickety (chybi rozsah `mine`), nema zalozku Moje. Vychozi stav ji ale
|
||||
* predpoklada, protoze prava dorazi az po prvnim vykresleni - bez teto
|
||||
* pojistky by koukal na prazdno a zadna zalozka by nesvitila.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user