From fbe5b8ff6fec9576de09ba5b018812de7bbc5775 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:38:20 +0200 Subject: [PATCH] Vyrizeno je vyslovny priznak, ne hadani ze stavu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Ticket.closed` se nastavuje vyslovne. Predchozi verze ho odvozovala ze jmena stavu (completed, vyreseno, ...), coz nikdo nechtel a hlavne to uhodne spatne pokazde, kdyz si nekdo pojmenuje stavy po svem. `TicketType.closedStatuses` zruseno, byla to tatáz obchazka o uroven vys. Kroky `ticket/upsert` a `ticket/set-status` maji vstup Vyrizeny s trema stavy: ano, ne, prazdne. Prazdne znamena nemenit - jinak by kazda zmena textu stavu mimochodem otevrela vyrizeny ticket. `POST /tickets/:id/status` prijima `closed` jako nepovinny bool a podminka ve strome se na nej muze zeptat. Overeno 8 kontrolami: ticket se stavem completed neni automaticky vyrizeny, dokud to nekdo nerekne. Automatizace s podminkou status = completed zabere na ticketu, ktery do toho stavu prejde, ale na uz existujici tickety nesahne - spousti ji udalost, ne stav. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/99-zmeny.md | 22 +++++++++++ src/data/services.ts | 37 ++++++++++++++----- src/data/ticketStore.ts | 73 +++++++++++++------------------------ src/data/ticketTypes.ts | 8 +--- src/routes/dashboard.ts | 15 ++++---- src/runtime/builtinSteps.ts | 45 +++++++++++++++++------ src/runtime/triggers.ts | 2 + 7 files changed, 118 insertions(+), 84 deletions(-) diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 6881d1b..569f944 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,28 @@ Nejnovejsi nahore. +## 2026-08-13 - vyrizeno je vyslovny priznak, ne hadani ze stavu + +### Zmeneno + +- **`Ticket.closed` se nastavuje vyslovne.** Predchozi verze ho odvozovala ze + jmena stavu (`completed`, `vyreseno`, ...), coz nikdo nechtel a hlavne to + uhodne spatne pokazde, kdyz si nekdo pojmenuje stavy po svem. +- `TicketType.closedStatuses` zruseno. Byla to tatáz obchazka o uroven vys. +- Kroky `ticket/upsert` a `ticket/set-status` maji vstup **Vyrizeny** + (ano/ne/prazdne). Prazdne znamena **nemenit** - jinak by kazda zmena textu + stavu mimochodem otevrela vyrizeny ticket. +- `POST /tickets/:id/status` prijima `closed` jako nepovinny bool. +- Podminka se muze zeptat na `closed`, takze jde napsat "kdyz je vyrizeny". + +### Overeno + +8 kontrol: ticket se stavem `completed` **neni** automaticky vyrizeny, dokud +to nekdo nerekne. Automatizace s podminkou `status = completed` zabere na +ticketu, ktery do toho stavu prejde, prida stitek a nastavi vyrizeno. Na uz +existujici tickety nesahne, dokud se s nimi neco nestane - spousti ji udalost, +ne stav. + ## 2026-08-13 - stav ticketu je volny retezec, ciselnik pryc ### Zmeneno diff --git a/src/data/services.ts b/src/data/services.ts index b9f065e..22e43be 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -452,7 +452,7 @@ export const services: Service[] = [ { id: 'ticket.status', name: 'status', type: 'string', required: true }, { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, { id: 'ticket.typeId', name: 'typeId', type: 'string', required: false }, - { id: 'ticket.stage', name: 'stage', type: 'string', required: false }, + { id: 'ticket.closed', name: 'closed', type: 'boolean', required: true }, { id: 'ticket.tags', name: 'tags', type: 'list', required: false }, { id: 'ticket.assigneeId', name: 'assigneeId', type: 'string', required: false }, { id: 'ticket.company', name: 'company', type: 'string', required: false }, @@ -521,9 +521,18 @@ export const services: Service[] = [ label: 'Stav', kind: 'text', required: false, - hint: - 'Nový, v řešení, čeká nebo vyřešeno. Jinou hodnotu (například stav hovoru ' + - 'od voicebota) uložíme jako fázi, protože to přesně je.', + hint: 'Cokoliv chcete, například ringing nebo Připraveno k expedici.', + }, + { + id: 'closed', + label: 'Vyřízený', + kind: 'choice', + required: false, + options: [ + { value: 'true', label: 'Ano' }, + { value: 'false', label: 'Ne' }, + ], + hint: 'Prázdné = nechat, jak je. Podle tohohle se počítá fronta a statistiky.', }, { id: 'stage', @@ -702,20 +711,28 @@ export const services: Service[] = [ { id: 'set-status', name: 'Změnit stav ticketu', - description: 'Například vyřešeno, když automatizace dokončila, co měla.', + description: + 'Stav je libovolný text, žádný číselník. Jestli je ticket vyřízený, ' + + 'říká samostatné pole - podle něj se počítá fronta a statistiky.', implementation: 'script', inputs: [ { id: 'status', label: 'Stav', - kind: 'choice', + kind: 'text', required: true, + hint: 'Cokoliv chcete, například completed nebo Předáno dopravci.', + }, + { + id: 'closed', + label: 'Vyřízený', + kind: 'choice', + required: false, options: [ - { value: 'new', label: 'Nový' }, - { value: 'open', label: 'V řešení' }, - { value: 'waiting', label: 'Čeká na klienta' }, - { value: 'resolved', label: 'Vyřešeno' }, + { value: 'true', label: 'Ano' }, + { value: 'false', label: 'Ne' }, ], + hint: 'Prázdné = nechat, jak je.', }, { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, ], diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 40bd0cb..6dca92a 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -37,33 +37,8 @@ import { withMirror } from './store/mirror.js'; */ export type TicketStatus = string; -/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni workflow. */ +/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni. Jen nabidka. */ export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const; - -/** - * Stavy, ktere znamenaji hotovo. - * - * Fronta i statistiky potrebuji vedet, co uz nikdo neresi. Kdyz je stav volny - * retezec, nejde to poznat z nej samotneho - proto se to drzi jako priznak - * `closed` na ticketu a tenhle seznam rika, ze se ma nastavit sam. - * - * Anglicke varianty jsou tu kvuli cizim aplikacim: voicebot posle `completed` - * a nikdo by nemel muset rucne rikat, ze to znamena hotovo. - */ -const CLOSING = new Set( - ['vyřešeno', 'hotovo', 'uzavřeno', 'resolved', 'closed', 'done', 'completed'].map((value) => - value.toLowerCase(), - ), -); - -/** Znamena tenhle stav, ze uz to nikdo neresi? */ -export function isClosingStatus(status: string, closedStatuses?: string[]): boolean { - const value = status.trim().toLowerCase(); - if (closedStatuses && closedStatuses.length > 0) { - return closedStatuses.some((item) => item.trim().toLowerCase() === value); - } - return CLOSING.has(value); -} export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; /** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */ @@ -180,14 +155,13 @@ export interface Ticket { /** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */ fields: Record; /** - * Uz to nikdo neresi? + * Je vyrizeny? * - * Kdyz je stav volny retezec, nejde z nej poznat, jestli je hotovo - `ringing` - * a `completed` jsou pro nas jen texty. Fronta, vytizeni i statistiky se - * proto ptaji na tenhle priznak, ne na stav. + * **Nastavuje se vyslovne**, neodvozuje se ze stavu. Stav je volny retezec + * a hadat z nej, jestli `completed` nebo `hotovo` znamena konec, by znamenalo + * uhodnout spatne pokazde, kdyz si nekdo pojmenuje stavy po svem. * - * Nastavuje se sam podle stavu (viz `isClosingStatus`), takze bezny uzivatel - * o nem nemusi vedet. + * Fronta, vytizeni i statistiky se ptaji na tenhle priznak. */ closed: boolean; /** @@ -853,8 +827,7 @@ function toTicket(stored: StoredTicket): Ticket { typeId: stored.typeId ?? null, fields: stored.fields ?? {}, tags: stored.tags ?? [], - // Starsi zaznamy priznak nemaji, dopocita se ze stavu. - closed: stored.closed ?? isClosingStatus(stored.status), + closed: stored.closed ?? false, externalId: stored.externalId ?? null, externalSource: stored.externalSource ?? null, firstResponseAt: stored.firstResponseAt ?? null, @@ -947,7 +920,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef /** Prvni nevyrizeny ticket. */ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { const stored = tickets.find( - (t) => !(t.closed ?? isClosingStatus(t.status)) && tenantIds.includes(t.tenantId), + (t) => !t.closed && tenantIds.includes(t.tenantId), ); return stored ? toTicket(stored) : undefined; } @@ -980,7 +953,7 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { const rows = everyone.map((person) => { const mine = visible.filter((t) => t.assigneeId === person.id); - const open = mine.filter((t) => !(t.closed ?? isClosingStatus(t.status))); + const open = mine.filter((t) => !t.closed); const oldest = open.reduce( (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), null, @@ -1002,9 +975,9 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { return { rows, unassigned: visible.filter( - (t) => t.assigneeId === null && !(t.closed ?? isClosingStatus(t.status)), + (t) => t.assigneeId === null && !t.closed, ).length, - openTotal: visible.filter((t) => !(t.closed ?? isClosingStatus(t.status))).length, + openTotal: visible.filter((t) => !t.closed).length, }; } @@ -1210,7 +1183,7 @@ export function getAgentStats( const rows = everyone.map((person) => { const open = visible.filter( - (t) => t.assigneeId === person.id && !(t.closed ?? isClosingStatus(t.status)), + (t) => t.assigneeId === person.id && !t.closed, ); /** @@ -1278,6 +1251,8 @@ export interface CreateTicketInput { priority: TicketPriority; /** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */ status?: string; + /** Je uz vyrizeny? Vychozi ne. */ + closed?: boolean; assigneeId?: string | null; assigneeGroupId?: string | null; typeId?: string | null; @@ -1323,7 +1298,7 @@ export function createTicket(input: CreateTicketInput): Ticket { typeId: input.typeId ?? null, fields: input.fields ?? {}, tags: input.tags ?? [], - closed: isClosingStatus(input.status ?? defaultStatuses[0]), + closed: input.closed ?? false, automationId: input.automationId ?? null, createdAt: now, updatedAt: now, @@ -1356,17 +1331,18 @@ function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined } /** - * Zmeni stav. + * Zmeni stav, pripadne i priznak vyrizeni. * - * Stav je **volny retezec**, takze se neoveruje proti ciselniku. Kdyz typ - * ticketu ma vlastni workflow, `closedStatuses` rekne, ktere z nich znamenaji - * hotovo - jinak se to pozna podle bezneho pojmenovani (`vyreseno`, `completed`). + * Stav je **volny retezec** a neoveruje se proti nicemu. Jestli je ticket + * vyrizeny, rika `closed` - vyslovne, ne odvozene ze jmena stavu. Kdyz se + * nepreda, priznak zustava, jak byl: zmena textu stavu sama o sobe neznamena, + * ze je hotovo. */ export function updateTicketStatus( id: string, status: TicketStatus, tenantIds: string[], - closedStatuses?: string[], + closed?: boolean, ): Ticket | undefined { const ticket = findWritable(id, tenantIds); if (!ticket) { @@ -1375,15 +1351,16 @@ export function updateTicketStatus( } const previous = ticket.status; + const wasClosed = ticket.closed ?? false; ticket.status = status; - ticket.closed = isClosingStatus(status, closedStatuses); + if (closed !== undefined) ticket.closed = closed; - if (ticket.closed) { + if (ticket.closed && !wasClosed) { ticket.resolvedAt = new Date().toISOString(); // Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane - // radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel. ticket.resolvedById = ticket.assigneeId; - } else if (isClosingStatus(previous, closedStatuses)) { + } else if (!ticket.closed && wasClosed) { // Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo. ticket.reopenCount = (ticket.reopenCount ?? 0) + 1; ticket.resolvedAt = null; diff --git a/src/data/ticketTypes.ts b/src/data/ticketTypes.ts index 07cb282..41dd705 100644 --- a/src/data/ticketTypes.ts +++ b/src/data/ticketTypes.ts @@ -44,13 +44,7 @@ export interface TicketType extends TenantEntity { * i stav, ktery poslala cizi aplikace a v seznamu neni. */ statuses: string[]; - /** - * Ktere z tech stavu znamenaji, ze uz to nikdo neresi. - * - * Fronta a statistiky se ptaji na tohle, ne na text stavu. Prazdne = pozna - * se to podle bezneho pojmenovani (`vyreseno`, `hotovo`, `completed`). - */ - closedStatuses?: string[]; + fields: TicketTypeField[]; } diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index aa0681d..71db5a4 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -48,11 +48,10 @@ import { listNotifications, markRead, unreadCount } from '../data/notifications. import { queueStats, recentRuns } from '../runtime/queue.js'; import { recordAudit } from '../data/audit.js'; import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js'; -import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; +import { listTicketTypes } from '../data/ticketTypes.js'; import { addComment, assignTicket, - findTicket, getAgentStats, getTicket, getWorkload, @@ -608,6 +607,11 @@ const statusSchema = z.object({ * nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument. */ status: z.string().trim().min(1).max(60), + /** + * Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama + * o sobe neznamena, ze je hotovo. + */ + closed: z.boolean().optional(), }); dashboardRouter.post('/tickets/:id/status', (req, res) => { @@ -616,16 +620,11 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => { return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' }); } - // Typ rika, ktere z jeho stavu znamenaji hotovo. Bez nej se to pozna - // podle bezneho pojmenovani (vyreseno, completed). - const current = findTicket(req.params.id, writableTenants(req)); - const type = current?.typeId ? findTicketType(current.typeId) : undefined; - const ticket = updateTicketStatus( req.params.id, parsed.data.status, writableTenants(req), - type?.closedStatuses, + parsed.data.closed, ); if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 20769f7..14a6273 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -11,7 +11,6 @@ import { createIncident } from '../data/incidentStore.js'; import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js'; -import { findTicketType } from '../data/ticketTypes.js'; import { assignTicket, assignTicketGroup, @@ -63,6 +62,21 @@ const channels: TicketChannel[] = [ 'portal', ]; +/** + * Zaskrtnuto, nebo ne? + * + * Ve strome se vsechno predava jako text, takze prijde "true", "1" nebo + * prazdno. Nevyplneno znamena "nemenit", ne "false" - jinak by kazdy krok, + * ktery meni stav, mimochodem otevrel vyrizeny ticket. + */ +function boolInput(value: string | undefined): boolean | undefined { + const text = value?.trim().toLowerCase(); + if (!text) return undefined; + if (['true', '1', 'ano', 'yes'].includes(text)) return true; + if (['false', '0', 'ne', 'no'].includes(text)) return false; + return undefined; +} + /** * Z JSONu nechá jen hodnoty, ktere se do vlastniho pole vejdou. * @@ -147,17 +161,23 @@ const handlers: Record = { }); /* - * Stav se nastavuje az po zalozeni, aby se dohledal typ a jeho workflow. - * Je to **volny retezec**: co posle cizi aplikace, to se ulozi. Podle - * `closedStatuses` typu se pozna, jestli to znamena hotovo. + * Stav je **volny retezec**: co posle cizi aplikace, to se ulozi. Jestli + * je ticket vyrizeny, rika `closed` - vyslovne. Bez nej se priznak nemeni, + * takze zmena textu stavu sama o sobe ticket nezavre ani neotevre. */ const wanted = inputs.status?.trim(); + const closed = boolInput(inputs.closed); let note = ''; - if (wanted) { - const type = result.ticket.typeId ? findTicketType(result.ticket.typeId) : undefined; - updateTicketStatus(result.ticket.id, wanted, [context.tenantId], type?.closedStatuses); - note = `, stav ${wanted}`; + if (wanted || closed !== undefined) { + updateTicketStatus( + result.ticket.id, + wanted || result.ticket.status, + [context.tenantId], + closed, + ); + note = wanted ? `, stav ${wanted}` : ''; + if (closed !== undefined) note += closed ? ', vyřízený' : ', nevyřízený'; } return { @@ -345,9 +365,12 @@ const handlers: Record = { const status = inputs.status?.trim(); if (!status) return missing('stav'); - const ticket = findTicket(ticketId, [context.tenantId]); - const type = ticket?.typeId ? findTicketType(ticket.typeId) : undefined; - const updated = updateTicketStatus(ticketId, status, [context.tenantId], type?.closedStatuses); + const updated = updateTicketStatus( + ticketId, + status, + [context.tenantId], + boolInput(inputs.closed), + ); if (!updated) return { ok: false, summary: 'stav se nepodařilo změnit', outputs: {} }; return { ok: true, summary: status, detail: null, outputs: { status } }; }, diff --git a/src/runtime/triggers.ts b/src/runtime/triggers.ts index 78c9427..da92c6f 100644 --- a/src/runtime/triggers.ts +++ b/src/runtime/triggers.ts @@ -166,6 +166,8 @@ export function ticketPayload(ticket: Ticket): Record { subject: ticket.subject, body: ticket.body, status: ticket.status, + /** Aby slo napsat podminku "uz je vyrizeny". */ + closed: ticket.closed, priority: ticket.priority, channel: ticket.channel, typeId: ticket.typeId,