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:
co-authored by
Claude Opus 5
parent
dd021b5f69
commit
2a3d75c85e
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user