Realne sluzby, OpenAI, odesilani e-mailu a helpdesk

Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps:
trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena
appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4,
Search Console, Google Ads i Sklik ted stoji na aplikaci analytics,
kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace
a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji.

OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni
adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona
hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime).
Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku.
Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64).

Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP.
Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene
hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od
zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle.

Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala,
vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve
fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId.
Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar.

Opravy v portalu:
- hlasky o ulozisti a odchozi IP vidi jen spravce platformy
- typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat
- stav ticketu je otevreny naseptavac, ne ciselnik
- ticket jde zalozit rucne, zakaznik u nej neni povinny
- kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne
- srovnane markdown tabulky v cele dokumentaci

Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky
Prehled, takze se prepnuti neprojevi v Lidech ani jinde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 07:40:16 +02:00
co-authored by Claude Opus 5
parent b25a149574
commit a771834e57
78 changed files with 7021 additions and 932 deletions
+54 -5
View File
@@ -41,7 +41,8 @@ import {
} from '../data/services.js';
import { config } from '../config.js';
import { egressIp } from '../data/egressIp.js';
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe, truncate } from '../scripts/util.js';
@@ -313,11 +314,57 @@ connectorsRouter.post('/:id/test', async (req, res) => {
return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl });
}
/*
* SMTP se neoveruje ctecim volanim, ale prihlasenim. `verify` nic neposila,
* takze test nikomu nic nedorucí - a pritom bez platneho hesla neprojde,
* coz je presne to, co ma overeni rict.
*/
if (service.transport === 'smtp') {
const settings = smtpSettings(target);
const url = 'settings' in settings ? smtpTargetUrl(settings.settings) : '';
const outcome = await verifySmtp(target);
const request = url === '' ? null : { method: 'SMTP', path: 'LOGIN', url };
const checked = 'přístupové údaje';
await setConnectorStatus(
connector.id,
outcome.ok ? 'ok' : 'error',
outcome.ok ? null : outcome.message,
{
at: new Date().toISOString(),
ok: outcome.ok,
checked,
status: outcome.status,
message: outcome.message,
detail: outcome.detail,
request,
responseHeaders: null,
// Posmovni servery seznamy povolenych IP nemaji, radu by to nedalo.
egressIp: null,
},
[tenantId],
);
if (!outcome.ok) console.warn(`[connectors] test ${connector.id} selhal: ${outcome.message}`);
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
return res.json({
ok: outcome.ok,
checked,
...(outcome.status !== null ? { status: outcome.status } : {}),
request,
message: outcome.message,
baseUrl: url,
...(outcome.detail ? { detail: outcome.detail } : {}),
});
}
const path = service.verifyPath ?? '/health';
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
// Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
const redact = createRedactor(Object.values(target.headers));
// Query se odrizne stejne jako v `ScriptRequestInfo` - muze v ni byt tajemstvi.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path.split('?')[0]}`;
const redact = createRedactor(targetSecrets(target));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
@@ -336,7 +383,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const message = service.verifyPath
? 'Napojení funguje, přístupové údaje jsou platné.'
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.';
const request = { method: 'GET', path, url: verifyUrl };
const request = { method: 'GET', path: path.split('?')[0], url: verifyUrl };
// Do historie patri i uspech. Bez nej se neda poznat, jestli konektor
// nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje.
@@ -378,7 +425,9 @@ connectorsRouter.post('/:id/test', async (req, res) => {
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
const request =
isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl };
isScriptError && err.request
? err.request
: { method: 'GET', path: path.split('?')[0], url: verifyUrl };
// Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno,
// co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy.
const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null;
+137
View File
@@ -55,6 +55,7 @@ import {
addComment,
assignTicket,
claimTicket,
createTicket,
ticketAssignee,
getAgentStats,
getTicket,
@@ -67,6 +68,7 @@ import {
import { requireAuth } from '../middleware/auth.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { helpdeskRouter } from './helpdesk.js';
import { settingsRouter } from './settings.js';
import { ticketActionsRouter } from './ticketActions.js';
import { widgetDataRouter } from './widgetData.js';
@@ -546,6 +548,109 @@ dashboardRouter.get('/tickets', (req, res) => {
});
});
/**
* Rucne zalozeny ticket.
*
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
* pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat -
* bez toho konci na papirku a v systemu neni.
*
* **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek
* od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je.
*/
const createTicketSchema = z.object({
subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'),
body: z.string().default(''),
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
typeId: z.string().trim().min(1).nullable().optional(),
assigneeId: z.string().trim().min(1).nullable().optional(),
assigneeGroupId: z.string().trim().min(1).nullable().optional(),
tags: z.array(z.string().trim().min(1)).max(20).default([]),
/** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */
customer: z
.object({
company: z.string().trim().default(''),
contact: z.string().trim().default(''),
reply: z.string().trim().default(''),
})
.optional(),
});
dashboardRouter.post('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'ticket.create')) {
return res.status(403).json({
error: 'forbidden',
message: 'Nemáte právo zakládat tickety.',
});
}
const parsed = createTicketSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.',
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
// Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari
// by znamenalo, ze se ticket omylem zalozi jinam.
const tenantId = scope.tenantId;
if (!tenantId) {
return res.status(400).json({
error: 'no_tenant',
message: 'Vyberte firmu, do které ticket patří.',
});
}
const input = parsed.data;
const customer = input.customer;
const hasCustomer =
customer !== undefined &&
(customer.company !== '' || customer.contact !== '' || customer.reply !== '');
const ticket = createTicket({
tenantId,
subject: input.subject,
body: input.body,
// Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu.
channel: 'portal',
priority: input.priority,
typeId: input.typeId ?? null,
assigneeId: input.assigneeId ?? null,
assigneeGroupId: input.assigneeGroupId ?? null,
tags: input.tags,
...(hasCustomer
? { customer: { id: null, company: customer.company, contact: customer.contact, reply: customer.reply } }
: {}),
trace: [
{
kind: 'note',
label: 'Založeno ručně',
status: 'info',
response: `Ticket založil ${req.user!.email} v portálu.`,
},
],
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'ticket.create',
target: ticket.id,
detail: { subject: ticket.subject },
});
return res.status(201).json(ticket);
});
/**
* Prevzeti ticketu.
*
@@ -615,6 +720,28 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => {
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
/**
* Stavy, ktere firma opravdu pouziva.
*
* Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace
* s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel.
* Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota
* projde stejne dobre.
*
* Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo
* jako ID ticketu.
*/
dashboardRouter.get('/tickets/statuses', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const items = [
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
].sort((a, b) => a.localeCompare(b, 'cs'));
return res.json({ items });
});
dashboardRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
@@ -749,6 +876,10 @@ dashboardRouter.use('/widget-data', widgetDataRouter);
// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`.
dashboardRouter.use('/tickets', ticketActionsRouter);
// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se
// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu.
dashboardRouter.use('/helpdesk', helpdeskRouter);
// ------------------------------------------------------------------- sluzby
/**
@@ -779,6 +910,12 @@ dashboardRouter.get('/services', (req, res) => {
id: script.id,
name: script.name,
})),
// Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel
// ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela.
ticketTypes: listTicketTypes(tenantIds).map((type) => ({
id: type.id,
name: type.name,
})),
},
),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
+226
View File
@@ -0,0 +1,226 @@
/**
* Helpdesk: pohled **zadavatele**, ne resitele.
*
* Firma tu vidi pozadavky, ktere sama poslala svemu dodavateli, a smi k nim
* pripsat komentar. Nic vic - stav, resitele ani prioritu urcuje ten, kdo to
* resi.
*
* Rozdeleni, na kterem to cele stoji:
*
* | Pole | Kdo to je |
* | ------------------------ | ------------------------------------------ |
* | `Ticket.tenantId` | firma, ktera pozadavek **resi**, tedy vlastnik |
* | `Ticket.helpdeskSourceId`| firma, ktera pozadavek **poslala** |
*
* Vlastnikem je zamerne dodavatel: kdyby jim byl zadavatel, mel by resitel
* pozadavek jen jako cizi ticket a nemel by ho ve sve fronte. Diky tomuhle
* rozdeleni funguje strana resitele beze zmeny - je to obycejny ticket v jeho
* seznamu.
*
* Kdo z firmy helpdesk vidi, urcuje pravo `helpdesk.view`, ktere prideluje
* admin te firmy. Komu pozadavek pripadne, urcuje `helpdeskProviderId` na
* firme, ktery nastavujeme my - kdo koho obsluhuje je obchodni vztah, ne volba
* klienta.
*/
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } 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';
export const helpdeskRouter = Router();
/**
* Firma, za kterou clovek prave jedna, plus kontrola prava.
*
* Helpdesk je vzdy za **jednu** firmu. Pohled pres vsechny firmy tu nedava
* smysl: pozadavek se posila konkretnimu dodavateli a ten se odviji od toho,
* kdo se pta.
*/
function asking(req: Request, res: Response, permission: string): string | null {
if (!hasPermission(req.user!, permission)) {
res.status(403).json({
error: 'forbidden',
message:
permission === 'helpdesk.create'
? 'Nemáte právo posílat požadavky na helpdesk.'
: 'Nemáte přístup k helpdesku.',
});
return null;
}
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const access = accessFor(req.user!, requested);
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId || !access.tenants.some((tenant) => tenant.id === tenantId)) {
res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, za kterou by šlo požadavek poslat.',
});
return null;
}
return tenantId;
}
/** Komu firma posila pozadavky. Vraci null a duvod uz odpovi. */
function providerFor(tenantId: string, res: Response): string | null {
const tenant = findTenant(tenantId);
const provider = tenant?.helpdeskProviderId ?? null;
if (!provider) {
res.status(400).json({
error: 'no_provider',
message:
'Vaše firma nemá nastaveného dodavatele helpdesku, takže není komu ' +
'požadavek poslat. Ozvěte se nám a doplníme to.',
});
return null;
}
if (!findTenant(provider)) {
res.status(400).json({
error: 'unknown_provider',
message: 'Nastavený dodavatel helpdesku už neexistuje.',
});
return null;
}
return provider;
}
/**
* Pozadavky teto firmy.
*
* Filtruje se podle `helpdeskSourceIds`, ne podle vlastnika - zadavatel
* vlastnikem neni. Bezny seznam ticketu tim zustava nedotceny.
*/
helpdeskRouter.get('/', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
const tenant = findTenant(tenantId);
const provider = tenant?.helpdeskProviderId ?? null;
return res.json({
items: listTickets({ tenantIds: [], helpdeskSourceIds: [tenantId] }),
tenantId,
/** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */
provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null,
canCreate: hasPermission(req.user!, 'helpdesk.create'),
});
});
const createSchema = z.object({
subject: z.string().trim().min(1, 'Napište, čeho se požadavek týká.'),
body: z.string().default(''),
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
});
/**
* Novy pozadavek.
*
* Vlastnika **nevybira uzivatel**, dosadi se dodavatel jeho firmy. Kdyby si ho
* vybiral sam, poslal by pozadavek nekomu, kdo s nim nema smlouvu.
*/
helpdeskRouter.post('/', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.create');
if (!tenantId) return;
const provider = providerFor(tenantId, res);
if (!provider) return;
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.',
});
}
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.
tenantId: provider,
helpdeskSourceId: tenantId,
subject: parsed.data.subject,
body: parsed.data.body,
channel: 'portal',
priority: parsed.data.priority,
// Zakaznik se vyplni sam: u helpdesku je znamy a je to prave ten, kdo se pta.
customer: {
id: null,
company: tenant?.name ?? '',
contact: person?.name ?? req.user!.email,
reply: req.user!.email,
},
trace: [
{
kind: 'note',
label: 'Požadavek z helpdesku',
status: 'info',
response: `Poslal ${req.user!.email} za firmu ${tenant?.name ?? tenantId}.`,
},
],
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'helpdesk.create',
target: ticket.id,
detail: { subject: ticket.subject, provider },
});
return res.status(201).json(ticket);
});
/** Detail vlastniho pozadavku. Cizi se tvari jako neexistujici. */
helpdeskRouter.get('/:id', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
// Prazdne `tenantIds`: pres helpdesk se ctou jen pozadavky, ktere firma
// poslala. Vlastni tickety ma v zalozce Tickety.
const ticket = getTicket(req.params.id, [], [tenantId]);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
}
return res.json(ticket);
});
const commentSchema = z.object({ text: z.string().trim().min(1, 'Komentář nesmí být prázdný.') });
/**
* Komentar zadavatele.
*
* Jedina zmena, kterou nad pozadavkem smi. Doplnit, co zapomnel napsat, je
* presne to, kvuli cemu se pozadavek otevira; stav a resitele urcuje ten,
* kdo to resi.
*/
helpdeskRouter.post('/:id/comment', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.',
});
}
const person = findPersonByEmail(req.user!.email);
const author = person?.name ?? req.user!.email;
const updated = addComment(req.params.id, author, parsed.data.text, [], [tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
}
return res.json(updated);
});
+4
View File
@@ -103,6 +103,8 @@ settingsRouter.use(
name: z.string().trim().min(2).max(80).optional(),
note: z.string().trim().max(500).optional(),
enabled: z.boolean().optional(),
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
}),
writePermission: 'tenant.manage',
platformOnly: true,
@@ -115,6 +117,8 @@ settingsRouter.use(
// Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval
// bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse.
intakeToken: generateIntakeToken(),
// Dodavatele helpdesku doplni spravce az pri nastaveni vztahu.
helpdeskProviderId: null,
}),
validate: (tenant, all) =>
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())