Hierarchie firmy: kdo co vidi, a zalozka Firma

Pohled na celou firmu dostal kazdy, kdo do ni patril: scopes.push('tenant')
se v access.ts neptalo na nic. mine byl dobrovolny filtr, ne strop, takze
resitel s roli agent poslal ?scope=tenant a dostal cely provoz.

Rozdil, na kterem to ted stoji: pohled je co chci videt, strop je co vubec smim
videt. Existoval jen pohled a klientovi se veril.

Model:

- Membership.seesAllTenant - vidi cely provoz firmy. Postaveni uctu ve firme,
  ne vlastnost resitele: clovek muze byt ve dvou firmach jednou reditel
  a jednou brigadnik
- PersonGroup.members je { personId, seesAll } misto holeho personIds. seesAll
  je vedouci sekce. Priznak visi na clenstvi, ne na cloveku a ne na skupine -
  diky tomu muze byt clovek v peti sekcich a jen ve dvou videt vsechno, coz
  role rict neumi, ta je jedna na celou firmu
- stara podoba personIds se dal cte, prevadi ji groupMembers

Vypocet stropu (visibilityFor), sjednoceni ne prunik: spravce platformy nebo
seesAllTenant vidi celou firmu, jinak svoje tickety plus vse ze sekci, kde ma
zaskrtnuto, plus jejich fronta. U zaznamu bez priznaku rozhoduje pravo
ticket.assign.others - kdo smel prehazovat cizi praci, uz stejne cely provoz
videl, takze se mu nic nebere.

Vynuceni:

- strop je povinna soucast TicketFilter, stejne jako tenantIds. Nepovinny filtr
  na prava je filtr, ktery jednou nekde chybi - takhle prekladac ukazal vsech
  trinact mist, ktera ho jeste nemela
- getWorkload a getAgentStats uz nesahaji do pole ticketu primo, jdou pres
  listTickets. Driv obchazely kazde omezeni viditelnosti
- getTicket kontroluje strop i u jednoho ticketu, bez toho by stacilo znat ID
- detail a prevzeti pocitaji strop za firmu ticketu, ne za prave prepnutou

Helpdesk se ridi toutez hierarchii, jen "moje" znamena, co jsem zalozil -
zadavatel pozadavek nikdy nema prirazeny. Ticket proto nese createdById.

Zalozka Firma: pod jednim mistem Prehled, Resitele, Sekce, Role a prava, Typy
ticketu a Pozvanky. V Nastaveni zustal Muj ucet a platformni veci. Role a Typy
jsou samostatne komponenty, sekce maji vlastni panel - u kazdeho clenstvi je
prepinac a radek na cloveka obecny EntityAdmin neumi.

Obsah ticketu se cte i v helpdesku, v seznamu jako jednoradkovy nahled.

Overeno na bezici instanci se tremi ucty nad tymiz daty: spravce platformy vidi
vsechny tri tickety vcetne neprirazeneho, Vomacka jako vedouci Servicedesku dva
(tickety obou clenu sekce), Kriz jako radovy clen jeden, jen svuj. Adresa cizho
ticketu vraci 404, vytizeni tymu ukazuje jen viditelne a po zaskrtnuti priznaku
se rozsah zmeni hned.

