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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 až 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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user