Firmy a prava: tenance napric portalem
Portal nemel zadnou tenanci. Kterykoliv prihlaseny uzivatel videl vsechny tickety vsech firem i cely seznam resitelu, requireRole se nikde nevolal. Tenant je hranice viditelnosti, tenantId na ticketu, resiteli i automatizaci. Uzivatel muze patrit do vic firem, v kazde s jinou roli. Pristup napric firmami je zvlast jako platformAdmin. Tri pohledy na tickety: all, tenant, mine. Admin mezi nimi prepina vcetne vyberu firmy. O pravech rozhoduje jedine data/access.ts, klient si nic nedovozuje a bere je z GET /api/dashboard/access. Filtr na firmu je v ulozistich povinny argument, takze zapomenuty filtr neznamena vse, ale nezkompiluje se. Cizi firma vraci 403 nebo 404, nikdy tise zuzeny vysledek. Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin, agent si smi vzit ticket na sebe. Zmena prihlasovani: ucet klient@firma.cz zanikl, demo ucty jsou nove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd021b5f69
commit
2a3d75c85e
+4
-2
@@ -41,12 +41,14 @@ authRouter.post('/login', async (req, res) => {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email };
|
||||
const token = jwt.sign(payload, config.jwtSecret, {
|
||||
expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
|
||||
console.info(`[auth] prihlasen: ${user.email} (${user.role})`);
|
||||
console.info(
|
||||
`[auth] prihlasen: ${user.email} (firem: ${user.memberships.length}, platforma: ${user.platformAdmin})`,
|
||||
);
|
||||
return res.json({ token, user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
|
||||
+113
-31
@@ -1,6 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
import {
|
||||
accessFor,
|
||||
isDenied,
|
||||
resolveScope,
|
||||
type ResolvedScope,
|
||||
} from '../data/access.js';
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
@@ -40,8 +46,32 @@ export const dashboardRouter = Router();
|
||||
// Cely dashboard je jen pro prihlasene.
|
||||
dashboardRouter.use(requireAuth);
|
||||
|
||||
dashboardRouter.get('/summary', (_req, res) => {
|
||||
res.json(getSummary());
|
||||
/**
|
||||
* Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null,
|
||||
* takze volajici jen zkontroluje `if (!scope) return;`.
|
||||
*/
|
||||
function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
|
||||
const resolved = resolveScope(req.user!, {
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
|
||||
if (isDenied(resolved)) {
|
||||
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */
|
||||
dashboardRouter.get('/access', (req, res) => {
|
||||
res.json(accessFor(req.user!));
|
||||
});
|
||||
|
||||
dashboardRouter.get('/summary', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
return res.json(getSummary(scope.tenantIds));
|
||||
});
|
||||
|
||||
dashboardRouter.get('/incidents', (_req, res) => {
|
||||
@@ -50,10 +80,13 @@ dashboardRouter.get('/incidents', (_req, res) => {
|
||||
|
||||
// ------------------------------------------------------------------- tickety
|
||||
|
||||
/** Resitele. Klient je potrebuje do nabidky prirazeni i do prehledu firmy. */
|
||||
/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */
|
||||
dashboardRouter.get('/people', (req, res) => {
|
||||
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
|
||||
res.json({ items: listPeople(), meId: me?.id ?? null });
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const me = findPersonByEmail(req.user!.email);
|
||||
return res.json({ items: listPeople(scope.tenantIds), meId: me?.id ?? null });
|
||||
});
|
||||
|
||||
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
|
||||
@@ -71,16 +104,22 @@ const ticketChannels: TicketChannel[] = [
|
||||
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
|
||||
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
|
||||
*/
|
||||
function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | null): TicketFilter {
|
||||
const filter: TicketFilter = {};
|
||||
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
|
||||
const filter: TicketFilter = { tenantIds: scope.tenantIds };
|
||||
|
||||
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
|
||||
if (scope.scope === 'mine') {
|
||||
filter.assignee = scope.personId ?? '__nikdo__';
|
||||
return applyRest(query, filter);
|
||||
}
|
||||
|
||||
const assignee = typeof query.assignee === 'string' ? query.assignee : undefined;
|
||||
if (assignee === 'me') {
|
||||
// Prihlaseny uzivatel nemusi byt resitel - pak nema smysl nic vracet.
|
||||
filter.assignee = myPersonId ?? '__nikdo__';
|
||||
} else if (assignee) {
|
||||
filter.assignee = assignee;
|
||||
}
|
||||
if (assignee) filter.assignee = assignee;
|
||||
|
||||
return applyRest(query, filter);
|
||||
}
|
||||
|
||||
function applyRest(query: Record<string, unknown>, filter: TicketFilter): TicketFilter {
|
||||
|
||||
const status = typeof query.status === 'string' ? query.status : undefined;
|
||||
if (status) {
|
||||
@@ -98,24 +137,46 @@ function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | n
|
||||
}
|
||||
|
||||
dashboardRouter.get('/tickets', (req, res) => {
|
||||
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
|
||||
const filter = ticketFilterFrom(req.query as Record<string, unknown>, me?.id ?? null);
|
||||
res.json({ items: listTickets(filter), meId: me?.id ?? null });
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const me = findPersonByEmail(req.user!.email);
|
||||
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
|
||||
|
||||
return res.json({
|
||||
items: listTickets(filter),
|
||||
meId: me?.id ?? null,
|
||||
scope: scope.scope,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
});
|
||||
|
||||
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
|
||||
dashboardRouter.get('/tickets/workload', (_req, res) => {
|
||||
res.json(getWorkload(listPeople()));
|
||||
dashboardRouter.get('/tickets/workload', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds));
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets/:id', (req, res) => {
|
||||
const ticket = getTicket(req.params.id);
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
// Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou.
|
||||
// Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404.
|
||||
const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id);
|
||||
const ticket = getTicket(req.params.id, reachable);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
/** Firmy, na ktere ma uzivatel dosah pri zapisu. */
|
||||
function writableTenants(req: Request): string[] {
|
||||
return accessFor(req.user!).tenants.map((tenant) => tenant.id);
|
||||
}
|
||||
|
||||
const assignSchema = z.object({
|
||||
/** null = vratit do fronty. */
|
||||
assigneeId: z.string().min(1).nullable(),
|
||||
@@ -130,11 +191,20 @@ dashboardRouter.post('/tickets/:id/assign', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = assignTicket(req.params.id, parsed.data.assigneeId);
|
||||
// Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe.
|
||||
const access = accessFor(req.user!);
|
||||
if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.',
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req));
|
||||
if (!ticket) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Ticket nebo řešitel neexistuje.',
|
||||
message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.',
|
||||
});
|
||||
}
|
||||
return res.json(ticket);
|
||||
@@ -150,7 +220,7 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
|
||||
}
|
||||
|
||||
const ticket = updateTicketStatus(req.params.id, parsed.data.status);
|
||||
const ticket = updateTicketStatus(req.params.id, parsed.data.status, writableTenants(req));
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
@@ -171,7 +241,7 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
|
||||
}
|
||||
|
||||
const author = req.user?.name ?? 'Portál';
|
||||
const ticket = addComment(req.params.id, author, parsed.data.text);
|
||||
const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req));
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
@@ -351,12 +421,14 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
return problems;
|
||||
}
|
||||
|
||||
dashboardRouter.get('/automations', (_req, res) => {
|
||||
res.json({ items: listAutomations() });
|
||||
dashboardRouter.get('/automations', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
return res.json({ items: listAutomations(scope.tenantIds) });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/automations/:id', (req, res) => {
|
||||
const automation = getAutomation(req.params.id);
|
||||
const automation = getAutomation(req.params.id, writableTenants(req));
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
@@ -372,7 +444,17 @@ dashboardRouter.post('/automations', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const automation = createAutomation(parsed.data.name);
|
||||
// Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci.
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
if (!scope.tenantId) {
|
||||
return res.status(400).json({
|
||||
error: 'tenant_required',
|
||||
message: 'Vyberte firmu, do které se má automatizace založit.',
|
||||
});
|
||||
}
|
||||
|
||||
const automation = createAutomation(parsed.data.name, scope.tenantId);
|
||||
return res.status(201).json(automation);
|
||||
});
|
||||
|
||||
@@ -400,7 +482,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow });
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
@@ -409,7 +491,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
|
||||
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
|
||||
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
const updated = regenerateWebhookToken(req.params.id);
|
||||
const updated = regenerateWebhookToken(req.params.id, writableTenants(req));
|
||||
if (!updated) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
@@ -420,7 +502,7 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
});
|
||||
|
||||
dashboardRouter.delete('/automations/:id', (req, res) => {
|
||||
if (!deleteAutomation(req.params.id)) {
|
||||
if (!deleteAutomation(req.params.id, writableTenants(req))) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
|
||||
+18
-5
@@ -14,6 +14,7 @@ import {
|
||||
type TicketPriority,
|
||||
type TraceInput,
|
||||
} from '../data/ticketStore.js';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
@@ -342,8 +343,19 @@ simulateRouter.post('/', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam.
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId = access.defaultTenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(403).json({
|
||||
error: 'no_tenant',
|
||||
message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.',
|
||||
});
|
||||
}
|
||||
const tenantIds = access.tenants.map((tenant) => tenant.id);
|
||||
|
||||
const input = parsed.data;
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email}`);
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`);
|
||||
|
||||
switch (input.action) {
|
||||
case 'ticket.created': {
|
||||
@@ -356,6 +368,7 @@ simulateRouter.post('/', (req, res) => {
|
||||
const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null;
|
||||
|
||||
const ticket = createTicket({
|
||||
tenantId,
|
||||
subject,
|
||||
// Kanal posila cely text, predmet je jen jeho zkraceni.
|
||||
body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`,
|
||||
@@ -376,14 +389,14 @@ simulateRouter.post('/', (req, res) => {
|
||||
}
|
||||
|
||||
case 'ticket.resolved': {
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket();
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds);
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, všechny tickety jsou hotové.',
|
||||
});
|
||||
}
|
||||
const ticket = updateTicketStatus(target.id, 'resolved');
|
||||
const ticket = updateTicketStatus(target.id, 'resolved', tenantIds);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
@@ -416,7 +429,7 @@ simulateRouter.post('/', (req, res) => {
|
||||
}
|
||||
|
||||
case 'automation.run': {
|
||||
const automations = listAutomations().filter((a) => a.enabled);
|
||||
const automations = listAutomations(tenantIds).filter((a) => a.enabled);
|
||||
const targetId = input.automationId ?? automations[0]?.id;
|
||||
if (!targetId) {
|
||||
return res.status(409).json({
|
||||
@@ -424,7 +437,7 @@ simulateRouter.post('/', (req, res) => {
|
||||
message: 'Není co spustit, žádná automatizace není aktivní.',
|
||||
});
|
||||
}
|
||||
const automation = recordRun(targetId, input.ok ?? true);
|
||||
const automation = recordRun(targetId, input.ok ?? true, tenantIds);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user