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
co-authored by Claude Opus 5
parent dd021b5f69
commit 2a3d75c85e
25 changed files with 997 additions and 163 deletions
+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;
}