Firmy a prava: tenance napric portalem

Portal nemel zadnou tenanci. Kterykoliv prihlaseny uzivatel videl vsechny
tickety vsech firem i cely seznam resitelu, requireRole se nikde nevolal.

Tenant je hranice viditelnosti, tenantId na ticketu, resiteli i automatizaci.
Uzivatel muze patrit do vic firem, v kazde s jinou roli. Pristup napric firmami
je zvlast jako platformAdmin.

Tri pohledy na tickety: all, tenant, mine. Admin mezi nimi prepina vcetne
vyberu firmy. O pravech rozhoduje jedine data/access.ts, klient si nic
nedovozuje a bere je z GET /api/dashboard/access.

Filtr na firmu je v ulozistich povinny argument, takze zapomenuty filtr
neznamena vse, ale nezkompiluje se. Cizi firma vraci 403 nebo 404, nikdy
tise zuzeny vysledek.

Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin,
agent si smi vzit ticket na sebe.

Zmena prihlasovani: ucet klient@firma.cz zanikl, demo ucty jsou nove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-03 12:59:30 +02:00
parent dd021b5f69
commit 2a3d75c85e
25 changed files with 997 additions and 163 deletions
+2
View File
@@ -28,6 +28,8 @@ React aplikaci ze slozky `dist/public`.
| Obsah ticketu a sablony | hotovo | `{{parametr}}` ze spoustece do poli akce |
| Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok |
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
| Databaze | chybi | data jsou v pameti, restart je vrati na vychozi stav |
@@ -41,6 +41,8 @@ image jen `dist`, takze staci jedna slozka.
| `src/routes/contact.ts` | poptavkovy formular z webu |
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu |
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji |
| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal |
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti |
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci |
@@ -87,6 +89,14 @@ Kod zustava anglicky.
**Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout,
takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy.
**Filtr na firmu je povinny argument.** `listTickets`, `listPeople`
i `listAutomations` vyzaduji `tenantIds`. Zapomenuty filtr tak neznamena "vse",
ale nezkompiluje se. Podrobnosti v [07-firmy-a-prava.md](07-firmy-a-prava.md).
**Prava se nikdy nedovozuji na klientovi.** Server vraci `GET /api/dashboard/access`
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).
+15 -4
View File
@@ -22,6 +22,7 @@ Vyzaduji `Authorization: Bearer <token>`:
| ------ | --------------------------------------------------- |
| GET | `/api/auth/me` |
| POST | `/api/auth/logout` |
| GET | `/api/dashboard/access` |
| GET | `/api/dashboard/summary` |
| GET | `/api/dashboard/people` |
| GET | `/api/dashboard/tickets` |
@@ -113,14 +114,24 @@ curl -X POST https://services.csbot.cz/apps/<app-id>/webhook/<token> \
Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci
nevykona - runtime neexistuje.
## Firmy a pohledy
Prava popisuje [07-firmy-a-prava.md](07-firmy-a-prava.md), tady jen API.
Endpointy dashboardu berou `scope` (`all`, `tenant`, `mine`) a `tenantId`.
`GET /api/dashboard/access` rekne, co uzivatel smi, aby to klient nedovozoval.
Pozadavek na pohled nebo firmu bez opravneni vraci **403 nebo 404**, nikdy tise
zuzeny vysledek. Uzivatel nesmi koukat na cizi cisla v domneni, ze jsou spravna.
## Tickety
Popis modelu je v [06-tickety.md](06-tickety.md), tady jen to, co se tyka API.
`GET /api/dashboard/tickets` bere filtry v query: `assignee`, `status`, `channel`.
U `assignee` jsou dve zvlastni hodnoty: `me` znamena resitele odpovidajiciho
prihlasenemu uzivateli, `unassigned` frontu bez resitele. Neznama hodnota filtru
se zaloguje a ignoruje - je lepsi ukazat vic ticketu nez prazdny seznam
`GET /api/dashboard/tickets` bere navic filtry `assignee`, `status`, `channel`.
U `assignee` je zvlastni hodnota `unassigned` pro frontu bez resitele.
U pohledu `mine` se `assignee` ignoruje, pohled je silnejsi. Neznama hodnota
filtru se zaloguje a ignoruje - je lepsi ukazat vic ticketu nez prazdny seznam
bez vysvetleni.
Odpoved nese vedle `items` jeste `meId`. Klient podle nej pozna, ktere tickety
+18
View File
@@ -96,6 +96,24 @@ autorita, kopie na klientovi existuje jen proto, aby UI nenabidlo nesmysl.
Dokud spoustec nema zadny parametr, nejde pridat podminka - nebylo by podle ceho
se rozhodovat. Dialog to vysvetli.
## Sirka karet pri zanoreni
Vetve ANO a NE jsou vedle sebe jen tehdy, kdyz je na to v dane karte misto.
Rozhoduje **sirka karty, ne sirka okna** - pouzivaji se container queries
(`@container` a `@2xl:grid-cols-2` v `FlowCanvas.tsx`).
Duvod: kazde zanoreni pulí dostupnou sirku. S beznym `lg:grid-cols-2` vypadal
strom na sirokem monitoru dobre v prvni urovni a ve treti uz mel karty siroke
par desitek pixelu, takze se popisy lamaly po jednom slove. Container query se
od urcite hloubky sama prepne na vetve pod sebou.
Ze stejneho duvodu se v uzke karte skryva popis akce (`hidden @xs:block`),
zmensuje ikona a ovladaci tlacitka se skladaji do sloupce. Nazev kroku
a nastaveni poli zustavaji vzdy videt - to je to podstatne.
**Pri uprave stromu nepouzivat `sm:` / `lg:` na veci uvnitr karet.** Reaguji
na okno a v zanoreni lzou.
## Co je v kterem kroku videt
Krok vidi parametry spoustece **plus vystupy vsech kroku pred nim**. Akce muze
+124
View File
@@ -0,0 +1,124 @@
# 07 - Firmy a prava
## Dve ruzne "firmy"
Snadno se pletou, proto hned na zacatku:
- **Tenant** je firma, ktera portal pouziva. Ma v nem svuj tym, svoje tickety
a svoje automatizace. Je to hranice viditelnosti.
- **`ticket.customer`** je zakaznik toho tenanta, tedy kdo pozadavek poslal.
S pravy nema nic spolecneho.
Tenanti jsou v `src/data/tenants.ts`.
## Uzivatel muze byt ve vic firmach
Proto `memberships`, ne jedno `tenantId`. Typicky externista, ktery dela
servicedesk dvema klientum a v kazdem ma jinou roli.
```ts
interface User {
id: string;
email: string;
name: string;
platformAdmin: boolean; // vidi napric vsemi firmami
memberships: Membership[]; // { tenantId, role }
}
type TenantRole = 'admin' | 'agent';
```
**Role je vzdy az uvnitr firmy.** Pristup napric firmami je zvlast jako
`platformAdmin` - to je nase pravo, ne zakaznicke. Kdyby byla role jen jedna
globalni, nesla by tahle situace vubec zapsat.
## Tri pohledy na tickety
| Pohled | Co ukazuje | Kdo smi |
| -------- | ------------------------- | ------------------------------ |
| `all` | napric vsemi firmami | jen `platformAdmin` |
| `tenant` | cela jedna firma | kdokoliv, kdo do ni patri |
| `mine` | jen tickety prihlaseneho | kdo ma navazaneho resitele |
Posilaji se jako query: `?scope=tenant&tenantId=tnt_automia`.
U `tenant` a `mine` je potreba vedet **kterou** firmu, protoze uzivatel jich
muze mit vic. Bez `tenantId` se pouzije prvni.
## Server je autorita
Vsechno rozhoduje `src/data/access.ts`, jedno misto pro cely portal. Kdyby se to
rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene.
`GET /api/dashboard/access` vraci, co uzivatel smi:
```json
{
"scopes": ["all", "tenant", "mine"],
"tenants": [{ "id": "tnt_automia", "name": "Automia" }],
"defaultTenantId": "tnt_automia",
"canAssignOthers": true,
"personId": "ppl_uhlir"
}
```
Klient podle toho kresli prepinac. **Nesmi si to dovozovat sam** - jinak by se
prava pocitala na dvou mistech a jednou se rozejdou.
## Nikdy tise nezuzujeme
Pozadavek na pohled nebo firmu, na kterou uzivatel nema pravo, vraci **chybu**,
ne potichu zuzeny vysledek:
| Situace | Odpoved |
| -------------------------------- | ------- |
| pohled bez opravneni | 403 |
| firma, do ktere nepatri | 404 |
| ucet bez firmy | 403 |
| prirazeni ostatnim bez prava | 403 |
Duvod: kdyby se pozadavek na cizi firmu jen prepnul na vlastni, uzivatel by
koukal na cizi cisla v domneni, ze jsou spravna. To je horsi nez chyba.
## Filtr v ulozisti je povinny
`listTickets`, `listPeople` a `listAutomations` maji `tenantIds` jako **povinny**
argument, ne volitelny. Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se.
Zapis (`assignTicket`, `updateTicketStatus`, `addComment`, `updateAutomation`,
`deleteAutomation`) bere `tenantIds` taky. Cizi zaznam se chova jako neexistujici,
tedy 404, ne 403 - z odpovedi nemá jit poznat, ze takove ID vubec existuje.
Vyjimka je verejny webhook. Ten se autorizuje tokenem v adrese, ne prihlasenim,
takze si automatizaci najde pres vsechny firmy.
## Prirazeni jen v ramci firmy
`assignTicket` odmitne resitele z jine firmy. Jinak by ticket zmizel z prehledu
sve firmy a objevil se nekomu, kdo do ni nepatri.
Prehazovat praci mezi lidmi smi jen `admin`. `agent` si smi vzit ticket na sebe,
ale nemuze ho poslat kolegovi - to hlida `canAssignOthers`.
## Demo ucty
Heslo je u vsech `demo1234`.
| E-mail | Kdo je |
| ------------------------- | ----------------------------------------------- |
| `admin@automia.cz` | spravce platformy, vidi vsechny tri firmy |
| `karel.vomacka@automia.cz`| agent v Automii, admin u Nordisu - dve firmy |
| `martin.kriz@automia.cz` | bezny resitel jedne firmy |
Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi
podle toho, ktera firma je prave zvolena.
## Co chybi
| Chybi | Poznamka |
| ------------------------ | ---------------------------------------------------- |
| Sprava clenstvi z portalu| memberships jdou zmenit jen v kodu |
| Pozvanky uzivatelu | zadny onboarding |
| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se |
| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje |
| Audit pristupu | odepreni se jen loguje, nikde se neuklada |
+23
View File
@@ -91,6 +91,29 @@ co vratil. Bez toho byla predvalidace k nicemu.
Prijmove zamerne neprirazuji, smerovani si ticket prevezme a podminkou
`assigned neni splneno` neprepise rucni rozhodnuti.
### Firmy a prava
Treti a nejvazneji dira: portal nemel zadnou tenanci. Kterykoliv prihlaseny
uzivatel videl vsechny tickety vsech firem a cely seznam resitelu, `requireRole`
se nikde nevolal. Popis v [07-firmy-a-prava.md](07-firmy-a-prava.md).
- Tenant jako hranice viditelnosti. `tenantId` na ticketu, resiteli
i automatizaci.
- Uzivatel muze patrit do **vic firem**, v kazde s jinou roli (`memberships`).
Pristup napric firmami je zvlast jako `platformAdmin`.
- Tri pohledy na tickety: `all`, `tenant`, `mine`. Admin mezi nimi prepina,
vcetne vyberu firmy.
- `src/data/access.ts` jako jedine misto, kde se rozhoduje o pravech.
`GET /api/dashboard/access` rekne klientovi, co smi kreslit.
- Filtr na firmu je v ulozistich **povinny argument**. Zapomenuty filtr
neznamena "vse", ale nezkompiluje se.
- Nikdy tise nezuzujeme. Cizi firma vraci 403 nebo 404.
- Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin,
agent si smi vzit ticket na sebe.
- `requireRole` nahrazen `requirePlatformAdmin`. Prava uvnitr firmy resi
`access.ts`, protoze zavisi na tom, ktera firma pozadavek zajima.
- Demo ucty pokryvaji vsechny tri situace vcetne cloveka ve dvou firmach.
### Zapsano jako otevrene rozhodnuti
Vsechny automatizace se stejnym spoustecem se spusti. Doporucene rozdeleni na to
+151
View File
@@ -0,0 +1,151 @@
/**
* Kdo co vidi. Jedno misto pro cely portal - kdyby se to rozlezlo po routach,
* driv nebo pozdeji nekde vznikne endpoint, ktery filtr zapomene.
*
* Tri pohledy na tickety:
* - `all` napric vsemi firmami, jen pro platformniho admina,
* - `tenant` cela jedna firma, pro kohokoliv, kdo do ni patri,
* - `mine` jen tickety prihlaseneho cloveka.
*
* Uzivatel muze patrit do vic firem, takze `tenant` a `mine` vzdy potrebuji
* vedet, o kterou firmu jde.
*/
import { findPersonByEmail } from './people.js';
import { findTenant, listTenants, type Tenant } from './tenants.js';
import type { User } from '../types.js';
export type TicketScope = 'all' | 'tenant' | 'mine';
export interface Access {
/** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */
scopes: TicketScope[];
/** Firmy, mezi kterymi muze prepinat. */
tenants: Tenant[];
/** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */
defaultTenantId: string | null;
/** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */
canAssignOthers: boolean;
/** ID resitele odpovidajiciho uzivateli, nebo null. */
personId: string | null;
}
export function accessFor(user: User): Access {
const person = findPersonByEmail(user.email);
const tenants = user.platformAdmin
? listTenants()
: user.memberships
.map((membership) => findTenant(membership.tenantId))
.filter((tenant): tenant is Tenant => {
if (!tenant) console.warn(`[access] ${user.email}: clenstvi v nezname firme`);
return tenant !== undefined;
})
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
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');
// Admin aspon v jedne firme, nebo platformni admin, smi prehazovat praci.
const isAdminSomewhere =
user.platformAdmin || user.memberships.some((membership) => membership.role === 'admin');
return {
scopes,
tenants,
defaultTenantId: tenants[0]?.id ?? null,
canAssignOthers: isAdminSomewhere,
personId: person?.id ?? null,
};
}
export interface ResolvedScope {
scope: TicketScope;
/** Firmy, ze kterych se smi vracet data. Prazdne pole = nic. */
tenantIds: string[];
/** Firma, na kterou je pohled zamereny. null u pohledu `all`. */
tenantId: string | null;
/** Vyplnene jen u pohledu `mine`. */
personId: string | null;
}
export interface ScopeDenied {
status: 403 | 404;
error: string;
message: string;
}
/**
* Prevede pozadavek klienta na to, co se smi vratit.
*
* Zamerne vraci chybu misto tiseho zuzeni. Kdyby se pozadavek na cizi firmu
* jen potichu prepnul na vlastni, uzivatel by koukal na cizi cisla v domneni,
* ze jsou spravna.
*/
export function resolveScope(
user: User,
requested: { scope?: string; tenantId?: string },
): ResolvedScope | ScopeDenied {
const access = accessFor(user);
const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as
| TicketScope
| string;
if (scope !== 'all' && scope !== 'tenant' && scope !== 'mine') {
return { status: 403, error: 'unknown_scope', message: `Neznámý pohled „${scope}".` };
}
if (!access.scopes.includes(scope)) {
console.warn(`[access] ${user.email}: pokus o pohled ${scope} bez opravneni`);
return {
status: 403,
error: 'forbidden',
message:
scope === 'all'
? 'Pohled přes všechny firmy má jen správce platformy.'
: 'K tomuto pohledu nemáte oprávnění.',
};
}
if (scope === 'all') {
return {
scope,
tenantIds: access.tenants.map((tenant) => tenant.id),
tenantId: null,
personId: null,
};
}
const tenantId = requested.tenantId ?? access.defaultTenantId;
if (!tenantId) {
return {
status: 403,
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy.',
};
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[access] ${user.email}: pokus o firmu ${tenantId} bez clenstvi`);
return {
status: 404,
error: 'not_found',
message: 'Firma neexistuje, nebo do ní nepatříte.',
};
}
return {
scope,
tenantIds: [tenantId],
tenantId,
personId: scope === 'mine' ? access.personId : null,
};
}
export function isDenied(value: ResolvedScope | ScopeDenied): value is ScopeDenied {
return 'status' in value;
}
+51 -17
View File
@@ -78,6 +78,8 @@ export function generateWebhookToken(): string {
/** Polozka v seznamu automatizaci - bez celeho stromu. */
export interface Automation {
id: string;
/** Firma, ktere automatizace patri. Hranice viditelnosti. */
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
@@ -104,6 +106,7 @@ export interface AutomationDetail extends Automation {
interface StoredAutomation {
id: string;
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
@@ -284,9 +287,15 @@ function nextId(): string {
return `AUT-${String(idCounter).padStart(2, '0')}`;
}
function seed(automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind'>) {
function seed(
automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind' | 'tenantId'> & {
tenantId?: string;
},
) {
const id = nextId();
store.set(id, {
// Ukazkova data patri Automii, kdyz neni receno jinak.
tenantId: 'tnt_automia',
...automation,
id,
kind: deriveKind(automation.flow),
@@ -758,23 +767,31 @@ function toDetail(stored: StoredAutomation): AutomationDetail {
};
}
export function listAutomations(): Automation[] {
/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */
export function listAutomations(tenantIds: string[]): Automation[] {
// Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt.
return [...store.values()]
.filter((stored) => tenantIds.includes(stored.tenantId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
}
export function getAutomation(id: string): AutomationDetail | undefined {
export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined {
const stored = store.get(id);
return stored ? toDetail(stored) : undefined;
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`);
return undefined;
}
return toDetail(stored);
}
export function createAutomation(name: string): AutomationDetail {
export function createAutomation(name: string, tenantId: string): AutomationDetail {
const id = nextId();
const now = new Date().toISOString();
const stored: StoredAutomation = {
id,
tenantId,
name,
kind: 'workflow',
enabled: false,
@@ -792,13 +809,25 @@ export function createAutomation(name: string): AutomationDetail {
return toDetail(stored);
}
/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */
function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined {
const stored = store.get(id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[automations] pokus o zapis do ${id} mimo povolene firmy`);
return undefined;
}
return stored;
}
export function updateAutomation(
id: string,
patch: { name?: string; enabled?: boolean; flow?: AutomationFlow },
tenantIds: string[],
): AutomationDetail | undefined {
const stored = store.get(id);
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] pokus o upravu neexistujici automatizace: ${id}`);
console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`);
return undefined;
}
@@ -865,10 +894,13 @@ function withWebhookToken(
}
/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */
export function regenerateWebhookToken(id: string): AutomationDetail | undefined {
const stored = store.get(id);
export function regenerateWebhookToken(
id: string,
tenantIds: string[],
): AutomationDetail | undefined {
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] regenerace tokenu pro neexistujici automatizaci: ${id}`);
console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`);
return undefined;
}
if (stored.flow.trigger?.connectorId !== 'webhook') {
@@ -898,10 +930,11 @@ export function findByWebhookToken(token: string): AutomationDetail | undefined
}
/** Zapise beh automatizace - drzi metriky i graf zive. */
export function recordRun(id: string, ok = true): AutomationDetail | undefined {
const stored = store.get(id);
export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined {
// Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim.
const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id);
if (!stored) {
console.warn(`[automations] recordRun pro neexistujici automatizaci: ${id}`);
console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`);
return undefined;
}
@@ -928,11 +961,12 @@ export function recordRun(id: string, ok = true): AutomationDetail | undefined {
return toDetail(updated);
}
export function deleteAutomation(id: string): boolean {
const name = store.get(id)?.name;
const existed = store.delete(id);
export function deleteAutomation(id: string, tenantIds: string[]): boolean {
const stored = findWritable(id, tenantIds);
const name = stored?.name;
const existed = stored !== undefined && store.delete(id);
if (!existed) {
console.warn(`[automations] pokus o smazani neexistujici automatizace: ${id}`);
console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`);
} else {
console.info(`[automations] smazana automatizace ${id}`);
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
+3 -3
View File
@@ -24,10 +24,10 @@ export function getRunsSeries(days = 14) {
return series;
}
export function getSummary() {
const tickets = listTickets();
export function getSummary(tenantIds: string[]) {
const tickets = listTickets({ tenantIds });
const incidents = listIncidents();
const automations = listAutomations();
const automations = listAutomations(tenantIds);
const series = getRunsSeries();
// Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila.
+37 -2
View File
@@ -11,6 +11,8 @@
export interface Person {
id: string;
/** Firma, jejiz je clenem tymu. Hranice viditelnosti. */
tenantId: string;
name: string;
email: string;
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
@@ -22,6 +24,7 @@ export interface Person {
export const people: Person[] = [
{
id: 'ppl_vomacka',
tenantId: 'tnt_automia',
name: 'Karel Vomáčka',
email: 'karel.vomacka@automia.cz',
role: 'Servicedesk',
@@ -29,6 +32,7 @@ export const people: Person[] = [
},
{
id: 'ppl_uhlir',
tenantId: 'tnt_automia',
name: 'Jiří Uhlíř',
email: 'admin@automia.cz',
role: 'Vedoucí týmu',
@@ -36,6 +40,7 @@ export const people: Person[] = [
},
{
id: 'ppl_kriz',
tenantId: 'tnt_automia',
name: 'Martin Kříž',
email: 'martin.kriz@automia.cz',
role: 'Integrace a API',
@@ -43,15 +48,45 @@ export const people: Person[] = [
},
{
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.
{
id: 'ppl_bartos',
tenantId: 'tnt_nordis',
name: 'Lukáš Bartoš',
email: 'lukas.bartos@nordis.cz',
role: 'Podpora',
capacity: 7,
},
{
id: 'ppl_horakova',
tenantId: 'tnt_nordis',
name: 'Simona Horáková',
email: 'simona.horakova@nordis.cz',
role: 'Fakturace',
capacity: 5,
},
{
id: 'ppl_kadlec',
tenantId: 'tnt_logitrans',
name: 'Ondřej Kadlec',
email: 'ondrej.kadlec@logitrans.cz',
role: 'Dispečink',
capacity: 6,
},
];
export function listPeople(): Person[] {
return [...people].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 people
.filter((person) => tenantIds.includes(person.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
export function findPerson(id: string): Person | undefined {
+31
View File
@@ -0,0 +1,31 @@
/**
* Tenant = firma, ktera servicedesk provozuje.
*
* POZOR na dve ruzne "firmy", ktere se snadno pletou:
* - tenant je nas zakaznik, ten, kdo portal pouziva a ma v nem svuj tym,
* - `ticket.customer` je zakaznik toho tenanta, tedy kdo pozadavek poslal.
*
* Tenant je hranice viditelnosti. Nic se nesmi vratit napric tenanty,
* pokud to vyslovne nepovoli role `platform`.
*
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu.
*/
export interface Tenant {
id: string;
name: string;
}
export const tenants: Tenant[] = [
{ id: 'tnt_automia', name: 'Automia' },
{ id: 'tnt_nordis', name: 'Nordis a.s.' },
{ id: 'tnt_logitrans', name: 'LogiTrans' },
];
export function listTenants(): Tenant[] {
return [...tenants].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
export function findTenant(id: string): Tenant | undefined {
return tenants.find((t) => t.id === id);
}
+75 -21
View File
@@ -73,6 +73,8 @@ export interface TicketTraceEntry {
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
tenantId: string;
subject: string;
/**
* Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru.
@@ -194,6 +196,7 @@ function seed(ticket: StoredTicket, trace: TraceInput[]) {
seed(
{
id: 'TK-4821',
tenantId: 'tnt_automia',
subject: 'Voicebot neodpovídá na volání po 18:00',
body:
'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' +
@@ -306,6 +309,7 @@ seed(
seed(
{
id: 'TK-4820',
tenantId: 'tnt_automia',
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
body:
'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' +
@@ -389,6 +393,7 @@ seed(
seed(
{
id: 'TK-4819',
tenantId: 'tnt_automia',
subject: 'Chybí denní report objednávek v e-mailu',
body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?',
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
@@ -482,6 +487,7 @@ seed(
seed(
{
id: 'TK-4817',
tenantId: 'tnt_logitrans',
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
body:
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
@@ -496,7 +502,7 @@ seed(
},
status: 'open',
priority: 'low',
assigneeId: 'ppl_vomacka',
assigneeId: 'ppl_kadlec',
automationId: null,
createdAt: minutesAgo(2_600),
updatedAt: minutesAgo(420),
@@ -524,13 +530,13 @@ seed(
},
{
kind: 'note',
label: 'Ticket ručně přiřazen na Karla Vomáčku.',
label: 'Ticket ručně přiřazen na Ondřeje Kadlece.',
status: 'info',
agoMinutes: 2_580,
},
{
kind: 'note',
label: 'Karel Vomáčka: Odhad odeslán, čeká se na objednávku.',
label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.',
status: 'info',
agoMinutes: 420,
},
@@ -540,6 +546,7 @@ seed(
seed(
{
id: 'TK-4812',
tenantId: 'tnt_nordis',
subject: 'Duplicitní zápis kontaktů z webového formuláře',
body:
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
@@ -554,7 +561,7 @@ seed(
},
status: 'resolved',
priority: 'critical',
assigneeId: 'ppl_uhlir',
assigneeId: 'ppl_bartos',
automationId: null,
createdAt: minutesAgo(5_100),
updatedAt: minutesAgo(1_500),
@@ -582,7 +589,7 @@ seed(
},
{
kind: 'note',
label: 'Jiří Uhlíř: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
status: 'info',
agoMinutes: 1_500,
},
@@ -607,14 +614,20 @@ function toTicket(stored: StoredTicket): Ticket {
// -------------------------------------------------------------------- dotazy
export interface TicketFilter {
/**
* Firmy, ze kterych se smi vracet. Povinne - kdyby to slo vynechat,
* driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data.
*/
tenantIds: string[];
/** ID resitele, nebo 'unassigned' pro nepridelene. */
assignee?: string;
status?: TicketStatus;
channel?: TicketChannel;
}
export function listTickets(filter: TicketFilter = {}): Ticket[] {
export function listTickets(filter: TicketFilter): Ticket[] {
const selected = tickets.filter((ticket) => {
if (!filter.tenantIds.includes(ticket.tenantId)) return false;
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
@@ -632,15 +645,22 @@ export function listTickets(filter: TicketFilter = {}): Ticket[] {
.map(toTicket);
}
export function getTicket(id: string): TicketDetail | undefined {
/** Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. */
export function getTicket(id: string, tenantIds: string[]): TicketDetail | undefined {
const stored = tickets.find((t) => t.id === id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
return undefined;
}
return { ...toTicket(stored), trace: traces.get(id) ?? [] };
}
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
export function firstOpenTicket(): Ticket | undefined {
const stored = tickets.find((t) => t.status !== 'resolved');
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
const stored = tickets.find(
(t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId),
);
return stored ? toTicket(stored) : undefined;
}
@@ -667,9 +687,11 @@ export interface Workload {
openTotal: number;
}
export function getWorkload(everyone: Person[]): Workload {
export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId));
const rows = everyone.map((person) => {
const mine = tickets.filter((t) => t.assigneeId === person.id);
const mine = visible.filter((t) => t.assigneeId === person.id);
const open = mine.filter((t) => t.status !== 'resolved');
const oldest = open.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
@@ -691,14 +713,15 @@ export function getWorkload(everyone: Person[]): Workload {
return {
rows,
unassigned: tickets.filter((t) => t.assigneeId === null && t.status !== 'resolved').length,
openTotal: tickets.filter((t) => t.status !== 'resolved').length,
unassigned: visible.filter((t) => t.assigneeId === null && t.status !== 'resolved').length,
openTotal: visible.filter((t) => t.status !== 'resolved').length,
};
}
// --------------------------------------------------------------------- zmeny
export interface CreateTicketInput {
tenantId: string;
subject: string;
body?: string;
sourceRef?: string | null;
@@ -724,6 +747,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
const stored: StoredTicket = {
id: `TK-${ticketCounter}`,
tenantId: input.tenantId,
subject: input.subject,
body: input.body ?? '',
sourceRef: input.sourceRef ?? null,
@@ -748,10 +772,25 @@ export function createTicket(input: CreateTicketInput): Ticket {
return toTicket(stored);
}
export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined {
/** Ticket z povolenych firem. Cizi se chova jako neexistujici. */
function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined {
const ticket = tickets.find((t) => t.id === id);
if (!ticket) return undefined;
if (!tenantIds.includes(ticket.tenantId)) {
console.warn(`[tickets] pokus o zapis do ticketu ${id} mimo povolene firmy`);
return undefined;
}
return ticket;
}
export function updateTicketStatus(
id: string,
status: TicketStatus,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] zmena stavu neexistujiciho ticketu: ${id}`);
console.warn(`[tickets] zmena stavu nedostupneho ticketu: ${id}`);
return undefined;
}
@@ -781,10 +820,14 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u
}
/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */
export function assignTicket(id: string, assigneeId: string | null): Ticket | undefined {
const ticket = tickets.find((t) => t.id === id);
export function assignTicket(
id: string,
assigneeId: string | null,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] prirazeni neexistujiciho ticketu: ${id}`);
console.warn(`[tickets] prirazeni nedostupneho ticketu: ${id}`);
return undefined;
}
@@ -793,6 +836,12 @@ export function assignTicket(id: string, assigneeId: string | null): Ticket | un
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`);
return undefined;
}
ticket.assigneeId = person?.id ?? null;
ticket.updatedAt = new Date().toISOString();
@@ -816,10 +865,15 @@ export function assignTicket(id: string, assigneeId: string | null): Ticket | un
}
/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */
export function addComment(id: string, author: string, text: string): Ticket | undefined {
const ticket = tickets.find((t) => t.id === id);
export function addComment(
id: string,
author: string,
text: string,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] komentar k neexistujicimu ticketu: ${id}`);
console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`);
return undefined;
}
+25 -9
View File
@@ -2,9 +2,13 @@ import bcrypt from 'bcryptjs';
import type { User } from '../types.js';
/**
* PROTOTYP: uzivatele jsou v pameti. Az prijde realny dashboard, tohle nahradi
* databaze (viz docs/04-backend-api.md, sekce "Kam dal").
* Hesla jsou hashovana pri startu, aby v kodu nebyl plaintext v uloziste-podobne strukture.
* PROTOTYP: uzivatele jsou v pameti. Az prijde databaze, tohle ji nahradi.
* Hesla se hashuji pri startu, aby v kodu nebyl plaintext.
*
* Demo ucty schvalne pokryvaji vsechny tri situace, ktere se lisi pravy:
* - platformni admin, ktery vidi napric firmami,
* - clovek ve dvou firmach, v kazde s jinou roli,
* - bezny resitel jedne firmy.
*/
const DEMO_PASSWORD = 'demo1234';
@@ -14,16 +18,28 @@ export const users: User[] = [
email: 'admin@automia.cz',
passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10),
name: 'Jiří Uhlíř',
role: 'admin',
company: 'Automia',
platformAdmin: true,
memberships: [{ tenantId: 'tnt_automia', role: 'admin' }],
},
{
id: 'usr_2',
email: 'klient@firma.cz',
email: 'karel.vomacka@automia.cz',
passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10),
name: 'Petra Klientová',
role: 'client',
company: 'Firma s.r.o.',
name: 'Karel Vomáčka',
platformAdmin: false,
// Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk.
memberships: [
{ tenantId: 'tnt_automia', role: 'agent' },
{ tenantId: 'tnt_nordis', role: 'admin' },
],
},
{
id: 'usr_3',
email: 'martin.kriz@automia.cz',
passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10),
name: 'Martin Kříž',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_automia', role: 'agent' }],
},
];
+15 -12
View File
@@ -37,16 +37,19 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) {
}
}
/** Omezi pristup na konkretni role. Pouzivat vzdy AZ za requireAuth. */
export function requireRole(...roles: User['role'][]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
console.error('[auth] requireRole pouzito bez requireAuth');
return res.status(401).json({ error: 'unauthorized' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' });
}
return next();
};
/**
* Jen pro spravce platformy, tedy pristup napric vsemi firmami.
* Prava uvnitr firmy resi `data/access.ts`, ne middleware - zavisi na tom,
* kterou firmu pozadavek zrovna zajima.
*/
export function requirePlatformAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
console.error('[auth] requirePlatformAdmin pouzito bez requireAuth');
return res.status(401).json({ error: 'unauthorized' });
}
if (!req.user.platformAdmin) {
console.warn(`[auth] ${req.user.email}: pokus o platformni akci`);
return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' });
}
return next();
}
+76 -4
View File
@@ -49,12 +49,53 @@ export function buildOpenApiDocument() {
},
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
role: { type: 'string', enum: ['admin', 'client'] },
company: { type: 'string', example: 'Automia' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
},
},
LoginRequest: {
@@ -419,6 +460,23 @@ export function buildOpenApiDocument() {
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
},
},
'/api/dashboard/access': {
get: {
tags: ['Dashboard'],
summary: 'Co uzivatel smi videt',
description:
'Povolene pohledy, firmy k prepinani a prava. Klient si to nesmi dovozovat sam.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Opravneni',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Access' } },
},
},
},
},
},
'/api/dashboard/people': {
get: {
tags: ['Tickety'],
@@ -452,13 +510,27 @@ export function buildOpenApiDocument() {
description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'scope',
in: 'query',
schema: { type: 'string', enum: ['all', 'tenant', 'mine'] },
description:
'`all` napric firmami (jen platformni admin), `tenant` cela firma, ' +
'`mine` jen moje tickety. Nepovoleny pohled vraci 403, nikdy se tise nezuzi.',
},
{
name: 'tenantId',
in: 'query',
schema: { type: 'string' },
description: 'Firma u pohledu `tenant` a `mine`. Bez clenstvi vraci 404.',
example: 'tnt_automia',
},
{
name: 'assignee',
in: 'query',
schema: { type: 'string' },
description:
'ID resitele, `me` pro prihlaseneho uzivatele, nebo `unassigned` pro frontu.',
example: 'me',
'ID resitele nebo `unassigned` pro frontu. U pohledu `mine` se ignoruje.',
},
{
name: 'status',
+4 -2
View File
@@ -41,12 +41,14 @@ authRouter.post('/login', async (req, res) => {
return invalid();
}
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
const payload: JwtPayload = { sub: user.id, email: user.email };
const token = jwt.sign(payload, config.jwtSecret, {
expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'],
});
console.info(`[auth] prihlasen: ${user.email} (${user.role})`);
console.info(
`[auth] prihlasen: ${user.email} (firem: ${user.memberships.length}, platforma: ${user.platformAdmin})`,
);
return res.json({ token, user: toPublicUser(user) });
});
+113 -31
View File
@@ -1,6 +1,12 @@
import { Router } from 'express';
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { publicBaseUrl } from '../config.js';
import {
accessFor,
isDenied,
resolveScope,
type ResolvedScope,
} from '../data/access.js';
import {
createAutomation,
deleteAutomation,
@@ -40,8 +46,32 @@ export const dashboardRouter = Router();
// Cely dashboard je jen pro prihlasene.
dashboardRouter.use(requireAuth);
dashboardRouter.get('/summary', (_req, res) => {
res.json(getSummary());
/**
* Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null,
* takze volajici jen zkontroluje `if (!scope) return;`.
*/
function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
const resolved = resolveScope(req.user!, {
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(resolved)) {
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
return null;
}
return resolved;
}
/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */
dashboardRouter.get('/access', (req, res) => {
res.json(accessFor(req.user!));
});
dashboardRouter.get('/summary', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json(getSummary(scope.tenantIds));
});
dashboardRouter.get('/incidents', (_req, res) => {
@@ -50,10 +80,13 @@ dashboardRouter.get('/incidents', (_req, res) => {
// ------------------------------------------------------------------- tickety
/** Resitele. Klient je potrebuje do nabidky prirazeni i do prehledu firmy. */
/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */
dashboardRouter.get('/people', (req, res) => {
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
res.json({ items: listPeople(), meId: me?.id ?? null });
const scope = scopeOrDeny(req, res);
if (!scope) return;
const me = findPersonByEmail(req.user!.email);
return res.json({ items: listPeople(scope.tenantIds), meId: me?.id ?? null });
});
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
@@ -71,16 +104,22 @@ const ticketChannels: TicketChannel[] = [
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
*/
function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | null): TicketFilter {
const filter: TicketFilter = {};
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
const filter: TicketFilter = { tenantIds: scope.tenantIds };
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
if (scope.scope === 'mine') {
filter.assignee = scope.personId ?? '__nikdo__';
return applyRest(query, filter);
}
const assignee = typeof query.assignee === 'string' ? query.assignee : undefined;
if (assignee === 'me') {
// Prihlaseny uzivatel nemusi byt resitel - pak nema smysl nic vracet.
filter.assignee = myPersonId ?? '__nikdo__';
} else if (assignee) {
filter.assignee = assignee;
}
if (assignee) filter.assignee = assignee;
return applyRest(query, filter);
}
function applyRest(query: Record<string, unknown>, filter: TicketFilter): TicketFilter {
const status = typeof query.status === 'string' ? query.status : undefined;
if (status) {
@@ -98,24 +137,46 @@ function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | n
}
dashboardRouter.get('/tickets', (req, res) => {
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
const filter = ticketFilterFrom(req.query as Record<string, unknown>, me?.id ?? null);
res.json({ items: listTickets(filter), meId: me?.id ?? null });
const scope = scopeOrDeny(req, res);
if (!scope) return;
const me = findPersonByEmail(req.user!.email);
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
return res.json({
items: listTickets(filter),
meId: me?.id ?? null,
scope: scope.scope,
tenantId: scope.tenantId,
});
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
dashboardRouter.get('/tickets/workload', (_req, res) => {
res.json(getWorkload(listPeople()));
dashboardRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds));
});
dashboardRouter.get('/tickets/:id', (req, res) => {
const ticket = getTicket(req.params.id);
const scope = scopeOrDeny(req, res);
if (!scope) return;
// Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou.
// Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404.
const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id);
const ticket = getTicket(req.params.id, reachable);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
/** Firmy, na ktere ma uzivatel dosah pri zapisu. */
function writableTenants(req: Request): string[] {
return accessFor(req.user!).tenants.map((tenant) => tenant.id);
}
const assignSchema = z.object({
/** null = vratit do fronty. */
assigneeId: z.string().min(1).nullable(),
@@ -130,11 +191,20 @@ dashboardRouter.post('/tickets/:id/assign', (req, res) => {
});
}
const ticket = assignTicket(req.params.id, parsed.data.assigneeId);
// Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe.
const access = accessFor(req.user!);
if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) {
return res.status(403).json({
error: 'forbidden',
message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.',
});
}
const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req));
if (!ticket) {
return res.status(404).json({
error: 'not_found',
message: 'Ticket nebo řešitel neexistuje.',
message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.',
});
}
return res.json(ticket);
@@ -150,7 +220,7 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => {
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
}
const ticket = updateTicketStatus(req.params.id, parsed.data.status);
const ticket = updateTicketStatus(req.params.id, parsed.data.status, writableTenants(req));
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
@@ -171,7 +241,7 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
}
const author = req.user?.name ?? 'Portál';
const ticket = addComment(req.params.id, author, parsed.data.text);
const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req));
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
@@ -351,12 +421,14 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
return problems;
}
dashboardRouter.get('/automations', (_req, res) => {
res.json({ items: listAutomations() });
dashboardRouter.get('/automations', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json({ items: listAutomations(scope.tenantIds) });
});
dashboardRouter.get('/automations/:id', (req, res) => {
const automation = getAutomation(req.params.id);
const automation = getAutomation(req.params.id, writableTenants(req));
if (!automation) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
@@ -372,7 +444,17 @@ dashboardRouter.post('/automations', (req, res) => {
});
}
const automation = createAutomation(parsed.data.name);
// Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci.
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!scope.tenantId) {
return res.status(400).json({
error: 'tenant_required',
message: 'Vyberte firmu, do které se má automatizace založit.',
});
}
const automation = createAutomation(parsed.data.name, scope.tenantId);
return res.status(201).json(automation);
});
@@ -400,7 +482,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
}
}
const updated = updateAutomation(req.params.id, { ...parsed.data, flow });
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
@@ -409,7 +491,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
const updated = regenerateWebhookToken(req.params.id);
const updated = regenerateWebhookToken(req.params.id, writableTenants(req));
if (!updated) {
return res.status(404).json({
error: 'not_found',
@@ -420,7 +502,7 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
});
dashboardRouter.delete('/automations/:id', (req, res) => {
if (!deleteAutomation(req.params.id)) {
if (!deleteAutomation(req.params.id, writableTenants(req))) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
return res.status(204).end();
+18 -5
View File
@@ -14,6 +14,7 @@ import {
type TicketPriority,
type TraceInput,
} from '../data/ticketStore.js';
import { accessFor } from '../data/access.js';
import { requireAuth } from '../middleware/auth.js';
export const simulateRouter = Router();
@@ -342,8 +343,19 @@ simulateRouter.post('/', (req, res) => {
});
}
// Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam.
const access = accessFor(req.user!);
const tenantId = access.defaultTenantId;
if (!tenantId) {
return res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.',
});
}
const tenantIds = access.tenants.map((tenant) => tenant.id);
const input = parsed.data;
console.info(`[simulace] ${input.action} spustil ${req.user?.email}`);
console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`);
switch (input.action) {
case 'ticket.created': {
@@ -356,6 +368,7 @@ simulateRouter.post('/', (req, res) => {
const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null;
const ticket = createTicket({
tenantId,
subject,
// Kanal posila cely text, predmet je jen jeho zkraceni.
body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`,
@@ -376,14 +389,14 @@ simulateRouter.post('/', (req, res) => {
}
case 'ticket.resolved': {
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket();
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds);
if (!target) {
return res.status(409).json({
error: 'nothing_to_resolve',
message: 'Není co vyřešit, všechny tickety jsou hotové.',
});
}
const ticket = updateTicketStatus(target.id, 'resolved');
const ticket = updateTicketStatus(target.id, 'resolved', tenantIds);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
@@ -416,7 +429,7 @@ simulateRouter.post('/', (req, res) => {
}
case 'automation.run': {
const automations = listAutomations().filter((a) => a.enabled);
const automations = listAutomations(tenantIds).filter((a) => a.enabled);
const targetId = input.automationId ?? automations[0]?.id;
if (!targetId) {
return res.status(409).json({
@@ -424,7 +437,7 @@ simulateRouter.post('/', (req, res) => {
message: 'Není co spustit, žádná automatizace není aktivní.',
});
}
const automation = recordRun(targetId, input.ok ?? true);
const automation = recordRun(targetId, input.ok ?? true, tenantIds);
if (!automation) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
+25 -6
View File
@@ -1,4 +1,18 @@
export type UserRole = 'admin' | 'client';
/**
* Uzivatel muze patrit do vic firem najednou (napr. externista, ktery dela
* servicedesk dvema klientum). Proto `memberships`, ne jedno `tenantId`.
*
* Role je vzdy az uvnitr firmy. Pristup napric firmami je zvlast, jako
* `platformAdmin` - to je nase pravo, ne zakaznicke.
*/
/** Role uvnitr jedne firmy. */
export type TenantRole = 'admin' | 'agent';
export interface Membership {
tenantId: string;
role: TenantRole;
}
export interface User {
id: string;
@@ -6,8 +20,9 @@ export interface User {
/** bcrypt hash - nikdy neposilat na klienta */
passwordHash: string;
name: string;
role: UserRole;
company: string;
/** Vidi napric vsemi firmami a muze mezi nimi prepinat. */
platformAdmin: boolean;
memberships: Membership[];
}
/** Verze uzivatele bezpecna pro odeslani na klienta. */
@@ -15,17 +30,21 @@ export interface PublicUser {
id: string;
email: string;
name: string;
role: UserRole;
company: string;
platformAdmin: boolean;
memberships: Membership[];
}
export interface JwtPayload {
sub: string;
email: string;
role: UserRole;
}
export function toPublicUser(user: User): PublicUser {
const { passwordHash: _passwordHash, ...rest } = user;
return rest;
}
/** Role uzivatele v dane firme, nebo undefined kdyz do ni nepatri. */
export function roleIn(user: User, tenantId: string): TenantRole | undefined {
return user.memberships.find((m) => m.tenantId === tenantId)?.role;
}
+11 -2
View File
@@ -2,12 +2,21 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import type { ReactNode } from 'react';
import { apiFetch, getToken, setToken } from '@/lib/api';
export type TenantRole = 'admin' | 'agent';
export interface Membership {
tenantId: string;
role: TenantRole;
}
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'admin' | 'client';
company: string;
/** Vidi napric vsemi firmami a muze mezi nimi prepinat. */
platformAdmin: boolean;
/** Uzivatel muze patrit do vic firem, v kazde s jinou roli. */
memberships: Membership[];
}
interface AuthState {
@@ -13,13 +13,15 @@ import {
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { useAuth, type AuthUser } from '@/auth/AuthContext';
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
import { EventToasts } from '@/components/dashboard/EventToasts';
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
import { SimulationModal } from '@/components/dashboard/SimulationModal';
import { Logo } from '@/components/layout/Logo';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import type { Access } from '@/types/dashboard';
const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
@@ -43,8 +45,18 @@ export function DashboardLayout() {
);
}
/** Kratky popis pristupu do topbaru. Uzivatel musi poznat, co vlastne vidi. */
function accessLabel(user: AuthUser | null, access: Access | null): string {
if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy';
const count = access?.tenants.length ?? 0;
if (count > 1) return `Přístup do ${count} firem`;
const role = user?.memberships[0]?.role;
return role === 'admin' ? 'Správce firmy' : 'Řešitel';
}
function DashboardShell() {
const { user, logout } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -157,11 +169,9 @@ function DashboardShell() {
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{user?.company ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
{access.data?.tenants[0]?.name ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
</div>
<LiveIndicator className="hidden sm:inline-flex" />
@@ -304,15 +304,18 @@ function ActionCard({
const inputs = resolved.operation.inputs;
return (
<div className="rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 @xs:size-10">
<Icon className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
<p className="truncate text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold break-words text-white">{resolved.operation.name}</p>
{/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */}
<p className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</p>
</div>
<StepControls
canMoveUp={canMoveUp}
@@ -386,10 +389,10 @@ function ConditionCard({
}
return (
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-4.5" />
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300 @xs:size-10">
<GitBranch className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -403,7 +406,7 @@ function ConditionCard({
podmínku odeberte.
</p>
) : (
<p className="mt-0.5 font-semibold text-white">
<p className="mt-0.5 font-semibold break-words text-white">
{describeCondition(field, step.operator, step.value)}
</p>
)}
@@ -472,12 +475,18 @@ function ConditionCard({
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{/*
Vetve vedle sebe jen tehdy, kdyz je na to v TETO karte misto.
Rozhoduje sirka karty, ne okna - kazde zanoreni ji pulí, takze
od urcite hloubky se vetve samy prepnou pod sebe misto toho,
aby se karty zmackly na par pixelu.
*/}
<div className="mt-4 grid gap-4 @2xl:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'rounded-xl border p-3',
'min-w-0 rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
@@ -523,14 +532,15 @@ function StepControls({
onMove: (offset: number) => void;
onRemove: () => void;
}) {
// V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu.
return (
<div className="flex shrink-0 items-center gap-0.5">
<div className="flex shrink-0 flex-col items-center gap-0.5 @xs:flex-row">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
@@ -540,7 +550,7 @@ function StepControls({
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
@@ -549,7 +559,7 @@ function StepControls({
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400 @xs:size-8"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
+35 -2
View File
@@ -1,11 +1,14 @@
import { Construction } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access } from '@/types/dashboard';
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
export default function Settings() {
usePageMeta({ title: 'Nastavení — portál Automia' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
return (
<div className="space-y-6">
@@ -19,11 +22,41 @@ export default function Settings() {
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<Row label="Jméno" value={user?.name ?? '—'} />
<Row label="E-mail" value={user?.email ?? '—'} />
<Row label="Organizace" value={user?.company ?? '—'} />
<Row label="Role" value={user?.role === 'admin' ? 'Interní správce' : 'Klient'} />
<Row
label="Přístup"
value={user?.platformAdmin ? 'Správce platformy' : 'Účet firmy'}
/>
<Row label="Firem" value={String(user?.memberships.length ?? 0)} />
</dl>
</section>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Členství ve firmách</h2>
<p className="mt-1 text-sm text-white/45">
Role je vždy uvnitř firmy. Ve dvou firmách můžete mít různá práva.
</p>
{access.data && access.data.tenants.length > 0 ? (
<ul className="mt-4 space-y-2">
{access.data.tenants.map((tenant) => {
const role = user?.memberships.find((m) => m.tenantId === tenant.id)?.role;
return (
<li
key={tenant.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3"
>
<span className="text-sm text-white/80">{tenant.name}</span>
<span className="text-xs text-white/45">
{role === 'admin' ? 'Správce firmy' : role === 'agent' ? 'Řešitel' : 'Přes platformu'}
</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-white/40">Účet nepatří do žádné firmy.</p>
)}
</section>
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
<Construction className="mx-auto size-6 text-white/30" />
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
+68 -20
View File
@@ -14,8 +14,10 @@ import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
Access,
TicketChannel,
TicketListResponse,
TicketScope,
TicketStatus,
Workload,
} from '@/types/dashboard';
@@ -30,6 +32,12 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
{ value: 'resolved', label: 'Vyřešené' },
];
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
];
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook' },
@@ -43,7 +51,12 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
export default function Tickets() {
usePageMeta({ title: 'Tickety — portál Automia' });
/** null = vsichni, 'me' = prihlaseny, 'unassigned' = fronta, jinak ID resitele. */
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
const [scope, setScope] = useState<TicketScope | null>(null);
const [tenantId, setTenantId] = useState<string | null>(null);
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(null);
const [status, setStatus] = useState<TicketStatus | null>(null);
const [channel, setChannel] = useState<TicketChannel | null>(null);
@@ -52,17 +65,26 @@ export default function Tickets() {
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
const params = new URLSearchParams();
if (scope) params.set('scope', scope);
if (tenantId) params.set('tenantId', tenantId);
if (assignee) params.set('assignee', assignee);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
const search = params.toString();
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
}, [assignee, status, channel]);
}, [scope, tenantId, assignee, status, channel]);
const workloadPath = useMemo(() => {
const params = new URLSearchParams();
// Prehled vytizeni je vzdy za firmu. U pohledu "vse" nema tym co znamenat.
if (scope === 'all') params.set('scope', 'all');
else if (tenantId) params.set('tenantId', tenantId);
const search = params.toString();
return `/api/dashboard/tickets/workload${search ? `?${search}` : ''}`;
}, [scope, tenantId]);
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
refetchOn: [...ticketEvents],
});
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
const items = useMemo(() => {
@@ -78,9 +100,15 @@ export default function Tickets() {
);
}, [tickets.data, query]);
const meId = tickets.data?.meId ?? null;
const data = tickets.data;
const meId = data?.meId ?? null;
const filtered = assignee !== null || status !== null || channel !== null;
const allowedScopes = access.data?.scopes ?? [];
const tenants = access.data?.tenants ?? [];
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
const activeScope = scope ?? data?.scope ?? null;
return (
<div className="space-y-6">
<header>
@@ -105,21 +133,41 @@ export default function Tickets() {
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{scopeOptions
.filter((option) => allowedScopes.includes(option.value))
.map((option) => (
<Chip
key={option.value}
active={activeScope === option.value}
onClick={() => setScope(option.value)}
title={option.hint}
>
{option.label}
</Chip>
))}
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
{tenants.length > 1 && activeScope !== 'all' && (
<select
value={tenantId ?? data?.tenantId ?? ''}
onChange={(event) => setTenantId(event.target.value)}
aria-label="Firma"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
{tenant.name}
</option>
))}
</select>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
Všechny
</Chip>
<Chip
active={assignee === 'me'}
onClick={() => setAssignee(assignee === 'me' ? null : 'me')}
disabled={meId === null}
title={
meId === null
? 'Přihlášený účet není v seznamu řešitelů, nemá tedy vlastní tickety.'
: undefined
}
>
Moje
Kdokoliv
</Chip>
<Chip
active={assignee === 'unassigned'}
+24
View File
@@ -17,9 +17,27 @@ export type TicketChannel =
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
export interface Tenant {
id: string;
name: string;
}
/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */
export type TicketScope = 'all' | 'tenant' | 'mine';
/** Co uzivatel smi. Klient si to nesmi dovozovat sam. */
export interface Access {
scopes: TicketScope[];
tenants: Tenant[];
defaultTenantId: string | null;
canAssignOthers: boolean;
personId: string | null;
}
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
export interface Person {
id: string;
tenantId: string;
name: string;
email: string;
role: string;
@@ -37,6 +55,8 @@ export interface TicketCustomer {
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. */
tenantId: string;
subject: string;
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
body: string;
@@ -80,6 +100,10 @@ export interface TicketDetail extends Ticket {
export interface TicketListResponse {
items: Ticket[];
meId: string | null;
/** Pohled, ktery server skutecne pouzil. */
scope: TicketScope;
/** Firma, na kterou je pohled zamereny. null u pohledu `all`. */
tenantId: string | null;
}
export interface PeopleResponse {