Resitel je clenstvi uctu, firma z ARES, prepinani jazyku schovane

Resitel uz neni vlastni zaznam spojeny s uctem pres e-mail: je to
clenstvi uctu ve firme a jeho ID je ID uctu. Popisek, kapacita, externi
ID a zapnuti visi na clenstvi, takze clovek ve dvou firmach je v kazde
jinak a spravce firmy ho vypne jen u sebe. Odebrani z firmy odebere jen
clenstvi. Stara data se pri startu jednou prevedou (migratePeople.ts),
vcetne odkazu v ticketech, skupinach, automatizacich, akcich a widgetech.
Sprava lidi v zalozce Lide zaklada ucty, pozvanka uz nema volbu resitele.

Zalozeni firmy z registru ARES: hledani podle IC nebo nazvu, dotazeni
IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu
a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz.
Vychozi rozlozeni dashboardu bez resitele neobsahuje list.myTickets.

Prepinani jazyku je docasne schovane (MULTILANG_ENABLED), web je cesky.
Dokumentace aktualizovana.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 13:09:35 +02:00
co-authored by Claude Fable 5.1
parent 6eed909a0d
commit bc6508e1f3
41 changed files with 1415 additions and 328 deletions
+16 -14
View File
@@ -15,7 +15,7 @@
* pocitalo na dvou mistech a jednou se rozejde.
*/
import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js';
import { groupMembers, groupsOfPerson, listGroups, personIdFor } from './people.js';
import { hasPermission, permissionsOf, rolesFor } from './permissions.js';
import { listActiveTenants, findTenant, type Tenant } from './tenants.js';
import { navFor } from './tenantFeatures.js';
@@ -60,16 +60,17 @@ export function visibilityFor(user: User, tenantId: string | null): Visibility {
return { kind: 'all' };
}
const person = findPersonByEmail(user.email);
if (!person) return { kind: 'scoped', personIds: [], groupIds: [] };
// Resitel je clenstvi: bez nej clovek ve firme nema ani svoje tickety.
const personId = personIdFor(user, tenantId);
if (!personId) return { kind: 'scoped', personIds: [], groupIds: [] };
const groupIds = groupsOfPerson(person.id, [tenantId])
.filter((group) => groupMembers(group).some((m) => m.personId === person.id && m.seesAll))
const groupIds = groupsOfPerson(personId, [tenantId])
.filter((group) => groupMembers(group).some((m) => m.personId === personId && 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]);
const personIds = new Set([personId]);
for (const group of listGroups([tenantId])) {
if (!groupIds.includes(group.id)) continue;
for (const member of groupMembers(group)) personIds.add(member.personId);
@@ -96,8 +97,6 @@ export function seesWholeTenant(user: User, tenantId: string | null): boolean {
* konkretni firmu - modul si kupuje firma, ne clovek.
*/
export function accessFor(user: User, tenantId?: string | null): Access {
const person = findPersonByEmail(user.email);
const tenants: Tenant[] = user.platformAdmin
? listActiveTenants()
: user.memberships
@@ -108,14 +107,17 @@ export function accessFor(user: User, tenantId?: string | null): Access {
})
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
const defaultTenantId = tenants[0]?.id ?? null;
const activeTenant = tenantId ?? defaultTenantId;
// Resitel = clenstvi ve vybrane firme, ID je ID uctu. Spravce platformy
// bez clenstvi resitelem neni a "moje tickety" u nej nemaji co ukazat.
const personId = personIdFor(user, activeTenant);
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');
const defaultTenantId = tenants[0]?.id ?? null;
const activeTenant = tenantId ?? defaultTenantId;
if (personId) scopes.push('mine');
const visibility = visibilityFor(user, activeTenant);
const groups = activeTenant ? listGroups([activeTenant]) : [];
@@ -138,7 +140,7 @@ export function accessFor(user: User, tenantId?: string | null): Access {
defaultTenantId,
// Zustava kvuli klientovi, ale uz se pocita z prava, ne z role.
canAssignOthers: hasPermission(user, 'ticket.assign.others', activeTenant),
personId: person?.id ?? null,
personId,
// Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme
// a resitel v druhe, uvidi po prepnuti jen to, co smi tam.
permissions: [...permissionsOf(user, activeTenant)].sort(),
+29 -4
View File
@@ -434,6 +434,31 @@ export async function initAutomations(): Promise<void> {
idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT'));
}
/**
* Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet
* zmenenych automatizaci.
*
* Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku
* `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince,
* proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich -
* nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`,
* ne podretezec.
*/
export function remapPersonIds(map: Map<string, string>): number {
let changed = 0;
for (const automation of store.values()) {
const before = JSON.stringify(automation.flow);
let after = before;
for (const [oldId, newId] of map) {
after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId));
}
if (after === before) continue;
changed += 1;
save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow }));
}
return changed;
}
function nextId(): string {
idCounter += 1;
return `AUT-${String(idCounter).padStart(2, '0')}`;
@@ -617,7 +642,7 @@ function seedDemoAutomations(): void {
body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}',
company: '{{customer}}',
priority: 'high',
assigneeId: 'ppl_vomacka',
assigneeId: 'usr_2',
},
},
{ id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' },
@@ -880,7 +905,7 @@ function seedDemoAutomations(): void {
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' },
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' },
},
],
no: [
@@ -896,7 +921,7 @@ function seedDemoAutomations(): void {
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' },
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' },
},
],
no: [
@@ -905,7 +930,7 @@ function seedDemoAutomations(): void {
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' },
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' },
},
],
},
+19 -4
View File
@@ -18,7 +18,8 @@ import { tenantScriptStore } from './tenantScripts.js';
import { onTicket } from './ticketHooks.js';
import { onTicketEvent } from '../runtime/triggers.js';
import { initQueue } from '../runtime/queue.js';
import { groupStore, personStore, seedGroups, seedPeople } from './people.js';
import { groupStore, seedGroups } from './people.js';
import { migratePeople } from './migratePeople.js';
import {
refreshRoles,
roleStore,
@@ -50,7 +51,6 @@ const entities: Array<{
{ store: tenantStore as EntityStore<TenantEntity>, seed: seedTenants },
{ store: userStore as EntityStore<TenantEntity>, seed: seedUsers },
{ store: roleStore as EntityStore<TenantEntity>, seed: systemRoles },
{ store: personStore as EntityStore<TenantEntity>, seed: seedPeople },
{ store: groupStore as EntityStore<TenantEntity>, seed: seedGroups },
{ store: featuresStore as EntityStore<TenantEntity>, seed: seedFeatures },
{ store: ticketTypeStore as EntityStore<TenantEntity>, seed: seedTicketTypes },
@@ -157,8 +157,8 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis
}
/*
* Tickety az po resitelich a typech: `toTicket` dohledava resitele podle ID
* a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje.
* Tickety az po uctech a typech: `toTicket` dohledava resitele podle ID
* uctu a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje.
*/
for (const item of runtimeData) {
try {
@@ -172,6 +172,21 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis
}
console.info(`[data] nactena provozni data: ${runtimeData.map((i) => i.name).join(', ')}`);
/*
* Stare zaznamy resitelu (`ppl_...`) na ucty. Az po nacteni ticketu
* a automatizaci, protoze se prepisuji v jejich kopii v pameti, a pred
* registraci hooku, aby preznaceni nespoustelo automatizace. Neco se
* stane jen pri prvnim startu po zmene modelu, pak je uloziste prazdne.
*/
try {
await migratePeople();
} catch (err) {
console.error(
'[data] migrace resitelu na ucty selhala:',
err instanceof Error ? err.message : err,
);
}
/*
* Zmena ticketu zaradi navazane automatizace. Registruje se az tady, aby
* uloziste ticketu nemuselo vedet o runtime - jinak by vznikl kruh.
+2 -2
View File
@@ -71,8 +71,8 @@ export async function initLayouts(): Promise<void> {
/**
* Rozlozeni uzivatele, nebo vychozi.
*
* `hasPerson` musi byt stejne, jako dostava katalog (`widgetCatalog`): kdo
* neni ve firme veden jako resitel, nema v katalogu `list.myTickets`, a tak
* `hasPerson` (= je clenem firmy) musi byt stejne, jako dostava katalog
* (`widgetCatalog`): kdo ve firme clenstvi nema, nema v katalogu `list.myTickets`, a tak
* nesmi byt ani ve vychozim rozlozeni. Jinak novy ucet uvidel jako prvni vec
* na dashboardu hlasku, ze widget "uz v katalogu neni". Misto nej dostane
* nezarazene tickety pres celou sirku, aby radek nezustal poloprazdny.
+3 -7
View File
@@ -35,12 +35,10 @@ export interface Invite extends TenantEntity {
/** Jake role dostane v te firme. */
roleIds: string[];
/**
* Ma se z nej stat i resitel, tedy nekdo, komu jde prehodit ticket?
*
* Uzivatel a resitel nejsou totez: ucetni muze mit pristup do portalu, aniz
* by kdy resila ticket. Proto se to pta, misto aby se hadalo.
* Stary priznak "ma byt i resitel". Uz nic nerozhoduje: resitel je kazdy
* clen firmy (viz data/people.ts). Zustava jen kvuli ulozenym pozvankam.
*/
asPerson: boolean;
asPerson?: boolean;
expiresAt: string;
usedAt: string | null;
usedByUserId: string | null;
@@ -62,7 +60,6 @@ export interface CreateInviteInput {
email?: string;
note?: string;
roleIds: string[];
asPerson?: boolean;
createdByEmail: string;
}
@@ -75,7 +72,6 @@ export function newInvite(input: CreateInviteInput): Invite {
email: (input.email ?? '').trim().toLowerCase(),
note: input.note ?? '',
roleIds: input.roleIds,
asPerson: input.asPerson ?? true,
expiresAt: new Date(Date.now() + VALID_DAYS * 86_400_000).toISOString(),
usedAt: null,
usedByUserId: null,
+213
View File
@@ -0,0 +1,213 @@
/**
* Migrace resitelu na ucty.
*
* Resitel byval vlastni zaznam (`ppl_...`) spojeny s uctem pres e-mail.
* Dnes je resitel clenstvi uctu ve firme a jeho ID je ID uctu, viz
* data/people.ts. Uloziste, ktere vzniklo driv, ma ale stare zaznamy
* a tickety, skupiny i automatizace na ne odkazuji.
*
* Co se stane pri prvnim startu po zmene:
* 1. ke kazdemu staremu resiteli se najde ucet podle e-mailu; kdyz neni,
* zalozi se s nahodnym heslem (clovek si ho nastavi pres pozvanku
* nebo zmenu hesla),
* 2. ucet dostane clenstvi ve firme resitele, pokud ho jeste nema,
* a na clenstvi se prenese popisek, kapacita a externi ID,
* 3. vsude, kde je stare ID, se prepise na ID uctu: tickety (resitel,
* kdo vyresil), skupiny, stromy automatizaci, tela akci, vlastni widgety,
* 4. stare zaznamy se smazou.
*
* Bezi jen kdyz stare zaznamy existuji, takze druhy start uz nic nedela.
* Chyba se loguje a start pokracuje: kontejner, ktery nenastartuje, je
* rozbita sluzba, kdezto par nepreznacenych ticketu je jen varovani v logu.
*/
import { randomBytes, randomUUID } from 'node:crypto';
import { remapPersonIds as remapAutomations } from './automationStore.js';
import { customWidgetStore, refreshCustomWidgets } from './customWidgets.js';
import { groupStore, refreshGroups, type PersonGroup } from './people.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { findTenant } from './tenants.js';
import { actionStore, refreshActions } from './ticketActions.js';
import { remapPersonIds as remapTickets } from './ticketStore.js';
import {
findStoredUser,
findStoredUserByEmail,
hashPassword,
refreshUsers,
userStore,
type StoredUser,
} from './users.js';
import type { Membership } from '../types.js';
/** Stary tvar resitele. Jen tady, nikde jinde uz se nepouziva. */
interface LegacyPerson extends TenantEntity {
tenantId: string;
name: string;
email: string;
role?: string;
capacity?: number;
enabled?: boolean;
externalIds?: string[];
}
const legacyStore = defineStore<LegacyPerson>('person');
/** Volby zapisu bez omezeni na firmu: ucty firmu nemaji, skupiny se berou z map. */
const anyTenant = { tenantIds: [] as string[], includeGlobal: true };
/**
* Najde nebo zalozi ucet pro stareho resitele a zajisti clenstvi ve firme.
* Vraci ID uctu a jestli ucet prave vznikl.
*/
async function accountFor(person: LegacyPerson): Promise<{ id: string; created: boolean }> {
const email = person.email.trim().toLowerCase();
const membershipFields = {
role: person.role ?? '',
capacity: person.capacity ?? 8,
externalIds: person.externalIds ?? [],
};
if (!findTenant(person.tenantId)) {
console.warn(`[migrace] resitel ${person.id} patri do nezname firmy ${person.tenantId}`);
}
const existing = email === '' ? undefined : findStoredUserByEmail(email);
if (!existing) {
const timestamp = nowIso();
const created = await userStore.create({
id: `usr_${randomUUID().slice(0, 8)}`,
tenantId: null,
// Bez e-mailu se nejde prihlasit, ale ucet musi vzniknout, jinak by
// tickety prisly o resitele. Spravce adresu doplni v Nastaveni.
email: email === '' ? `${person.id}@placeholder.cz` : email,
name: person.name,
passwordHash: await hashPassword(randomBytes(18).toString('base64url')),
platformAdmin: false,
enabled: person.enabled !== false,
memberships: [{ tenantId: person.tenantId, roleIds: ['role_agent'], ...membershipFields }],
createdAt: timestamp,
updatedAt: timestamp,
});
// Do kopie v pameti, aby dalsi resitel se stejnym e-mailem nasel tenhle ucet.
await refreshUsers();
return { id: created.id, created: true };
}
const current = findStoredUser(existing.id) ?? existing;
const membership = current.memberships.find((item) => item.tenantId === person.tenantId);
const memberships: Membership[] = membership
? current.memberships.map((item) =>
item.tenantId === person.tenantId ? { ...item, ...membershipFields } : item,
)
: [...current.memberships, { tenantId: person.tenantId, roleIds: ['role_agent'], ...membershipFields }];
await userStore.update(current.id, { memberships } as Partial<StoredUser>, anyTenant);
await refreshUsers();
return { id: current.id, created: false };
}
/** Prepise ID v clenech skupiny. Vraci true, kdyz se neco zmenilo. */
async function remapGroup(group: PersonGroup, map: Map<string, string>): Promise<boolean> {
const patch: Partial<PersonGroup> = {};
let changed = false;
if (group.members) {
const members = group.members.map((member) =>
map.has(member.personId) ? { ...member, personId: map.get(member.personId)! } : member,
);
if (members.some((member, index) => member !== group.members[index])) {
patch.members = members;
changed = true;
}
}
if (group.personIds) {
const personIds = group.personIds.map((id) => map.get(id) ?? id);
if (personIds.some((id, index) => id !== group.personIds![index])) {
patch.personIds = personIds;
changed = true;
}
}
if (!changed) return false;
await groupStore.update(group.id, patch, { tenantIds: [group.tenantId], includeGlobal: true });
return true;
}
/** Nahradi cela ID v JSON podobe hodnoty. Vraci novou hodnotu, nebo null beze zmeny. */
function remapJson<T>(value: T, map: Map<string, string>): T | null {
const before = JSON.stringify(value);
let after = before;
for (const [oldId, newId] of map) {
after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId));
}
return after === before ? null : (JSON.parse(after) as T);
}
export async function migratePeople(): Promise<void> {
await legacyStore.init();
const legacy = await legacyStore.listAll();
if (legacy.length === 0) return;
console.info(`[migrace] ${legacy.length} starych zaznamu resitelu, prevadim na ucty`);
// Stare ID -> ID uctu. Jeden ucet muze pokryt vic starych resitelu
// (tentyz e-mail ve dvou firmach), mapa je proto z ID, ne z e-mailu.
const map = new Map<string, string>();
let created = 0;
for (const person of legacy) {
try {
const account = await accountFor(person);
map.set(person.id, account.id);
if (account.created) created += 1;
} catch (err) {
console.error(
`[migrace] resitel ${person.id} (${person.email}) se nepodaril prevest:`,
err instanceof Error ? err.message : err,
);
}
}
// Tickety a automatizace se prepisuji v kopii v pameti a ukladaji pres
// svoje zrcadlo. Skupiny, akce a widgety jdou pres uloziste a obnovu kopie.
const tickets = remapTickets(map);
const automations = remapAutomations(map);
let groups = 0;
for (const group of await groupStore.listAll()) {
if (await remapGroup(group, map)) groups += 1;
}
if (groups > 0) await refreshGroups();
let actions = 0;
for (const action of await actionStore.listAll()) {
const body = remapJson(action.body, map);
if (!body) continue;
await actionStore.update(action.id, { body }, { tenantIds: [action.tenantId], includeGlobal: true });
actions += 1;
}
if (actions > 0) await refreshActions();
let widgets = 0;
for (const widget of await customWidgetStore.listAll()) {
const source = remapJson(widget.source, map);
if (!source) continue;
await customWidgetStore.update(
widget.id,
{ source },
{ tenantIds: [widget.tenantId], includeGlobal: true },
);
widgets += 1;
}
if (widgets > 0) await refreshCustomWidgets();
// Stare zaznamy pryc, jen ty prevedene. Neprevedeny zustane a migrace se
// k nemu pri dalsim startu vrati.
const removed = await legacyStore.removeMany([...map.keys()]);
await legacyStore.flush();
console.info(
`[migrace] resitele -> ucty: ${map.size} prevedeno (${created} novych uctu), ` +
`tickety ${tickets}, automatizace ${automations}, skupiny ${groups}, ` +
`akce ${actions}, widgety ${widgets}, smazano ${removed} starych zaznamu`,
);
}
+9 -12
View File
@@ -8,22 +8,22 @@
* a hlaska v portalu. Kdo ma portal zavreny, uvidi to pri prihlaseni, protoze
* upozorneni jsou ulozena, ne jen poslana.
*
* Spojka mezi resitelem a uctem je **e-mail**: resitel nemusi mit ucet
* (viz data/people.ts), a kdyz ho nema, upozorneni se zahodi.
* Resitel je clenstvi uctu (viz data/people.ts), takze ID resitele je rovnou
* ID uctu, kteremu upozorneni patri. Vypnuty ucet nema komu co ukazat,
* upozorneni se zahodi.
*/
import { randomUUID } from 'node:crypto';
import { publish } from '../events/bus.js';
import { findPerson } from './people.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import { findUserByEmail } from './users.js';
import { findUserById } from './users.js';
export type NotificationKind = 'ticket.assigned' | 'ticket.mentioned' | 'automation.failed';
export interface Notification extends TenantEntity {
tenantId: string;
/** Komu to patri. ID uzivatelskeho uctu, ne resitele. */
/** Komu to patri. ID uctu (= ID resitele). */
userId: string;
kind: NotificationKind;
title: string;
@@ -45,8 +45,8 @@ export async function refreshNotifications(): Promise<void> {
export interface NotifyInput {
tenantId: string;
/** Resitel, kteremu to patri. Ucet se dohleda pres e-mail. */
personId: string;
/** Komu to patri. ID uctu, tedy totez co ID resitele. */
userId: string;
kind: NotificationKind;
title: string;
href?: string | null;
@@ -60,12 +60,9 @@ export interface NotifyInput {
* prirazeni ticketu - stejna dohoda jako u auditu.
*/
export function notify(input: NotifyInput): void {
const person = findPerson(input.personId);
if (!person) return;
const user = findUserByEmail(person.email);
const user = findUserById(input.userId);
if (!user) {
// Resitel bez uctu je bezna vec. Neni komu to ukazat, ale neni to chyba.
// Vypnuty nebo smazany ucet: neni komu to ukazat, ale neni to chyba.
return;
}
+95 -48
View File
@@ -1,89 +1,134 @@
/**
* Resitele ticketu - lide, kteri maji pozadavek "u sebe".
*
* Zamerne oddelene od `users.ts`. Uzivatel je ten, kdo se prihlasi do portalu,
* resitel je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy:
* technik muze mit tickety a do portalu se nikdy neprihlasit.
* Spojka mezi obojim je e-mail.
* **Resitel je clenstvi uctu ve firme, ne vlastni zaznam.** Kazdy clen firmy
* muze mit tickety u sebe, takze ID resitele je ID uctu. Driv byl resitel
* zvlastni zaznam spojeny s uctem pres e-mail; e-mail je ale prihlasovaci
* jmeno, ktere spravce muze zmenit, a tim se vazba tise rozpadla - clovek
* najednou nevidel "moje tickety" a nikdo nevedel proc.
*
* Resitel se hleda pri kazdem requestu (filtr "moje tickety"), proto se drzi
* kopie v pameti. Vzor je v `store/cached.ts`.
* `Person` zustava jako pohled pro API: jmeno a e-mail z uctu, popisek,
* kapacita, externi ID a role z clenstvi. Tentyz clovek ve dvou firmach je
* dvakrat, pokazde se stejnym `id` a jinym `tenantId`. Nic se tu neuklada,
* vsechno se odvozuje z kopie uctu v pameti (`users.ts`).
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import type { Person } from '../shared/people.js';
import type { Membership } from '../types.js';
import { allStoredUsers, findStoredUser, type StoredUser } from './users.js';
/** Tvar resitele je sdileny s webem, viz src/shared/people.ts. */
export type { Person };
export const personStore = defineStore<Person>('person');
const cache = withCache(personStore);
/** Kolik nevyrizenych ticketu je zdrava zatez, kdyz clenstvi nerekne jinak. */
export const DEFAULT_CAPACITY = 8;
export function seedPeople(): Person[] {
const timestamp = nowIso();
const base = { enabled: true, externalIds: [], createdAt: timestamp, updatedAt: timestamp };
return [
{ ...base, id: 'ppl_vomacka', tenantId: 'tnt_automia', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Servicedesk', capacity: 8 },
{ ...base, id: 'ppl_uhlir', tenantId: 'tnt_automia', name: 'Jiří Uhlíř', email: 'admin@automia.cz', role: 'Vedoucí týmu', capacity: 5 },
{ ...base, id: 'ppl_kriz', tenantId: 'tnt_automia', name: 'Martin Kříž', email: 'martin.kriz@automia.cz', role: 'Integrace a API', capacity: 6 },
{ ...base, 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.
{ ...base, id: 'ppl_bartos', tenantId: 'tnt_nordis', name: 'Lukáš Bartoš', email: 'lukas.bartos@nordis.cz', role: 'Podpora', capacity: 7 },
{ ...base, id: 'ppl_horakova', tenantId: 'tnt_nordis', name: 'Simona Horáková', email: 'simona.horakova@nordis.cz', role: 'Fakturace', capacity: 5 },
{ ...base, id: 'ppl_kadlec', tenantId: 'tnt_logitrans', name: 'Ondřej Kadlec', email: 'ondrej.kadlec@logitrans.cz', role: 'Dispečink', capacity: 6 },
];
/** Pohled resitele z uctu a jednoho jeho clenstvi. Jedine misto, kde vznika. */
export function personView(user: StoredUser, membership: Membership): Person {
return {
id: user.id,
tenantId: membership.tenantId,
name: user.name,
email: user.email,
role: membership.role ?? '',
capacity: membership.capacity ?? DEFAULT_CAPACITY,
// Vypnuty ucet je vypnuty vsude, vypnute clenstvi jen tady.
enabled: user.enabled && membership.enabled !== false,
externalIds: membership.externalIds ?? [],
roleIds: membership.roleIds,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
export async function refreshPeople(): Promise<void> {
await cache.refresh();
function membershipIn(user: { memberships: Membership[] }, tenantId: string): Membership | undefined {
return user.memberships.find((membership) => membership.tenantId === tenantId);
}
/**
* ID resitele, kterym uzivatel ve firme je. `null` = neni jejim clenem.
*
* Je to ID uctu, ale volajici se ma ptat tudy a ne brat `user.id` primo:
* spravce platformy bez clenstvi ve firme resitelem neni a "moje tickety"
* u nej nemaji co ukazat.
*/
export function personIdFor(
user: { id: string; memberships: Membership[] },
tenantId: string | null,
): string | null {
if (!tenantId) return null;
return membershipIn(user, tenantId) ? user.id : null;
}
function viewsIn(users: StoredUser[], tenantIds: string[]): Person[] {
const views: Person[] = [];
for (const user of users) {
for (const membership of user.memberships) {
if (tenantIds.includes(membership.tenantId)) views.push(personView(user, membership));
}
}
return views.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 cache.listByTenant(tenantIds, 'name').filter((person) => person.enabled);
// Aktivni = aktivni ucet i aktivni clenstvi v te firme, viz personView.
return viewsIn(allStoredUsers(), tenantIds).filter((person) => person.enabled);
}
/** Vcetne vypnutych. Pro spravu tymu. */
export function listAllPeople(tenantIds: string[]): Person[] {
return cache.listByTenant(tenantIds, 'name');
return viewsIn(allStoredUsers(), tenantIds);
}
/**
* Resitel podle ID z cizi aplikace.
*
* Hleda se **jen ve vybranych firmach**: dve firmy mohou mit voicebota se
* stejnym ID a ticket nesmi skoncit u cizi firmy.
* stejnym ID a ticket nesmi skoncit u cizi firmy. Externi ID visi na
* clenstvi, takze tentyz clovek muze mit v kazde firme jine.
*/
export function findPersonByExternalId(value: string, tenantIds: string[]): Person | undefined {
const needle = value.trim();
if (needle === '') return undefined;
return cache
.all()
.find(
(person) =>
tenantIds.includes(person.tenantId) &&
person.enabled !== false &&
(person.externalIds ?? []).some((id) => id.trim() === needle),
for (const user of allStoredUsers()) {
if (!user.enabled) continue;
const membership = user.memberships.find(
(item) =>
tenantIds.includes(item.tenantId) &&
item.enabled !== false &&
(item.externalIds ?? []).some((id) => id.trim() === needle),
);
if (membership) return personView(user, membership);
}
return undefined;
}
export function findPerson(id: string): Person | undefined {
return cache.byId(id);
/**
* Resitel v dane firme, vcetne vypnuteho uctu.
*
* Firma je povinna: kdo v ni neni clenem, v ni resitelem neni, i kdyz ucet
* existuje. Diky tomu je kontrola "resitel z jine firmy" jen `undefined`.
*/
export function findPerson(id: string, tenantId: string): Person | undefined {
const user = findStoredUser(id);
if (!user) return undefined;
const membership = membershipIn(user, tenantId);
return membership ? personView(user, membership) : undefined;
}
/** Spojka na prihlaseneho uzivatele - podle ni funguje filtr "moje tickety". */
export function findPersonByEmail(email: string): Person | undefined {
const normalized = email.trim().toLowerCase();
return cache.find((person) => person.email.toLowerCase() === normalized && person.enabled);
}
/** Vsichni resitele bez ohledu na firmu. Jen pro nabidky v katalogu. */
export function allPeople(): Person[] {
return cache.all();
/**
* Jmeno cloveka bez ohledu na firmu.
*
* Jmeno je vlastnost uctu, ne clenstvi, takze tu firma neni potreba. Pro
* popisky (napr. sloupec "podle resitele" ve widgetu), kde uz ticket sam
* prosel filtrem na firmu.
*/
export function personName(id: string): string | undefined {
return findStoredUser(id)?.name;
}
// ------------------------------------------------------------------- skupiny
@@ -103,6 +148,7 @@ export function allPeople(): Person[] {
* nikdy nedokazala rict, ta je jedna na celou firmu.
*/
export interface GroupMember {
/** ID resitele, tedy ID uctu. Nazev pole zustava kvuli ulozenym zaznamum. */
personId: string;
/**
* true = vidi vsechny tickety skupiny, ne jen svoje.
@@ -160,9 +206,10 @@ export function seedGroups(): PersonGroup[] {
tenantId: 'tnt_automia',
name: 'Servicedesk',
// Vomacka sekci vede, takze vidi vsechno v ni. Kriz jen svoje.
// ID clenu jsou ID uctu, viz hlavicka souboru.
members: [
{ personId: 'ppl_vomacka', seesAll: true },
{ personId: 'ppl_kriz', seesAll: false },
{ personId: 'usr_2', seesAll: true },
{ personId: 'usr_3', seesAll: false },
],
createdAt: timestamp,
updatedAt: timestamp,
+3 -2
View File
@@ -23,7 +23,8 @@ const routeKinds: Record<string, string> = {
tenants: 'tenant',
users: 'user',
roles: 'role',
people: 'person',
// Resitel je clenstvi uctu, zapis do lidi meni ucty.
people: 'user',
groups: 'personGroup',
features: 'tenantFeatures',
'ticket-types': 'ticketType',
@@ -38,7 +39,7 @@ let queuedFull = false;
/**
* Obnovi kopie v pameti po zapisu.
*
* `route` je cesta v routeru nastaveni, napr. `/people/ppl_1`. Podle ni se
* `route` je cesta v routeru nastaveni, napr. `/people/usr_1`. Podle ni se
* pozna, co se zmenilo. Bez ni se obnovi vsechno.
*/
export function bootstrapDataRefresh(route?: string): Promise<void> {
+44 -20
View File
@@ -242,6 +242,33 @@ export async function initTickets(): Promise<void> {
traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter);
}
/**
* Prepise ID resitelu podle mapy stare -> nove. Vraci pocet zmenenych ticketu.
*
* Jen pro migraci (data/migratePeople.ts): resitel byval vlastni zaznam
* `ppl_...`, dnes je to ID uctu. Meni se jen odkazy, `updatedAt` ani hooky
* se nespousteji - ticket se vecne nezmenil, jen se preznacil.
*/
export function remapPersonIds(map: Map<string, string>): number {
let changed = 0;
for (const ticket of tickets) {
let touched = false;
if (ticket.assigneeId && map.has(ticket.assigneeId)) {
ticket.assigneeId = map.get(ticket.assigneeId)!;
touched = true;
}
if (ticket.resolvedById && map.has(ticket.resolvedById)) {
ticket.resolvedById = map.get(ticket.resolvedById)!;
touched = true;
}
if (touched) {
changed += 1;
persist(ticket);
}
}
return changed;
}
let ticketCounter = 4_821;
let traceCounter = 0;
@@ -351,7 +378,7 @@ function seedDemoTickets(): void {
},
status: 'V řešení',
priority: 'high',
assigneeId: 'ppl_novakova',
assigneeId: 'usr_novakova',
automationId: 'AUT-02',
createdAt: minutesAgo(310),
updatedAt: minutesAgo(42),
@@ -465,7 +492,7 @@ function seedDemoTickets(): void {
},
status: 'Čeká na klienta',
priority: 'normal',
assigneeId: 'ppl_kriz',
assigneeId: 'usr_3',
automationId: 'AUT-03',
createdAt: minutesAgo(1_180),
updatedAt: minutesAgo(190),
@@ -642,7 +669,7 @@ function seedDemoTickets(): void {
},
status: 'V řešení',
priority: 'low',
assigneeId: 'ppl_kadlec',
assigneeId: 'usr_kadlec',
automationId: null,
createdAt: minutesAgo(2_600),
updatedAt: minutesAgo(420),
@@ -702,7 +729,7 @@ function seedDemoTickets(): void {
status: 'Vyřešeno',
closed: true,
priority: 'critical',
assigneeId: 'ppl_bartos',
assigneeId: 'usr_bartos',
automationId: null,
createdAt: minutesAgo(5_100),
updatedAt: minutesAgo(1_500),
@@ -764,10 +791,10 @@ function toTicket(stored: StoredTicket): Ticket {
if (!assigneeId) return { ...base, assignee: null };
const person = findPerson(assigneeId);
const person = findPerson(assigneeId, stored.tenantId);
if (!person) {
// Resitel zmizel ze seznamu - ticket nesmi spadnout, ale chceme o tom vedet.
console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz neexistuje`);
// Resitel uz neni clenem firmy - ticket nesmi spadnout, ale chceme o tom vedet.
console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz ve firme neni`);
return { ...base, assignee: null };
}
return { ...base, assignee: { id: person.id, name: person.name } };
@@ -1288,7 +1315,7 @@ function applyValues(ticket: StoredTicket, apply: TicketApply | undefined): void
// Stejne jako pri zalozeni: mrtvy odkaz na resitele radeji nez ulozit.
if (apply.assigneeId) {
if (findPerson(apply.assigneeId)) ticket.assigneeId = apply.assigneeId;
if (findPerson(apply.assigneeId, ticket.tenantId)) ticket.assigneeId = apply.assigneeId;
else console.warn(`[tickets] neznamy resitel ${apply.assigneeId}, ticket zustava jak byl`);
}
@@ -1491,7 +1518,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
// Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz.
let assigneeId = input.assigneeId ?? null;
if (assigneeId && !findPerson(assigneeId)) {
if (assigneeId && !findPerson(assigneeId, input.tenantId)) {
console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`);
assigneeId = null;
}
@@ -1631,15 +1658,11 @@ export function assignTicket(
return undefined;
}
const person = assigneeId ? findPerson(assigneeId) : null;
// Resitel musi byt clenem firmy ticketu. Jinak by ticket zmizel z prehledu
// firmy a objevil se nekomu, kdo do ni nepatri. Necleny `findPerson` nevrati.
const person = assigneeId ? findPerson(assigneeId, ticket.tenantId) : null;
if (assigneeId && !person) {
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`);
console.warn(`[tickets] ${id}: prirazeni na ${assigneeId}, ktery neni clenem firmy`);
return undefined;
}
@@ -1653,7 +1676,7 @@ export function assignTicket(
if (person && person.id !== previousAssignee) {
notify({
tenantId: ticket.tenantId,
personId: person.id,
userId: person.id,
kind: 'ticket.assigned',
title: `Máte nový ticket ${ticket.id}: ${ticket.subject}`,
href: `/dashboard/tickety/${ticket.id}`,
@@ -1766,8 +1789,9 @@ export function claimTicket(
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
const person = findPerson(personId);
if (!person || person.tenantId !== ticket.tenantId) return undefined;
// Necleny firmy `findPerson` nevrati, takze tohle je i kontrola firmy.
const person = findPerson(personId, ticket.tenantId);
if (!person) return undefined;
ticket.assigneeId = person.id;
markResponded(ticket);
+61 -4
View File
@@ -32,6 +32,10 @@ const DEMO_PASSWORD = 'demo1234';
* - platformni admin, ktery vidi napric firmami,
* - clovek ve dvou firmach, v kazde s jinou roli,
* - bezny resitel jedne firmy.
*
* Resitel je clenstvi, ne zvlastni zaznam (viz data/people.ts). Popisek
* a kapacita proto visi na clenstvi: Nordis a LogiTrans maji svuj tym, aby
* bylo videt, ze firma neni jen stitek.
*/
export function seedUsers(): StoredUser[] {
const timestamp = nowIso();
@@ -50,7 +54,7 @@ export function seedUsers(): StoredUser[] {
email: 'admin@automia.cz',
name: 'Jiří Uhlíř',
platformAdmin: true,
memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'] }],
memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'], role: 'Vedoucí týmu', capacity: 5 }],
},
{
...base,
@@ -60,8 +64,8 @@ export function seedUsers(): StoredUser[] {
platformAdmin: false,
// Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk.
memberships: [
{ tenantId: 'tnt_automia', roleIds: ['agent'] },
{ tenantId: 'tnt_nordis', roleIds: ['admin'] },
{ tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Servicedesk', capacity: 8 },
{ tenantId: 'tnt_nordis', roleIds: ['admin'], role: 'Správce servicedesku', capacity: 8 },
],
},
{
@@ -70,7 +74,39 @@ export function seedUsers(): StoredUser[] {
email: 'martin.kriz@automia.cz',
name: 'Martin Kříž',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'] }],
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Integrace a API', capacity: 6 }],
},
{
...base,
id: 'usr_novakova',
email: 'eva.novakova@automia.cz',
name: 'Eva Nováková',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], role: 'Voiceboti', capacity: 6 }],
},
{
...base,
id: 'usr_bartos',
email: 'lukas.bartos@nordis.cz',
name: 'Lukáš Bartoš',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'], role: 'Podpora', capacity: 7 }],
},
{
...base,
id: 'usr_horakova',
email: 'simona.horakova@nordis.cz',
name: 'Simona Horáková',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'], role: 'Fakturace', capacity: 5 }],
},
{
...base,
id: 'usr_kadlec',
email: 'ondrej.kadlec@logitrans.cz',
name: 'Ondřej Kadlec',
platformAdmin: false,
memberships: [{ tenantId: 'tnt_logitrans', roleIds: ['agent'], role: 'Dispečink', capacity: 6 }],
},
];
}
@@ -115,6 +151,27 @@ export function findUserById(id: string): User | undefined {
return found && found.enabled ? toUser(found) : undefined;
}
/**
* Zaznam uctu vcetne vypnuteho.
*
* Pro pohled resitele (data/people.ts): vypnuty clovek ma dal jmeno u svych
* starych ticketu, jen se nenabizi k prirazeni.
*/
export function findStoredUser(id: string): StoredUser | undefined {
return cache.byId(id);
}
/** Ucet podle e-mailu vcetne vypnuteho. Pro kontrolu unikatnosti a migraci. */
export function findStoredUserByEmail(email: string): StoredUser | undefined {
const normalized = email.trim().toLowerCase();
return cache.find((user) => user.email.toLowerCase() === normalized);
}
/** Vsechny ucty bez razeni. Pro odvozene pohledy, ktere si radi samy. */
export function allStoredUsers(): StoredUser[] {
return cache.all();
}
/**
* Asynchronne zamerne. `hashSync` blokuje event loop na desitky milisekund
* a po tu dobu nikdo jiny nedostane odpoved - u hesla se to opakuje pri
+3 -3
View File
@@ -202,9 +202,9 @@ export function widgetCatalog(
);
/*
* Kdo neni veden jako resitel, nema "moje tickety" co ukazat: ticket se
* prirazuje resiteli, ne uctu. Nabidnout mu prazdnou dlazdici by znamenalo,
* ze uvidi prazdno navzdy a nedozvi se proc.
* `hasPerson` = je clenem firmy. Kdo v ni clenstvi nema (spravce platformy
* na cizi firme), nema "moje tickety" co ukazat. Nabidnout mu prazdnou
* dlazdici by znamenalo, ze uvidi prazdno navzdy a nedozvi se proc.
*/
const builtin = widgets.filter((widget) => hasPerson || widget.id !== 'list.myTickets');
+132 -12
View File
@@ -97,6 +97,7 @@ const settingsEntities = [
{ path: 'tenants', label: 'firmy', permission: 'tenant.manage' },
{ path: 'users', label: 'uzivatele', permission: 'user.manage' },
{ path: 'roles', label: 'role a prava', permission: 'role.manage' },
// Resitele maji vlastni popis v `additionalPaths`: pod endpointy jsou ucty.
{ path: 'people', label: 'resitele', permission: 'people.manage' },
{ path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' },
{ path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' },
@@ -149,6 +150,109 @@ const tooMany = { '429': { description: 'Prilis mnoho pokusu z jedne adresy, viz
* Pridane pri kontrole uplnosti: kazda registrovana routa musi byt tady.
*/
const additionalPaths: Record<string, unknown> = {
/*
* Resitel je clenstvi uctu ve firme, ID resitele je ID uctu. Endpointy
* a pravo zustavaji, ale zalozeni zaklada ucet (nebo prida clenstvi uz
* existujicimu) a smazani odebira clenstvi. Prepisuje obecny popis z
* `settingsEntities`, protoze telo je jine nez u ostatnich entit.
*/
'/api/dashboard/settings/people': {
get: {
tags: ['Nastaveni'],
summary: 'Seznam - resitele',
description: 'Clenove vybrane firmy vcetne vypnutych uctu, jeden pohled na clenstvi.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Resitele', {
type: 'object',
properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Person' } } },
}),
},
},
post: {
tags: ['Nastaveni'],
summary: 'Vytvorit - resitele',
description:
'Zalozi ucet s clenstvim ve vybrane firme. Kdyz ucet s tim e-mailem uz existuje ' +
'a ve firme neni, prida se mu jen clenstvi (jmeno, heslo a zapnuti se neprepisuji). ' +
'Bez hesla dostane nahodne. Role musi byt teto firmy nebo systemove, vychozi role_agent.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
password: { type: 'string', format: 'password', description: 'Nepovinne, jinak nahodne.' },
roleIds: { type: 'array', items: { type: 'string' }, default: ['role_agent'] },
role: { type: 'string', description: 'Popisek, cim se v tymu zabyva.' },
capacity: { type: 'integer', default: 8 },
externalIds: { type: 'array', items: { type: 'string' } },
enabled: { type: 'boolean', default: true },
},
}),
responses: {
'201': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'400': { description: 'Neplatny vstup, neznama role, nebo uz je clenem firmy' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
},
},
},
'/api/dashboard/settings/people/{id}': {
get: {
tags: ['Nastaveni'],
summary: 'Detail - resitele',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'404': { description: 'Neni clenem firmy' },
},
},
patch: {
tags: ['Nastaveni'],
summary: 'Upravit - resitele',
description:
'Jmeno, e-mail a zapnuti meni ucet (plati ve vsech firmach), role, popisek, kapacita ' +
'a externi ID meni clenstvi v teto firme. E-mail musi zustat unikatni.',
security: bearer,
parameters: [idParam, tenantParam],
requestBody: jsonBody({
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
enabled: { type: 'boolean' },
roleIds: { type: 'array', items: { type: 'string' } },
role: { type: 'string' },
capacity: { type: 'integer' },
externalIds: { type: 'array', items: { type: 'string' } },
},
}),
responses: {
'200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'400': { description: 'Neplatny vstup, neznama role, nebo obsazeny e-mail' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
'404': { description: 'Neni clenem firmy' },
},
},
delete: {
tags: ['Nastaveni'],
summary: 'Smazat - resitele',
description:
'Odebere clenstvi ve vybrane firme. Ucet zustava; bez jedineho clenstvi se vypne ' +
'(spravce platformy ne).',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'204': { description: 'Clenstvi odebrano' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
'404': { description: 'Neni clenem firmy' },
},
},
},
'/api/dashboard/people/{id}': {
get: {
tags: ['Tickety'],
@@ -231,7 +335,7 @@ const additionalPaths: Record<string, unknown> = {
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Prevzato', { $ref: '#/components/schemas/Ticket' }),
'400': { description: 'Volajici neni veden jako resitel' },
'400': { description: 'Volajici neni clenem firmy' },
'403': { description: 'Chybi pravo ticket.assign.self, nebo ticket neni ve skupine volajiciho' },
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
'409': { description: 'Ticket uz nekdo resi' },
@@ -330,7 +434,7 @@ const additionalPaths: Record<string, unknown> = {
email: { type: 'string', description: 'Prazdne = komukoliv s odkazem.' },
note: { type: 'string' },
roleIds: { type: 'array', items: { type: 'string' } },
asPerson: { type: 'boolean', default: true },
asPerson: { type: 'boolean', description: 'Stary priznak, ignoruje se: resitel je kazdy clen firmy.' },
},
}),
responses: {
@@ -564,8 +668,9 @@ const additionalPaths: Record<string, unknown> = {
tags: ['Nastaveni'],
summary: 'Zalozit firmu z ARES vcetne uctu',
description:
'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet se roli spravce firmy ' +
'a nahodnym heslem; bez e-mailu dostanou nahradni IC-poradi@placeholder.cz.',
'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet s roli spravce firmy ' +
'a nahodnym heslem; funkce z rejstriku jde do popisku clenstvi. Bez e-mailu dostanou ' +
'nahradni IC-poradi@placeholder.cz.',
security: bearer,
requestBody: jsonBody({
type: 'object',
@@ -582,6 +687,11 @@ const additionalPaths: Record<string, unknown> = {
properties: {
name: { type: 'string' },
email: { type: 'string', description: 'Prazdne = nahradni e-mail.' },
roles: {
type: 'array',
items: { type: 'string' },
description: 'Funkce z rejstriku, jde do popisku clenstvi.',
},
},
},
},
@@ -629,7 +739,7 @@ const additionalPaths: Record<string, unknown> = {
'/api/dashboard/settings/people-overview': {
get: {
tags: ['Nastaveni'],
summary: 'Resitele vcetne vypnutych',
summary: 'Resitele vcetne vypnutych (totez co seznam)',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Resitele') },
@@ -1041,16 +1151,26 @@ export function buildOpenApiDocument() {
},
Person: {
type: 'object',
description: 'Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail.',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'ppl_vomacka' },
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: { type: 'boolean', description: 'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.' },
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
@@ -1099,7 +1219,7 @@ export function buildOpenApiDocument() {
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'ppl_vomacka' },
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
@@ -1494,8 +1614,8 @@ export function buildOpenApiDocument() {
tags: ['Tickety'],
summary: 'Seznam resitelu',
description:
'Lide, na ktere jde ticket priradit. `meId` je resitel odpovidajici ' +
'prihlasenemu uzivateli, nebo null, pokud zadny neni.',
'Clenove firmy, na ktere jde ticket priradit. `meId` je ID prihlaseneho uctu, ' +
'nebo null, kdyz ve firme neni clenem.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
@@ -1676,7 +1796,7 @@ export function buildOpenApiDocument() {
type: 'object',
required: ['assigneeId'],
properties: {
assigneeId: { type: 'string', nullable: true, example: 'ppl_vomacka' },
assigneeId: { type: 'string', nullable: true, example: 'usr_2', description: 'ID uctu clena firmy.' },
},
},
},
+20 -3
View File
@@ -23,7 +23,7 @@ import {
import { recordAudit } from '../data/audit.js';
import { nowIso } from '../data/store/index.js';
import { generateIntakeToken, listTenants, tenantStore, type Tenant } from '../data/tenants.js';
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
import { hashPassword, listAllUsers, refreshUsers, userStore, type StoredUser } from '../data/users.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { validationError } from '../middleware/validation.js';
@@ -83,6 +83,8 @@ const createSchema = z.object({
name: z.string().trim().min(2, 'Jméno je moc krátké.').max(80),
/** Prazdne = nahradni e-mail podle poradi. */
email: z.string().trim().email('Zadejte platný e-mail.').optional().or(z.literal('')),
/** Funkce z rejstriku (jednatel, clen predstavenstva). Jen popisek clenstvi. */
roles: z.array(z.string().trim().max(80)).max(10).default([]),
}),
)
.max(50)
@@ -90,7 +92,12 @@ const createSchema = z.object({
});
/**
* Zalozi firmu a ucty vybranych osob, vsechny jako spravce firmy.
* Zalozi firmu a ucty vybranych osob (vsechny jako spravce firmy).
*
* Resitel je clenstvi (viz data/people.ts), takze se novi lide objevi
* v Lidech rovnou a jde jim prihodit ticket - presne to, co spravce po
* zalozeni firmy udela jako prvni. Funkce z rejstriku jde do popisku
* clenstvi, nic jineho nerozhoduje.
*
* Udaje firmy se berou znovu z ARES, ne z klienta: co se ulozi k firme, ma
* odpovidat registru, ne tomu, co prislo v tele. Heslo je nahodne a nikam se
@@ -164,13 +171,23 @@ aresRouter.post('/tenants', async (req, res) => {
name: person.name,
passwordHash: await hashPassword(randomBytes(18).toString('base64url')),
platformAdmin: false,
memberships: [{ tenantId: created.id, roleIds: ['role_admin'] }],
memberships: [
{
tenantId: created.id,
roleIds: ['role_admin'],
role: person.roles.join(', '),
capacity: 8,
externalIds: [],
},
],
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
};
users.push(await userStore.create(user));
}
// Ucty se ctou z kopie v pameti, bez obnovy by nove lidi v Lidech nebyli.
if (users.length > 0) await refreshUsers();
recordAudit({
userId: req.user!.id,
+3 -5
View File
@@ -28,7 +28,6 @@ import { z } from 'zod';
import { seesWholeTenant } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { findPersonByEmail } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
@@ -153,7 +152,6 @@ helpdeskRouter.post('/', (req, res) => {
if (!parsed.success) return validationError(res, parsed.error);
const tenant = findTenant(tenantId);
const person = findPersonByEmail(req.user!.email);
const ticket = createTicket({
// Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket.
@@ -169,7 +167,8 @@ helpdeskRouter.post('/', (req, res) => {
customer: {
id: null,
company: tenant?.name ?? '',
contact: person?.name ?? req.user!.email,
// Zadavatel je prihlaseny ucet, jmeno je jeho.
contact: req.user!.name,
reply: req.user!.email,
},
trace: [
@@ -224,8 +223,7 @@ helpdeskRouter.post('/:id/comment', (req, res) => {
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const person = findPersonByEmail(req.user!.email);
const author = person?.name ?? req.user!.email;
const author = req.user!.name;
const updated = addComment(req.params.id, author, parsed.data.text, [], [tenantId]);
if (!updated) {
+4 -29
View File
@@ -25,7 +25,6 @@ import {
refreshInvites,
} from '../data/invites.js';
import { hasPermission, rolesFor, type Role } from '../data/permissions.js';
import { personStore, refreshPeople, listPeople } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import {
findUserByEmail,
@@ -179,34 +178,10 @@ publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => {
userId = created.id;
}
// Resitel je clenstvi (viz data/people.ts), takze prijetim pozvanky je
// clovek rovnou i v Lidech. Zadny dalsi zaznam se nezaklada.
await refreshUsers();
/*
* Resitel je nekdo, komu jde prehodit ticket. Neni to totez co ucet:
* ucetni muze mit pristup do portalu, aniz by kdy resila ticket.
*/
if (invite.asPerson) {
const alreadyPerson = listPeople([invite.tenantId]).some(
(person) => person.email.toLowerCase() === email,
);
if (!alreadyPerson) {
const timestamp = nowIso();
await personStore.create({
id: `ppl_${Math.random().toString(36).slice(2, 10)}`,
tenantId: invite.tenantId,
name: parsed.data.name,
email,
role: '',
capacity: 8,
enabled: true,
externalIds: [],
createdAt: timestamp,
updatedAt: timestamp,
});
await refreshPeople();
}
}
await markUsed(invite, userId);
recordAudit({
@@ -276,7 +251,8 @@ const createSchema = z.object({
email: z.string().trim().email().optional().or(z.literal('')),
note: z.string().trim().max(120).optional(),
roleIds: z.array(z.string().min(1)).min(1, 'Vyberte aspoň jednu roli.'),
asPerson: z.boolean().default(true),
/** Stary priznak, prijima se kvuli starsim klientum a ignoruje. */
asPerson: z.boolean().optional(),
});
inviteRouter.post('/', (req, res) => {
@@ -304,7 +280,6 @@ inviteRouter.post('/', (req, res) => {
email: parsed.data.email || undefined,
note: parsed.data.note,
roleIds: parsed.data.roleIds,
asPerson: parsed.data.asPerson,
createdByEmail: req.user!.email,
});
+296 -39
View File
@@ -10,7 +10,8 @@
* uzivatel ulozil roli a prava by se zmenila az po restartu.
*/
import type { Request } from 'express';
import { randomBytes, randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import { bootstrapDataRefresh } from '../data/refresh.js';
@@ -21,7 +22,14 @@ import {
validateWidget,
type CustomWidget,
} from '../data/customWidgets.js';
import { groupStore, listAllPeople, personStore, type Person } from '../data/people.js';
import {
DEFAULT_CAPACITY,
findPerson,
groupStore,
listAllPeople,
personView,
type Person,
} from '../data/people.js';
import {
allPermissions,
roleStore,
@@ -54,7 +62,18 @@ import {
validateAction,
type TicketAction,
} from '../data/ticketActions.js';
import { hashPassword, listAllUsers, userStore, usersOfTenant, type StoredUser } from '../data/users.js';
import {
findStoredUser,
findStoredUserByEmail,
hashPassword,
listAllUsers,
refreshUsers,
userStore,
usersOfTenant,
type StoredUser,
} from '../data/users.js';
import { nowIso } from '../data/store/index.js';
import type { Membership } from '../types.js';
import { hasPermission } from '../data/permissions.js';
import { publish } from '../events/bus.js';
import { safeRouter } from '../middleware/asyncHandler.js';
@@ -163,11 +182,30 @@ settingsRouter.use('/ares', aresRouter);
// ---------------------------------------------------------------- uzivatele
/**
* Clenstvi nese i pole resitele (popisek, kapacita, externi ID, viditelnost).
* Prijimaji se nepovinne a pri uprave se **doplni z ulozeneho clenstvi**, viz
* `keepMembershipFields`: klient, ktery posila jen role, by je jinak smazal.
*/
const membershipSchema = z.object({
tenantId: z.string().min(1),
roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'),
seesAllTenant: z.boolean().optional(),
role: z.string().trim().max(60).optional(),
capacity: z.number().int().min(1).max(200).optional(),
externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
enabled: z.boolean().optional(),
});
/** Do prichozich clenstvi doplni pole, ktera klient neposlal, z ulozenych. */
function keepMembershipFields(incoming: Membership[], existing?: StoredUser): Membership[] {
if (!existing) return incoming;
return incoming.map((membership) => {
const stored = existing.memberships.find((item) => item.tenantId === membership.tenantId);
return stored ? { ...stored, ...membership } : membership;
});
}
const userCreate = z.object({
email: z.string().trim().email('Zadejte platný e-mail.'),
name: z.string().trim().min(2).max(80),
@@ -212,7 +250,11 @@ function prepareUserInput(
input: UserInput | undefined,
existing?: StoredUser,
): { ok: true; input: UserInput | undefined } | { ok: false; status: number; message: string } {
if (req.user!.platformAdmin) return { ok: true, input };
if (req.user!.platformAdmin) {
return input?.memberships
? { ok: true, input: { ...input, memberships: keepMembershipFields(input.memberships, existing) } }
: { ok: true, input };
}
if (existing?.platformAdmin) {
return { ok: false, status: 403, message: 'Správce platformy upravuje jen správce platformy.' };
@@ -252,7 +294,13 @@ function prepareUserInput(
}
// Clenstvi jinde zustavaji, ta spravce firmy nevidi a nesmi je smazat.
const others = (existing?.memberships ?? []).filter((m) => m.tenantId !== tenantId);
return { ok: true, input: { ...input, memberships: [...others, ...input.memberships] } };
return {
ok: true,
input: {
...input,
memberships: [...others, ...keepMembershipFields(input.memberships, existing)],
},
};
}
return { ok: true, input };
@@ -409,53 +457,262 @@ settingsRouter.get('/roles-available', (req, res) => {
// ------------------------------------------------------------------ resitele
const personCreate = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email(),
/*
* Resitel je clenstvi uctu ve firme, ne vlastni zaznam (viz data/people.ts).
* Endpointy a pravo `people.manage` zustavaji, ale pod nimi jsou ucty:
* zalozeni resitele zalozi ucet (nebo prida clenstvi uz existujicimu),
* smazani odebere clenstvi. `crudRouter` tu nejde pouzit, protoze zaznam,
* ktery se meni, je ucet bez firmy a odpoved je pohled za jednu firmu.
*
* Sprava uctu (`/users`) zustava spravci platformy. Kdyz tam nekomu zmeni
* jmeno, v Lidech se to projevi samo - pohled se odvozuje.
*/
const personFields = {
/** Cim se v tymu zabyva. Jen popisek. */
role: z.string().trim().max(60).optional(),
capacity: z.number().int().min(1).max(200).optional(),
/** ID, pod kterymi cloveka znaji cizi aplikace, napr. voicebotId. */
externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
/** Role clenstvi v teto firme. Vychozi je bezny resitel. */
roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.').optional(),
};
const personCreate = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email('Zadejte platný e-mail.'),
/** Bez hesla dostane nahodne; clovek si ho nastavi pres zmenu hesla. */
password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.').optional(),
enabled: z.boolean().optional(),
...personFields,
});
settingsRouter.use(
'/people',
crudRouter<Person, z.infer<typeof personCreate>, Partial<Person>>({
store: personStore,
idPrefix: 'ppl',
event: 'person',
createSchema: personCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().optional(),
role: z.string().trim().max(60).optional(),
capacity: z.number().int().min(1).max(200).optional(),
enabled: z.boolean().optional(),
externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
}),
writePermission: 'people.manage',
build: (input) => ({
name: input.name,
email: input.email.toLowerCase(),
role: input.role ?? '',
capacity: input.capacity ?? 8,
enabled: true,
externalIds: input.externalIds ?? [],
}),
validate: (person, all) =>
all.some((other) => other.email.toLowerCase() === person.email.toLowerCase())
? [`Řešitel s e-mailem ${person.email} už v této firmě je.`]
: [],
}),
);
const personUpdate = z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email('Zadejte platný e-mail.').optional(),
enabled: z.boolean().optional(),
...personFields,
});
/** Vcetne vypnutych. Pro spravu tymu. */
/** Firma a pravo spravovat jeji lidi. Pri odepreni odpovi a vrati null. */
function managedPeopleTenant(req: Request, res: Response): string | null {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return null;
if (!hasPermission(req.user!, 'people.manage', tenantId)) {
console.warn(`[crud] ${req.user!.email}: chybi pravo people.manage u person`);
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
return null;
}
return tenantId;
}
/** Role z teto firmy nebo systemove. Vraci popis problemu, nebo null. */
function unknownRoles(roleIds: string[], tenantId: string): string | null {
const known = rolesFor(tenantId);
const unknown = roleIds.filter((ref) => !known.some((role) => role.id === ref || role.key === ref));
return unknown.length > 0 ? `Role ${unknown.join(', ')} v této firmě neexistuje.` : null;
}
/** Ucet s clenstvim v teto firme. Cizi se chova jako neexistujici. */
function memberAccount(id: string, tenantId: string): StoredUser | undefined {
const user = findStoredUser(id);
return user && memberOf(user, tenantId) ? user : undefined;
}
/**
* Spravce firmy na spravce platformy nesaha, stejne jako u `/users`. Jinak by
* mu pres Lide mohl vzit clenstvi nebo vypnout ucet.
*/
function guardPlatformAdmin(req: Request, res: Response, target: StoredUser): boolean {
if (target.platformAdmin && !req.user!.platformAdmin) {
res.status(403).json({ error: 'forbidden', message: 'Správce platformy upravuje jen správce platformy.' });
return false;
}
return true;
}
/** Ohlasi zmenu do streamu, aby si portal seznam opravil na miste. */
function announcePerson(verb: 'created' | 'updated' | 'deleted', person: Person): void {
publish(
`person.${verb}`,
`person ${verb}: ${person.name}`,
verb === 'deleted' ? { id: person.id } : { id: person.id, person },
person.tenantId,
);
}
settingsRouter.get('/people', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: listAllPeople([tenantId]) });
});
/** Vcetne vypnutych. Pro spravu tymu. Totez co seznam, zustava kvuli klientum. */
settingsRouter.get('/people-overview', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: listAllPeople([tenantId]) });
});
settingsRouter.get('/people/:id', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const person = findPerson(req.params.id, tenantId);
if (!person) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
return res.json(person);
});
settingsRouter.post('/people', async (req, res) => {
const tenantId = managedPeopleTenant(req, res);
if (!tenantId) return;
const parsed = personCreate.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const input = parsed.data;
const roleIds = input.roleIds ?? ['role_agent'];
const roleProblem = unknownRoles(roleIds, tenantId);
if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem });
const membership: Membership = {
tenantId,
roleIds,
role: input.role ?? '',
capacity: input.capacity ?? DEFAULT_CAPACITY,
externalIds: input.externalIds ?? [],
// Zapnuti je za clenstvi: spravce firmy rozhoduje o cloveku u sebe, ne jinde.
enabled: input.enabled ?? true,
};
const email = input.email.toLowerCase();
const existing = findStoredUserByEmail(email);
let saved: StoredUser | undefined;
if (existing) {
/*
* Ucet uz je: clovek z jine firmy, nebo nekdo, komu spravce platformy
* zalozil ucet driv. Prida se jen clenstvi; jmeno, heslo ani priznak
* zapnuti se neprepisuji, ty nejsou teto firmy.
*/
if (!guardPlatformAdmin(req, res, existing)) return;
if (memberOf(existing, tenantId)) {
return res.status(400).json({
error: 'validation_error',
message: `Řešitel s e-mailem ${email} už v této firmě je.`,
});
}
saved = await userStore.update(
existing.id,
{ memberships: [...existing.memberships, membership] } as Partial<StoredUser>,
{ tenantIds: [], includeGlobal: true },
);
} else {
const timestamp = nowIso();
saved = await userStore.create({
id: `usr_${randomUUID().slice(0, 8)}`,
tenantId: null,
email,
name: input.name,
// Nahodne heslo se nikam neposila, clovek si nastavi svoje.
passwordHash: await hashPassword(input.password ?? randomBytes(18).toString('base64url')),
platformAdmin: false,
memberships: [membership],
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
});
}
if (!saved) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
await refreshUsers();
const person = personView(saved, membership);
announcePerson('created', person);
return res.status(201).json(person);
});
settingsRouter.patch('/people/:id', async (req, res) => {
const tenantId = managedPeopleTenant(req, res);
if (!tenantId) return;
const existing = memberAccount(req.params.id, tenantId);
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
if (!guardPlatformAdmin(req, res, existing)) return;
const parsed = personUpdate.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const input = parsed.data;
if (input.roleIds) {
const roleProblem = unknownRoles(input.roleIds, tenantId);
if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem });
}
const email = input.email?.toLowerCase();
if (email !== undefined) {
const taken = findStoredUserByEmail(email);
if (taken && taken.id !== existing.id) {
return res.status(400).json({ error: 'validation_error', message: `E-mail ${email} už někdo má.` });
}
}
// Clenstvi v jinych firmach zustavaji, jak jsou - nejsou teto firmy.
const memberships = existing.memberships.map((item): Membership => {
if (item.tenantId !== tenantId) return item;
return {
...item,
...(input.roleIds !== undefined ? { roleIds: input.roleIds } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.capacity !== undefined ? { capacity: input.capacity } : {}),
...(input.externalIds !== undefined ? { externalIds: input.externalIds } : {}),
// Vypnuti jen tady. Ucet jako celek vypina jen sprava uzivatelu.
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
};
});
const patch: Partial<StoredUser> = {
memberships,
...(input.name !== undefined ? { name: input.name } : {}),
...(email !== undefined ? { email } : {}),
};
const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true });
if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
await refreshUsers();
const person = personView(updated, memberships.find((item) => item.tenantId === tenantId)!);
announcePerson('updated', person);
return res.json(person);
});
/**
* Smazani resitele = odebrani clenstvi. Ucet zustava: muze byt i v jine
* firme a i kdyz neni, jeho tickety a historie se na nej dal odkazuji.
* Ucet bez jedineho clenstvi se vypne, aby se s nim neslo prihlasit
* do prazdna; spravce platformy se nevypina, ten firmu nepotrebuje.
*/
settingsRouter.delete('/people/:id', async (req, res) => {
const tenantId = managedPeopleTenant(req, res);
if (!tenantId) return;
const existing = memberAccount(req.params.id, tenantId);
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
if (!guardPlatformAdmin(req, res, existing)) return;
const before = personView(existing, existing.memberships.find((item) => item.tenantId === tenantId)!);
const memberships = existing.memberships.filter((item) => item.tenantId !== tenantId);
const patch: Partial<StoredUser> = {
memberships,
...(memberships.length === 0 && !existing.platformAdmin ? { enabled: false } : {}),
};
const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true });
if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
await refreshUsers();
announcePerson('deleted', before);
return res.status(204).end();
});
// ------------------------------------------------------------------- skupiny
/**
+3 -2
View File
@@ -23,7 +23,7 @@ import {
type WidgetTicketFilter,
} from '../data/customWidgets.js';
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js';
import { findGroup, listGroups, listPeople, personName } from '../data/people.js';
import { getPath } from '../scripts/mapping.js';
import { runScript } from '../scripts/runner.js';
import { scriptIdFor } from '../scripts/lookup.js';
@@ -142,7 +142,8 @@ function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string |
function groupLabel(key: string, groupBy: WidgetGroupBy): string {
switch (groupBy) {
case 'assignee':
return key === '' ? 'Bez řešitele' : (findPerson(key)?.name ?? key);
// Jmeno je vlastnost uctu, ticket uz prosel filtrem na firmu.
return key === '' ? 'Bez řešitele' : (personName(key) ?? key);
case 'group':
return key === '' ? 'Bez skupiny' : (findGroup(key)?.name ?? key);
case 'type':
+5 -4
View File
@@ -478,12 +478,13 @@ const handlers: Record<string, Handler> = {
const assigneeId = inputs.assigneeId?.trim();
if (!assigneeId) return missing('řešitele');
const person = findPerson(assigneeId);
if (!person || person.tenantId !== context.tenantId) {
// Necleny firmy `findPerson` nevrati, cizi clovek je tedy "neexistuje".
const person = findPerson(assigneeId, context.tenantId);
if (!person) {
return {
ok: false,
summary: `řešitel ${assigneeId} neexistuje`,
detail: 'Zkontrolujte ID řešitele v kroku. Řešitelé se spravují v Nastavení.',
detail: 'Zkontrolujte ID řešitele v kroku. Řešitelé jsou členové firmy, spravují se v Nastavení.',
outputs: {},
};
}
@@ -503,7 +504,7 @@ const handlers: Record<string, Handler> = {
* Preda ticket cloveku podle ID z cizi aplikace.
*
* Typicky pripad: voicebot posle `voicebotId` a ticket ma skoncit u toho,
* komu ten voicebot patri. Vazba je u resitele (`externalIds`), takze pri
* komu ten voicebot patri. Vazba je u clenstvi (`externalIds`), takze pri
* zmene cloveka se meni na jednom miste, ne v kazde automatizaci.
*
* Kdyz se nikdo nenajde, muze se pouzit nahradni skupina - jinak by ticket
+1 -1
View File
@@ -38,7 +38,7 @@ export interface Access {
defaultTenantId: string | null;
/** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */
canAssignOthers: boolean;
/** ID resitele odpovidajiciho uzivateli, nebo null. */
/** ID resitele = ID uctu, kdyz je clenem vybrane firmy. Jinak null. */
personId: string | null;
/** Efektivni prava. Klient podle nich kresli tlacitka. */
permissions: string[];
+15 -1
View File
@@ -4,7 +4,14 @@
import type { TenantEntity } from './entities.js';
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
/**
* Resitel = clen firmy, jak ho vidi API.
*
* Neni to vlastni zaznam: `id` je ID uctu a `tenantId` firma, ve ktere ma
* clenstvi. Jmeno a e-mail jsou z uctu, `role`, `capacity` a `externalIds`
* z clenstvi (viz `Membership` v users.ts). Tentyz clovek ve dvou firmach je
* tedy dvakrat, pokazde se stejnym `id` a jinym `tenantId`.
*/
export interface Person extends TenantEntity {
/** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */
tenantId: string;
@@ -18,6 +25,13 @@ export interface Person extends TenantEntity {
enabled: boolean;
/** ID, pod kterymi cloveka znaji cizi aplikace (voicebot, ustredna, chat). */
externalIds: string[];
/**
* Role clenstvi v teto firme (ID nebo klic systemove role).
*
* Sprava lidi je ukazuje ve sloupci Role a nechava je menit. Nepovinne,
* aby starsi klient bez sloupce dal fungoval.
*/
roleIds?: string[];
}
/** Skupina resitelu tak, jak ji vidi bezny uzivatel: jen jmeno. */
+21
View File
@@ -31,6 +31,27 @@ export interface Membership {
* kazdemu spravci zmizela vetsina ticketu.
*/
seesAllTenant?: boolean;
/**
* Resitel je clenstvi, ne zvlastni zaznam.
*
* Kazdy clen firmy muze mit tickety u sebe, proto tyhle tri veci visi na
* clenstvi: v jedne firme je clovek dispecer s kapacitou 6, v druhe ucetni
* s kapacitou 3. ID resitele je ID uctu. Driv byl resitel vlastni zaznam
* spojeny s uctem pres e-mail, a zmena e-mailu tu vazbu rozbila.
*/
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
role?: string;
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. Vychozi 8. */
capacity?: number;
/** ID, pod kterymi cloveka znaji cizi aplikace teto firmy (voicebot, ustredna). */
externalIds?: string[];
/**
* false = v teto firme se nenabizi k prirazeni, stare tickety mu zustavaji.
*
* Je to za clenstvi, ne za ucet: spravce firmy smi vypnout cloveka u sebe,
* ne v jine firme, kde ten clovek pracuje dal. `undefined` = aktivni.
*/
enabled?: boolean;
}
/** Verze uzivatele bezpecna pro odeslani na klienta. */