From ff41a8bd4e2c1fe766641c1592ac5a93d76a19fe Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 12 Nov 2025 17:48:12 -0300 Subject: [PATCH] Auto-pause internal work during lunch --- convex/crons.ts | 7 ++ convex/tickets.ts | 94 +++++++++++++++++++ docs/RUSTDESK-PROVISIONING.md | 13 +++ .../tickets/ticket-summary-header.tsx | 1 + 4 files changed, 115 insertions(+) diff --git a/convex/crons.ts b/convex/crons.ts index 75dbb06..e90758c 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -10,4 +10,11 @@ crons.interval( {} ) +crons.daily( + "auto-pause-internal-lunch", + { hourUTC: 15, minuteUTC: 0 }, + api.tickets.pauseInternalSessionsForLunch, + {} +) + export default crons diff --git a/convex/tickets.ts b/convex/tickets.ts index a9f1b51..888fc6a 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -20,10 +20,17 @@ import { const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); +const LUNCH_BREAK_REASON = "LUNCH_BREAK"; +const LUNCH_BREAK_NOTE = "Pausa automática do intervalo de almoço"; +const LUNCH_BREAK_PAUSE_LABEL = "Intervalo de almoço"; +const LUNCH_BREAK_TIMEZONE = "America/Sao_Paulo"; +const LUNCH_BREAK_HOUR = 12; + const PAUSE_REASON_LABELS: Record = { NO_CONTACT: "Falta de contato", WAITING_THIRD_PARTY: "Aguardando terceiro", IN_PROCEDURE: "Em procedimento", + [LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL, }; const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; @@ -293,6 +300,19 @@ function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatus return {}; } +function getHourMinuteInTimezone(date: number, timeZone: string): { hour: number; minute: number } { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + const parts = formatter.formatToParts(new Date(date)) + const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0") + const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0") + return { hour, minute } +} + function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record): Doc<"tickets"> { const merged = { ...ticketDoc } as Record; for (const [key, value] of Object.entries(patch)) { @@ -305,6 +325,47 @@ function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record; } +async function pauseSessionForLunch(ctx: MutationCtx, ticket: Doc<"tickets">, session: Doc<"ticketWorkSessions">) { + const now = Date.now() + const durationMs = Math.max(0, now - session.startedAt) + const slaPatch = buildSlaStatusPatch(ticket, "PAUSED", now) + + await ctx.db.patch(session._id, { + stoppedAt: now, + durationMs, + pauseReason: LUNCH_BREAK_REASON, + pauseNote: LUNCH_BREAK_NOTE, + }) + + await ctx.db.patch(ticket._id, { + working: false, + activeSessionId: undefined, + status: "PAUSED", + totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, + internalWorkedMs: (ticket.internalWorkedMs ?? 0) + durationMs, + updatedAt: now, + ...slaPatch, + }) + + await ctx.db.insert("ticketEvents", { + ticketId: ticket._id, + type: "WORK_PAUSED", + payload: { + actorId: null, + actorName: "Sistema", + actorAvatar: null, + sessionId: session._id, + sessionDurationMs: durationMs, + workType: (session.workType ?? "INTERNAL").toUpperCase(), + pauseReason: LUNCH_BREAK_REASON, + pauseReasonLabel: PAUSE_REASON_LABELS[LUNCH_BREAK_REASON], + pauseNote: LUNCH_BREAK_NOTE, + source: "lunch-break-cron", + }, + createdAt: now, + }) +} + function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) { if (ticketDoc.firstResponseAt) { return {}; @@ -3683,6 +3744,39 @@ export const pauseWork = mutation({ }, }) +export const pauseInternalSessionsForLunch = mutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now() + const { hour } = getHourMinuteInTimezone(now, LUNCH_BREAK_TIMEZONE) + if (hour !== LUNCH_BREAK_HOUR) { + return { skipped: true, reason: "outside_lunch_window" as const } + } + + const activeSessions = await ctx.db + .query("ticketWorkSessions") + .filter((q) => q.eq(q.field("stoppedAt"), undefined)) + .collect() + + let paused = 0 + for (const sessionDoc of activeSessions) { + const session = sessionDoc as Doc<"ticketWorkSessions"> + const workType = (session.workType ?? "INTERNAL").toUpperCase() + if (workType !== "INTERNAL") continue + + const ticket = (await ctx.db.get(session.ticketId)) as Doc<"tickets"> | null + if (!ticket || ticket.activeSessionId !== session._id) { + continue + } + + await pauseSessionForLunch(ctx, ticket, session) + paused += 1 + } + + return { skipped: false, paused } + }, +}) + export const adjustWorkSummary = mutation({ args: { ticketId: v.id("tickets"), diff --git a/docs/RUSTDESK-PROVISIONING.md b/docs/RUSTDESK-PROVISIONING.md index 01e32dc..3096cf8 100644 --- a/docs/RUSTDESK-PROVISIONING.md +++ b/docs/RUSTDESK-PROVISIONING.md @@ -77,6 +77,19 @@ Fluxo ideal: } } ``` + - **Checklist pós-preparo**: todo ciclo bem-sucedido sempre termina com este bloco no `rustdesk.log` (nessa ordem): + 1. `Senha padrão definida com sucesso` + 2. `Aplicando senha nos perfis do RustDesk` + 3. Quatro linhas `Senha escrita via fallback ...` / `verification-method ...` / `approve-mode ...` cobrindo `%APPDATA%`, `ProgramData`, `LocalService`, `SystemProfile` + 4. `Senha e flags de segurança gravadas ...` seguido por `Senha confirmada em ... (FM***8P)` para cada `RustDesk.toml` + 5. Logs de propagação (`RustDesk.toml propagado para ...`, `RustDesk_local.toml propagado para ...`, `RustDesk2.toml propagado ...`) + 6. `Serviço RustDesk reiniciado/run ativo` e `remote_id atualizado para ...` + + > Observação: as mensagens `Aviso: chave 'password' não encontrada em ...RustDesk_local.toml` são esperadas — esse arquivo nunca armazena a senha, apenas `verification-method`/`approve-mode`. + - **Quando algo der errado**: + - Se o log parar em `Senha padrão definida...` e nada mais aparecer, algum erro interrompeu o módulo antes de gravar os arquivos. Rode o Raven com DevTools (`Ctrl+Shift`) e capture o stack trace. + - Se aparecer “senha divergente” para algum diretório, execute o script PowerShell acima para confirmar e, se necessário, rode “Preparar” novamente (ele sempre derruba `rustdesk.exe`, limpa os perfis e reaplica o PIN). + - Se `Serviço RustDesk reiniciado...` não surgir, pare/inicie manualmente (`Stop-Service RustDesk; Start-Service RustDesk`) após verificar que os arquivos já trazem a senha nova. - **Reforço contínuo**: toda nova execução do botão “Preparar” repete o ciclo (kill → copiar → gravar flags → `sc start`). Se alguém voltar manualmente para “Use both”, o Raven mata o processo, reescreve os TOML e reinicia o serviço — o RustDesk volta travado na senha permanente com o PIN registrado nos logs (`rustdesk.log`). - **Sincronização**: `syncRustdeskAccess(machineToken, info)` chama `/api/machines/remote-access`. Há retries automáticos: ```ts diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index e54065f..9cf6e6c 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -99,6 +99,7 @@ const PAUSE_REASONS = [ { value: "NO_CONTACT", label: "Falta de contato" }, { value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" }, { value: "IN_PROCEDURE", label: "Em procedimento" }, + { value: "LUNCH_BREAK", label: "Intervalo de almoço" }, ] type CustomerOption = {