Log rekne co zpusobilo jakou zmenu, prevzeti ticketu a pozvanky
Nalezeno na bezicim serveru: TK-4946 mel 177 udalosti a 620 radku logu, pritom se skoro nic nestalo. Zmereno proti fronte: ve stejnem okne vzniklo presne tolik behu, kolik prislo udalosti (22 a 22), kazdy s jednim pokusem. Fronta nenasobi nic, odesilatel poslal 177 POSTu. Nase vina byla, ze to z historie neslo poznat. - Data udalosti se ukladaji. Kdyz krok nema vlastni, ulozi se to, cim beh zacal - u webhooku prijate telo. Prazdna udalost je horsi nez zadna. - Shodna udalost se pocita (repeats, lastAt), nezaklada dalsi radek. Ticket se pritom nemeni, takze duplikat nerozblika dashboard ani nespusti automatizaci na zmenu ticketu. Zahodit ji nejde, jinak by nikdo nezjistil, ze proti nam neco tluce. - Zmeny se radi pod udalost, ktera je zpusobila, a u udalosti stoji jmeno automatizace. Log se cte jako "prislo tohle -> zmenilo to tohle". - Poznamka o stavu jen kdyz se stav zmenil. "z in-progress na in-progress" u kazde zpravy byl zdroj tech 620 radku. - runsToday konecne znamena dnes: behy po dnech, k tomu vcera a celkem. Dosud to byl citac od zalozeni automatizace, jen se jmenoval "dnes". Vedle toho prace, o kterou slo predtim: - Prevzeti ticketu ze skupiny (POST /tickets/:id/claim) a krok Predat skupine s prepinacem automatickeho prideleni nejvolnejsimu. - Pozvanky do firmy: odkaz s nahodnym kodem, heslo si nastavi pozvany. - Resitele, skupiny a pozvanky presunuty z Nastaveni do zalozky Lide, cleny skupiny se vybiraji klikanim. - Ctyri AI znaky, ktere zbyvaly v kodu, pryc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fbe5b8ff6f
commit
f1e8253169
+75
-1
@@ -42,9 +42,10 @@ import { collectScopes } from '../data/flowScope.js';
|
||||
import { widgetCatalog } from '../data/widgets.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
|
||||
import { findPersonByEmail, groupsOfPerson, listGroups, listPeople } from '../data/people.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { listNotifications, markRead, unreadCount } from '../data/notifications.js';
|
||||
import { inviteRouter } from './invites.js';
|
||||
import { queueStats, recentRuns } from '../runtime/queue.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
|
||||
@@ -52,6 +53,8 @@ import { listTicketTypes } from '../data/ticketTypes.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
claimTicket,
|
||||
ticketAssignee,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
getWorkload,
|
||||
@@ -541,6 +544,74 @@ dashboardRouter.get('/tickets', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevzeti ticketu.
|
||||
*
|
||||
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
|
||||
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
|
||||
* vezme.
|
||||
*
|
||||
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
|
||||
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
|
||||
* `ticket.assign.others`.
|
||||
*/
|
||||
dashboardRouter.post('/tickets/:id/claim', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const access = accessFor(req.user!);
|
||||
if (!access.personId) {
|
||||
return res.status(400).json({
|
||||
error: 'not_a_person',
|
||||
message: 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenantIds = writableTenants(req);
|
||||
const current = ticketAssignee(req.params.id, tenantIds);
|
||||
if (current === undefined) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
|
||||
const ticket = getTicket(req.params.id, tenantIds);
|
||||
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
|
||||
if (current !== null && current !== access.personId) {
|
||||
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
|
||||
if (!hasPermission(req.user!, 'ticket.assign.others')) {
|
||||
return res.status(409).json({
|
||||
error: 'already_assigned',
|
||||
message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mine = groupsOfPerson(access.personId, tenantIds).map((group) => group.id);
|
||||
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
|
||||
|
||||
if (!inMyGroup && current !== null && current !== access.personId) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Tenhle ticket není ve vaší skupině.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = claimTicket(req.params.id, access.personId, tenantIds);
|
||||
if (!updated) {
|
||||
return res.status(400).json({ error: 'failed', message: 'Převzetí se nepodařilo.' });
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId: updated.tenantId,
|
||||
action: 'ticket.claim',
|
||||
target: updated.id,
|
||||
});
|
||||
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
|
||||
dashboardRouter.get('/tickets/workload', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
@@ -665,6 +736,9 @@ dashboardRouter.use('/connectors', connectorsRouter);
|
||||
// Nastaveni vsech entit. Cele stoji na fabrice crudRouter.
|
||||
dashboardRouter.use('/settings', settingsRouter);
|
||||
|
||||
// Pozvanky do firmy.
|
||||
dashboardRouter.use('/invites', inviteRouter);
|
||||
|
||||
// Data pro vlastni widgety. Jeden request na cely prehled.
|
||||
dashboardRouter.use('/widget-data', widgetDataRouter);
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Pozvanky do firmy.
|
||||
*
|
||||
* Dva druhy cest v jednom souboru, protoze patri k sobe:
|
||||
* - **za prihlasenim**: spravce firmy pozvanku vytvori, vidi seznam a muze ji
|
||||
* zrusit,
|
||||
* - **verejne**: kdo dostal odkaz, si prohledne, kam ho zve, a prijme ji.
|
||||
*
|
||||
* Verejna cast je zamerne uzka: rekne jen nazev firmy a jestli je pozvanka
|
||||
* platna. Nic o tom, kdo ve firme je, ani co obsahuje - kod v adrese je jedina
|
||||
* ochrana a odkaz se snadno dostane, kam nema.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
import {
|
||||
findByCode,
|
||||
inviteProblem,
|
||||
inviteStore,
|
||||
listInvites,
|
||||
markUsed,
|
||||
newInvite,
|
||||
refreshInvites,
|
||||
} from '../data/invites.js';
|
||||
import { rolesFor, type Role } from '../data/permissions.js';
|
||||
import { personStore, refreshPeople, listPeople } from '../data/people.js';
|
||||
import { findTenant } from '../data/tenants.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import {
|
||||
findUserByEmail,
|
||||
hashPassword,
|
||||
listAllUsers,
|
||||
refreshUsers,
|
||||
userStore,
|
||||
} from '../data/users.js';
|
||||
import { nowIso } from '../data/store/index.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { currentTenant } from './crud.js';
|
||||
|
||||
/** Verejne cesty. Bez prihlaseni, autorizuje kod v adrese. */
|
||||
export const publicInviteRouter = Router();
|
||||
|
||||
/** Cesty za prihlasenim: sprava pozvanek. */
|
||||
export const inviteRouter = Router();
|
||||
|
||||
// ------------------------------------------------------------------ verejne
|
||||
|
||||
/**
|
||||
* Co je za timhle odkazem.
|
||||
*
|
||||
* Vraci minimum: nazev firmy a jestli to jde prijmout. Kdyz uz e-mail znamy,
|
||||
* rekne ho, aby ho clovek nemusel psat - ale nic navic.
|
||||
*/
|
||||
publicInviteRouter.get('/:code', (req, res) => {
|
||||
const invite = findByCode(req.params.code);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
|
||||
}
|
||||
|
||||
const problem = inviteProblem(invite);
|
||||
const tenant = findTenant(invite.tenantId);
|
||||
|
||||
return res.json({
|
||||
tenant: tenant?.name ?? 'Neznámá firma',
|
||||
email: invite.email || null,
|
||||
/** Uz ucet ma? Pak staci heslo, ne registrace. */
|
||||
knownUser: invite.email ? findUserByEmail(invite.email) !== undefined : false,
|
||||
valid: problem === null,
|
||||
problem,
|
||||
});
|
||||
});
|
||||
|
||||
const acceptSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Prijeti pozvanky.
|
||||
*
|
||||
* Tri pripady:
|
||||
* - ucet neexistuje: zalozi se a pripoji k firme,
|
||||
* - ucet existuje a heslo sedi: jen se pripoji k firme,
|
||||
* - ucet existuje a heslo nesedi: odmitnuto.
|
||||
*
|
||||
* Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady.
|
||||
*/
|
||||
publicInviteRouter.post('/:code/accept', (req, res) => {
|
||||
const invite = findByCode(req.params.code);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
|
||||
}
|
||||
|
||||
const problem = inviteProblem(invite);
|
||||
if (problem) return res.status(409).json({ error: 'invalid_invite', message: problem });
|
||||
|
||||
const parsed = acceptSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
/*
|
||||
* Kdyz je pozvanka na konkretni adresu, musi souhlasit. Bez toho by odkaz
|
||||
* poslany dal fungoval komukoliv, coz u pristupu do firmy neni v poradku.
|
||||
*/
|
||||
if (invite.email && invite.email !== email) {
|
||||
return res.status(403).json({
|
||||
error: 'wrong_email',
|
||||
message: 'Tahle pozvánka je pro jinou e-mailovou adresu.',
|
||||
});
|
||||
}
|
||||
|
||||
const existing = findUserByEmail(email);
|
||||
|
||||
return void (async () => {
|
||||
let userId: string;
|
||||
|
||||
if (existing) {
|
||||
// Ucet uz je. Pripojime ho k firme, ale jen kdyz prokaze, ze je jeho.
|
||||
/*
|
||||
* Ucet uz existuje, takze musi prokazat, ze je jeho. Bez toho by kdokoliv
|
||||
* s odkazem pripojil cizi adresu ke sve firme a videl by jeji data.
|
||||
*/
|
||||
const stored = listAllUsers().find((item) => item.id === existing.id);
|
||||
const passwordOk =
|
||||
stored !== undefined && (await bcrypt.compare(parsed.data.password, stored.passwordHash));
|
||||
|
||||
if (!passwordOk) {
|
||||
return res.status(401).json({
|
||||
error: 'wrong_password',
|
||||
message: 'S touhle adresou už účet existuje. Zadejte k němu svoje heslo.',
|
||||
});
|
||||
}
|
||||
|
||||
const already = existing.memberships.some(
|
||||
(membership) => membership.tenantId === invite.tenantId,
|
||||
);
|
||||
if (!already) {
|
||||
await userStore.update(
|
||||
existing.id,
|
||||
{
|
||||
memberships: [
|
||||
...existing.memberships,
|
||||
{ tenantId: invite.tenantId, roleIds: invite.roleIds },
|
||||
],
|
||||
},
|
||||
{ tenantIds: [], includeGlobal: true },
|
||||
);
|
||||
}
|
||||
userId = existing.id;
|
||||
} else {
|
||||
const timestamp = nowIso();
|
||||
const created = await userStore.create({
|
||||
id: `usr_${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: null,
|
||||
email,
|
||||
name: parsed.data.name,
|
||||
passwordHash: hashPassword(parsed.data.password),
|
||||
platformAdmin: false,
|
||||
enabled: true,
|
||||
memberships: [{ tenantId: invite.tenantId, roleIds: invite.roleIds }],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
userId = created.id;
|
||||
}
|
||||
|
||||
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({
|
||||
userId,
|
||||
userEmail: email,
|
||||
tenantId: invite.tenantId,
|
||||
action: 'invite.accept',
|
||||
target: invite.id,
|
||||
detail: { pozval: invite.createdByEmail },
|
||||
});
|
||||
|
||||
console.info(`[pozvanky] ${email} prijal pozvanku do ${invite.tenantId}`);
|
||||
return res.status(201).json({ ok: true, email });
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[pozvanky] prijeti selhalo:', err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo přijmout.' });
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- za prihlasenim
|
||||
|
||||
inviteRouter.use(requireAuth);
|
||||
|
||||
/** Kdo smi zvat. Zvat lidi do firmy je totez co spravovat uzivatele. */
|
||||
const PERMISSION = 'user.manage';
|
||||
|
||||
inviteRouter.get('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const base = `${publicBaseUrl()}/pozvanka/`;
|
||||
return res.json({
|
||||
items: listInvites([tenantId]).map((invite) => ({
|
||||
...invite,
|
||||
/** Cela adresa, at ji jde rovnou zkopirovat a poslat. */
|
||||
url: `${base}${invite.code}`,
|
||||
problem: inviteProblem(invite),
|
||||
})),
|
||||
roles: rolesFor(tenantId).map((role: Role) => ({
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
inviteRouter.post('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
|
||||
const invite = newInvite({
|
||||
tenantId,
|
||||
email: parsed.data.email || undefined,
|
||||
note: parsed.data.note,
|
||||
roleIds: parsed.data.roleIds,
|
||||
asPerson: parsed.data.asPerson,
|
||||
createdByEmail: req.user!.email,
|
||||
});
|
||||
|
||||
return void inviteStore
|
||||
.create(invite)
|
||||
.then(async () => {
|
||||
await refreshInvites();
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId,
|
||||
action: 'invite.create',
|
||||
target: invite.id,
|
||||
detail: { email: invite.email || '(komukoliv)' },
|
||||
});
|
||||
return res.status(201).json({
|
||||
...invite,
|
||||
url: `${publicBaseUrl()}/pozvanka/${invite.code}`,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[pozvanky] zalozeni selhalo:', err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo vytvořit.' });
|
||||
});
|
||||
});
|
||||
|
||||
/** Zruseni pozvanky. Odkaz okamzite prestane platit. */
|
||||
inviteRouter.delete('/:id', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return void inviteStore
|
||||
.remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false })
|
||||
.then(async (removed) => {
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
|
||||
}
|
||||
await refreshInvites();
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId,
|
||||
action: 'invite.delete',
|
||||
target: req.params.id,
|
||||
});
|
||||
return res.status(204).end();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[pozvanky] zruseni selhalo:', err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'internal_error', message: 'Pozvánku se nepodařilo zrušit.' });
|
||||
});
|
||||
});
|
||||
|
||||
export { PERMISSION as invitePermission };
|
||||
Reference in New Issue
Block a user