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:
co-authored by
Claude Fable 5.1
parent
6eed909a0d
commit
bc6508e1f3
+16
-14
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user