Co z toho plyne: neprirazeny ticket bez skupiny nevidi nikdo krome toho, kdo
vidi celou firmu. Prichozi praci musi nekdo smerovat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-02 12:15:47 +02:00
co-authored by Claude Opus 5
parent a6722f9379
commit 6bcd7f845c
24 changed files with 951 additions and 195 deletions
+73 -1
View File
@@ -15,7 +15,7 @@
* pocitalo na dvou mistech a jednou se rozejde.
*/
import { findPersonByEmail } from './people.js';
import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js';
import { hasPermission, permissionsOf } from './permissions.js';
import { listActiveTenants, findTenant, type Tenant } from './tenants.js';
import { navFor, type NavItem } from './tenantFeatures.js';
@@ -23,6 +23,68 @@ import type { User } from '../types.js';
export type TicketScope = 'all' | 'tenant' | 'mine';
/**
* Strop viditelnosti: co uzivatel **vubec smi** videt.
*
* Neplest s pohledem. Pohled (`TicketScope`) je to, co si clovek zvolil, strop
* je to, co mu vubec patri - a klient si ho nevolí. Driv existoval jen pohled,
* takze kazdy clen firmy si mohl vyzadat `scope=tenant` a dostal vsechno.
*/
export type Visibility =
| { kind: 'all' }
/** Jen tickety techhle lidi a techhle skupin, vcetne jejich fronty. */
| { kind: 'scoped'; personIds: string[]; groupIds: string[] };
/**
* Co uzivatel smi videt ve vybrane firme.
*
* Sjednoceni, ne prunik: svoje tickety plus vsechno ze sekci, kde ma
* u clenstvi zaskrtnuto "vidi vse".
*
* `seesAllTenant === undefined` jsou zaznamy ulozene driv, kdy priznak
* neexistoval. Tam se pta na pravo prehazovat cizi praci: kdo dosud smel
* prirazovat ostatnim, uz stejne cely provoz videl, takze se mu nic nebere.
* Bezny resitel timhle sitem neprojde a spadne na svoje sekce - coz je prave
* ta zmena, o kterou jde.
*/
export function visibilityFor(user: User, tenantId: string | null): Visibility {
if (user.platformAdmin) return { kind: 'all' };
if (!tenantId) return { kind: 'scoped', personIds: [], groupIds: [] };
const membership = user.memberships.find((item) => item.tenantId === tenantId);
if (membership?.seesAllTenant === true) return { kind: 'all' };
if (membership?.seesAllTenant === undefined && hasPermission(user, 'ticket.assign.others', tenantId)) {
return { kind: 'all' };
}
const person = findPersonByEmail(user.email);
if (!person) return { kind: 'scoped', personIds: [], groupIds: [] };
const groupIds = groupsOfPerson(person.id, [tenantId])
.filter((group) => groupMembers(group).some((m) => m.personId === person.id && m.seesAll))
.map((group) => group.id);
// Lide z vedenych sekci: jejich tickety patri vedoucimu do dohledu i tehdy,
// kdyz si je vzali na sebe a ze fronty skupiny tim zmizely.
const personIds = new Set([person.id]);
for (const group of listGroups([tenantId])) {
if (!groupIds.includes(group.id)) continue;
for (const member of groupMembers(group)) personIds.add(member.personId);
}
return { kind: 'scoped', personIds: [...personIds], groupIds };
}
/**
* Vidi ten clovek na celou firmu?
*
* Kratka odpoved na `visibilityFor(...).kind === 'all'`. Pouziva to helpdesk,
* kde strop nejde uplatnit pres resitele a skupiny - zadavatel neni ani jedno.
*/
export function seesWholeTenant(user: User, tenantId: string | null): boolean {
return visibilityFor(user, tenantId).kind === 'all';
}
export interface Access {
/** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */
scopes: TicketScope[];
@@ -112,6 +174,13 @@ export interface ResolvedScope {
tenantId: string | null;
/** Vyplnene jen u pohledu `mine`. */
personId: string | null;
/**
* Strop viditelnosti. **Pohled se o nej orizne**, nikdy naopak.
*
* Nese ho `ResolvedScope`, protoze tudy prochazi kazdy dotaz na tickety.
* Kdyby si ho pocital az kazdy endpoint sam, jednou nekde chybi.
*/
visibility: Visibility;
}
export interface ScopeDenied {
@@ -154,11 +223,13 @@ export function resolveScope(
}
if (scope === 'all') {
// Pohled pres vsechny firmy ma jen spravce platformy, strop je tedy 'all'.
return {
scope,
tenantIds: access.tenants.map((tenant) => tenant.id),
tenantId: null,
personId: null,
visibility: { kind: 'all' },
};
}
@@ -181,6 +252,7 @@ export function resolveScope(
tenantIds: [tenantId],
tenantId,
personId: scope === 'mine' ? access.personId : null,
visibility: visibilityFor(user, tenantId),
};
}
+2 -1
View File
@@ -25,7 +25,8 @@ export function getRunsSeries(days = 14) {
}
export function getSummary(tenantIds: string[]) {
const tickets = listTickets({ tenantIds });
// Ukazkova cisla nad vlastnimi daty, strop viditelnosti se sem nevztahuje.
const tickets = listTickets({ tenantIds, visibility: { kind: 'all' } });
const incidents = listIncidents();
const automations = listAutomations(tenantIds);
const series = getRunsSeries();
+48 -3
View File
@@ -121,17 +121,58 @@ export function allPeople(): Person[] {
* Clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich ma
* dovolenou. Fronta bez resitele se tim rozpadne na fronty skupin.
*/
/**
* Clenstvi ve skupine.
*
* Priznak visi na **clenstvi**, ne na cloveku a ne na skupine. Diky tomu muze
* byt clovek v peti skupinach a jen ve dvou z nich videt vsechno - to by role
* nikdy nedokazala rict, ta je jedna na celou firmu.
*/
export interface GroupMember {
personId: string;
/**
* true = vidi vsechny tickety skupiny, ne jen svoje.
*
* Tohle je "vedouci sekce". Neni to zvlastni pole na skupine schvalne:
* vedoucich muze byt vic a jeden clovek muze vest vic sekci.
*/
seesAll: boolean;
}
export interface PersonGroup extends TenantEntity {
tenantId: string;
name: string;
personIds: string[];
members: GroupMember[];
/**
* Stara podoba clenstvi, jen ID bez priznaku.
*
* Zaznamy ulozene driv ji maji misto `members`. Cte se pres `groupMembers`,
* zapisuje se uz vzdycky `members`.
*/
personIds?: string[];
}
/**
* Clenove skupiny, at uz je zaznam v jakekoliv podobe.
*
* Jedine misto, kde se stara podoba prevadi. Kdyby se cetlo `personIds` primo,
* skupina ulozena driv by po zmene modelu prisla o cleny.
*/
export function groupMembers(group: PersonGroup): GroupMember[] {
if (group.members) return group.members;
return (group.personIds ?? []).map((personId) => ({ personId, seesAll: false }));
}
/** Je ten clovek clenem skupiny? */
export function isMember(group: PersonGroup, personId: string): boolean {
return groupMembers(group).some((member) => member.personId === personId);
}
/** Skupiny, jejichz clenem ten clovek je. */
export function groupsOfPerson(personId: string, tenantIds: string[]): PersonGroup[] {
return groupCache
.all()
.filter((group) => tenantIds.includes(group.tenantId) && group.personIds.includes(personId));
.filter((group) => tenantIds.includes(group.tenantId) && isMember(group, personId));
}
export const groupStore = defineStore<PersonGroup>('personGroup');
@@ -144,7 +185,11 @@ export function seedGroups(): PersonGroup[] {
id: 'grp_servicedesk',
tenantId: 'tnt_automia',
name: 'Servicedesk',
personIds: ['ppl_vomacka', 'ppl_kriz'],
// Vomacka sekci vede, takze vidi vsechno v ni. Kriz jen svoje.
members: [
{ personId: 'ppl_vomacka', seesAll: true },
{ personId: 'ppl_kriz', seesAll: false },
],
createdAt: timestamp,
updatedAt: timestamp,
},
+1 -1
View File
@@ -63,7 +63,7 @@ export const moduleCatalog: ModuleDefinition[] = [
* stejne rozhoduje pravo `helpdesk.view`, ktere prideluje admin firmy.
*/
{ key: 'helpdesk', label: 'Helpdesk', permissions: ['helpdesk.view'], required: true },
{ key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true },
{ key: 'people', label: 'Firma', permissions: ['ticket.view'], required: true },
{ key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] },
/*
* Akce a widgety jsou **stejna uroven jako automatizace**, ne polozka
+88 -11
View File
@@ -19,6 +19,7 @@
import { config } from '../config.js';
import { publish } from '../events/bus.js';
import { currentRun } from '../runtime/context.js';
import type { Visibility } from './access.js';
import { onTicketChanged } from './ticketHooks.js';
import { notify } from './notifications.js';
import { findPerson, type Person } from './people.js';
@@ -196,6 +197,13 @@ export interface Ticket {
tags: string[];
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/**
* Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace.
*
* Je to **ucet, ne resitel**: v helpdesku podle nej zadavatel pozna svoje
* pozadavky, a ten je nikdy nema prirazene - resi je nekdo u dodavatele.
*/
createdById: string | null;
/**
* Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu.
* Bez toho nejde rict, jak dlouho zakaznik cekal na reakci.
@@ -245,6 +253,7 @@ interface StoredTicket
| 'resolvedById'
| 'reopenCount'
| 'helpdeskSourceId'
| 'createdById'
> {
assigneeId: string | null;
helpdeskSourceId?: string | null;
@@ -259,6 +268,7 @@ interface StoredTicket
resolvedAt?: string | null;
resolvedById?: string | null;
reopenCount?: number;
createdById?: string | null;
events?: TicketEvent[];
}
@@ -876,6 +886,7 @@ function toTicket(stored: StoredTicket): Ticket {
resolvedById: stored.resolvedById ?? null,
reopenCount: stored.reopenCount ?? 0,
helpdeskSourceId: stored.helpdeskSourceId ?? null,
createdById: stored.createdById ?? null,
};
if (!assigneeId) return { ...base, assignee: null };
@@ -897,6 +908,14 @@ export interface TicketFilter {
* driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data.
*/
tenantIds: string[];
/**
* Strop viditelnosti uvnitr firmy. **Povinny ze stejneho duvodu** jako
* `tenantIds`: nepovinny filtr na prava je filtr, ktery jednou nekde chybi.
*
* Poklada ho `resolveScope`, viz data/access.ts. Kdo ma videt vsechno,
* posila `{ kind: 'all' }` - vyslovne, ne vynechanim.
*/
visibility: Visibility;
/**
* Firmy, ze kterych pozadavek prisel pres helpdesk.
*
@@ -907,6 +926,11 @@ export interface TicketFilter {
helpdeskSourceIds?: string[];
/** ID resitele, nebo 'unassigned' pro nepridelene. */
assignee?: string;
/**
* Ucet, ktery ticket zalozil. Pouziva **jen helpdesk**: tam je "moje" to,
* co jsem poslal, ne to, co mam prirazene.
*/
createdById?: string;
status?: TicketStatus;
channel?: TicketChannel;
/** Typ ticketu. `none` = tickety bez typu. */
@@ -917,6 +941,20 @@ export interface TicketFilter {
groupId?: string;
}
/**
* Vejde se ticket do stropu?
*
* Sjednoceni, ne prunik: bud ho ma u sebe nekdo, na koho vidim, nebo lezi
* ve fronte sekce, kterou vedu. Fronta bez resitele patri do druhe podminky -
* bez ni by vedouci nemel co rozdelovat.
*/
function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean {
if (visibility.kind === 'all') return true;
if (ticket.assigneeId && visibility.personIds.includes(ticket.assigneeId)) return true;
if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true;
return false;
}
export function listTickets(filter: TicketFilter): Ticket[] {
const sources = filter.helpdeskSourceIds;
const selected = tickets.filter((ticket) => {
@@ -925,6 +963,13 @@ export function listTickets(filter: TicketFilter): Ticket[] {
if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false;
} else if (!filter.tenantIds.includes(ticket.tenantId)) {
return false;
} else if (!withinVisibility(ticket, filter.visibility)) {
/*
* Strop se pta jen u vlastnickeho pohledu. Pohled zadavatele nad nim
* neni: pozadavek posila firma dodavateli, zadavatel u nej neni resitel
* ani clen zadne skupiny, takze by mu strop vzal i to, co sam poslal.
*/
return false;
}
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
@@ -942,6 +987,7 @@ export function listTickets(filter: TicketFilter): Ticket[] {
) {
return false;
}
if (filter.createdById && ticket.createdById !== filter.createdById) return false;
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
return true;
@@ -969,6 +1015,7 @@ export function getTicket(
id: string,
tenantIds: string[],
helpdeskSourceIds: string[] = [],
visibility: Visibility = { kind: 'all' },
): TicketDetail | undefined {
const stored = tickets.find((t) => t.id === id);
if (!stored) return undefined;
@@ -983,6 +1030,18 @@ export function getTicket(
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
return undefined;
}
/*
* Strop plati i na jeden ticket, ne jen na seznam. Bez toho by staciloa
* znat ID: seznam by ho neukazal, ale adresa detailu by ho vydala.
*
* Pohledu zadavatele se to netyka, ten stoji na `helpdeskSourceIds` - viz
* `listTickets`.
*/
if (owns && !asked && !withinVisibility(stored, visibility)) {
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo strop viditelnosti`);
return undefined;
}
return {
...toTicket(stored),
trace: traces.get(id) ?? [],
@@ -990,6 +1049,18 @@ export function getTicket(
};
}
/**
* Vejde se ten ticket do stropu?
*
* Pouziva detail a prevzeti, kde se firma ticketu pozna az po nalezeni -
* odkaz z pohledu "vse" muze vest do jine firmy uzivatele a strop se pocita
* za firmu ticketu, ne za prave prepnutou.
*/
export function ticketWithinVisibility(id: string, visibility: Visibility): boolean {
const stored = tickets.find((t) => t.id === id);
return stored ? withinVisibility(stored, visibility) : false;
}
/** Prvni nevyrizeny ticket. */
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
const stored = tickets.find(
@@ -1021,11 +1092,17 @@ export interface Workload {
openTotal: number;
}
export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId));
export function getWorkload(
everyone: Person[],
tenantIds: string[],
visibility: Visibility,
): Workload {
// Pres listTickets, aby strop platil i tady. Driv se sahalo primo do pole
// a vytizeni tymu tak obchazelo kazde omezeni viditelnosti.
const visible = listTickets({ tenantIds, visibility });
const rows = everyone.map((person) => {
const mine = visible.filter((t) => t.assigneeId === person.id);
const mine = visible.filter((t) => t.assignee?.id === person.id);
const open = mine.filter((t) => !t.closed);
const oldest = open.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
@@ -1047,9 +1124,7 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
return {
rows,
unassigned: visible.filter(
(t) => t.assigneeId === null && !t.closed,
).length,
unassigned: visible.filter((t) => t.assignee === null && !t.closed).length,
openTotal: visible.filter((t) => !t.closed).length,
};
}
@@ -1453,13 +1528,12 @@ export function getAgentStats(
everyone: Person[],
tenantIds: string[],
since: number | null,
visibility: Visibility,
): AgentStatsRow[] {
const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId));
const visible = listTickets({ tenantIds, visibility });
const rows = everyone.map((person) => {
const open = visible.filter(
(t) => t.assigneeId === person.id && !t.closed,
);
const open = visible.filter((t) => t.assignee?.id === person.id && !t.closed);
/**
* Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv,
@@ -1483,7 +1557,7 @@ export function getAgentStats(
.filter((value) => value >= 0);
const responseTimes = visible
.filter((t) => t.assigneeId === person.id && at(t.firstResponseAt) !== null)
.filter((t) => t.assignee?.id === person.id && at(t.firstResponseAt) !== null)
.map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0))
.filter((value) => value >= 0);
@@ -1541,6 +1615,8 @@ export interface CreateTicketInput {
fields?: Record<string, string | number | boolean | null>;
tags?: string[];
automationId?: string | null;
/** Ucet, ktery ho zaklada rucne. null u automatizace a prijmu zvenku. */
createdById?: string | null;
/** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */
trace?: TraceInput[];
}
@@ -1583,6 +1659,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
tags: input.tags ?? [],
closed: input.closed ?? false,
automationId: input.automationId ?? null,
createdById: input.createdById ?? null,
createdAt: now,
updatedAt: now,
};
+46 -9
View File
@@ -5,6 +5,7 @@ import {
accessFor,
isDenied,
resolveScope,
visibilityFor,
type ResolvedScope,
} from '../data/access.js';
import {
@@ -43,7 +44,13 @@ import { collectScopes } from '../data/flowScope.js';
import { widgetCatalog } from '../data/widgets.js';
import { createIncident, listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, groupsOfPerson, listGroups, listPeople } from '../data/people.js';
import {
findPersonByEmail,
groupsOfPerson,
isMember,
listGroups,
listPeople,
} from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
import { listNotifications, markRead, unreadCount } from '../data/notifications.js';
import { inviteRouter } from './invites.js';
@@ -59,6 +66,7 @@ import {
ticketAssignee,
getAgentStats,
getTicket,
ticketWithinVisibility,
getWorkload,
listTickets,
updateTicketStatus,
@@ -355,20 +363,27 @@ dashboardRouter.get('/people/:id', (req, res) => {
return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' });
}
const tickets = listTickets({ tenantIds: scope.tenantIds, assignee: person.id });
const resolvedRecently = listTickets({ tenantIds: scope.tenantIds }).filter(
const tickets = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
assignee: person.id,
});
const resolvedRecently = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
}).filter(
(ticket) => ticket.resolvedById === person.id,
);
// Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech.
const since = Date.now() - 30 * 86_400_000;
const stats = getAgentStats([person], scope.tenantIds, since)[0] ?? null;
const stats = getAgentStats([person], scope.tenantIds, since, scope.visibility)[0] ?? null;
return res.json({
person,
stats,
groups: listGroups(scope.tenantIds)
.filter((group) => group.personIds.includes(person.id))
.filter((group) => isMember(group, person.id))
.map((group) => ({ id: group.id, name: group.name })),
/** Co ma prave ted u sebe. */
open: tickets.filter((ticket) => !ticket.closed),
@@ -502,8 +517,11 @@ function myOpenTickets(req: Request): number {
const access = accessFor(req.user!);
if (!access.personId) return 0;
// Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr
// na prava nesmi byt nepovinny.
return listTickets({
tenantIds: access.tenants.map((tenant) => tenant.id),
visibility: { kind: 'scoped', personIds: [access.personId], groupIds: [] },
assignee: access.personId,
}).filter((ticket) => !ticket.closed).length;
}
@@ -579,7 +597,7 @@ const ticketChannels: TicketChannel[] = [
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
*/
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
const filter: TicketFilter = { tenantIds: scope.tenantIds };
const filter: TicketFilter = { tenantIds: scope.tenantIds, visibility: scope.visibility };
// Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho".
const typeId = query.typeId;
@@ -637,7 +655,11 @@ dashboardRouter.get('/tickets', (req, res) => {
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout.
*/
const statuses = [
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
...new Set(
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map(
(ticket) => ticket.status,
),
),
].sort((a, b) => a.localeCompare(b, 'cs'));
return res.json({
@@ -784,6 +806,11 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => {
const ticket = getTicket(req.params.id, tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
// Prevzit jde jen to, na co clovek vidi.
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
if (current !== null && current !== access.personId) {
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
@@ -838,7 +865,11 @@ dashboardRouter.get('/tickets/statuses', (req, res) => {
if (!scope) return;
const items = [
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
...new Set(
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map(
(ticket) => ticket.status,
),
),
].sort((a, b) => a.localeCompare(b, 'cs'));
return res.json({ items });
@@ -847,7 +878,7 @@ dashboardRouter.get('/tickets/statuses', (req, res) => {
dashboardRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds));
return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility));
});
dashboardRouter.get('/tickets/:id', (req, res) => {
@@ -861,6 +892,12 @@ dashboardRouter.get('/tickets/:id', (req, res) => {
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
// Strop se pocita za firmu ticketu, ne za prave prepnutou. Bez teto kontroly
// by seznam ticket schoval, ale adresa detailu by ho vydala.
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
+14 -2
View File
@@ -25,7 +25,7 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { actingTenant } from '../data/access.js';
import { actingTenant, seesWholeTenant } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { findPersonByEmail } from '../data/people.js';
@@ -112,7 +112,17 @@ helpdeskRouter.get('/', (req, res) => {
const provider = tenant?.helpdeskProviderId ?? null;
return res.json({
items: listTickets({ tenantIds: [], helpdeskSourceIds: [tenantId] }),
/*
* Kdo z firmy vidi ktere pozadavky, se ridi toutez hierarchii jako
* u ticketu. Lisi se jen to, co je moje: zadavatel pozadavek nikdy nema
* prirazeny, resi ho nekdo u dodavatele - takze je to to, co sam zalozil.
*/
items: listTickets({
tenantIds: [],
helpdeskSourceIds: [tenantId],
visibility: { kind: 'all' },
...(seesWholeTenant(req.user!, tenantId) ? {} : { createdById: req.user!.id }),
}),
tenantId,
/** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */
provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null,
@@ -154,6 +164,8 @@ helpdeskRouter.post('/', (req, res) => {
// Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket.
tenantId: provider,
helpdeskSourceId: tenantId,
// Podle tohohle zadavatel pozna svoje pozadavky, viz seznam vyse.
createdById: req.user!.id,
subject: parsed.data.subject,
body: parsed.data.body,
channel: 'portal',
+15 -3
View File
@@ -325,9 +325,21 @@ settingsRouter.get('/people-overview', (req, res) => {
// ------------------------------------------------------------------- skupiny
/**
* Clen skupiny.
*
* `seesAll` je "vedouci sekce": vidi vsechny tickety skupiny, ne jen svoje.
* Visi to na **clenstvi**, ne na cloveku a ne na skupine, takze jeden clovek
* muze jednu sekci vest a v druhe byt radovy.
*/
const groupMember = z.object({
personId: z.string().min(1),
seesAll: z.boolean().default(false),
});
const groupCreate = z.object({
name: z.string().trim().min(2).max(60),
personIds: z.array(z.string()).default([]),
members: z.array(groupMember).default([]),
});
settingsRouter.use(
@@ -338,12 +350,12 @@ settingsRouter.use(
createSchema: groupCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
personIds: z.array(z.string()).optional(),
members: z.array(groupMember).optional(),
}),
writePermission: 'group.manage',
build: (input: z.infer<typeof groupCreate>) => ({
name: input.name,
personIds: input.personIds,
members: input.members,
}),
}),
);
+3 -3
View File
@@ -111,7 +111,7 @@ ticketActionsRouter.get('/:id/actions', async (req, res) => {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const actions = actionsForTicket(
@@ -143,7 +143,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const action = findAction(req.params.actionId);
@@ -362,7 +362,7 @@ function builtinAction<T>(options: {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds);
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
if (!hasPermission(req.user!, options.permission, ticket.tenantId)) {
+18 -6
View File
@@ -394,12 +394,17 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
if (source.kind === 'agentStats') {
return {
kind: 'agents',
rows: getAgentStats(listPeople(scope.tenantIds), scope.tenantIds, since(source.period)),
rows: getAgentStats(
listPeople(scope.tenantIds),
scope.tenantIds,
since(source.period),
scope.visibility,
),
};
}
if (source.kind === 'workload') {
const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds);
const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility);
return {
kind: 'workload',
rows: workload.rows.map((row) => ({
@@ -420,9 +425,10 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
throw new Error('Zdroj Konektor se počítá zvlášť.');
}
const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) =>
matches(ticket, source.filter, scope.personId),
);
const tickets = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
}).filter((ticket) => matches(ticket, source.filter, scope.personId));
if (source.kind === 'ticketCount') {
if (!source.groupBy) {
@@ -561,6 +567,12 @@ widgetDataRouter.get('/options', (req, res) => {
statuses: Object.entries(statusLabels).map(([value, label]) => ({ value, label })),
channels: Object.entries(channelLabels).map(([value, label]) => ({ value, label })),
// Tagy se berou z toho, co je na ticketech - vlastni seznam tagu neexistuje.
tags: [...new Set(listTickets({ tenantIds: scope.tenantIds }).flatMap((ticket) => ticket.tags))].sort(),
tags: [
...new Set(
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).flatMap(
(ticket) => ticket.tags,
),
),
].sort(),
});
});
+11 -3
View File
@@ -20,7 +20,13 @@ import { sendMail } from '../mail/smtp.js';
import { resolveTarget } from '../scripts/connections.js';
import { findTenantScript, noteRun } from '../data/tenantScripts.js';
import { runSandbox } from './sandbox.js';
import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js';
import {
findGroup,
findPerson,
findPersonByExternalId,
isMember,
listPeople,
} from '../data/people.js';
import {
assignTicket,
assignTicketGroup,
@@ -429,13 +435,15 @@ const handlers: Record<string, Handler> = {
}
const people = listPeople([context.tenantId]).filter(
(person) => group.personIds.includes(person.id) && person.enabled !== false,
(person) => isMember(group, person.id) && person.enabled !== false,
);
if (people.length === 0) {
return { ok: false, summary: `skupina ${group.name} nemá koho`, outputs: {} };
}
const workload = getWorkload(people, [context.tenantId]);
// Automat rozdeluje praci za firmu, ne za prihlaseneho cloveka - strop
// viditelnosti se sem nevztahuje.
const workload = getWorkload(people, [context.tenantId], { kind: 'all' });
const best = [...workload.rows].sort((a, b) => {
if (a.open !== b.open) return a.open - b.open;
// Pri shode rozhoduje podil ke kapacite: dva tickety u cloveka s kapacitou
+12
View File
@@ -19,6 +19,18 @@
export interface Membership {
tenantId: string;
roleIds: string[];
/**
* Vidi vsechny tickety firmy, ne jen svoje a svych sekci.
*
* Je to **postaveni uctu ve firme**, ne vlastnost resitele, proto to visi
* tady a ne na `Person`: clovek muze byt ve dvou firmach jednou reditel
* a jednou brigadnik.
*
* `undefined` u zaznamu ulozenych driv. Co pak plati, resi `visibilityFor`
* v data/access.ts - nemuze to byt tise `false`, jinak by pri nasazeni
* kazdemu spravci zmizela vetsina ticketu.
*/
seesAllTenant?: boolean;
}
export interface User {