Auto-pause internal work during lunch

This commit is contained in:
Esdras Renan 2025-11-12 17:48:12 -03:00
parent c6a7e0dd0b
commit ff41a8bd4e
4 changed files with 115 additions and 0 deletions

View file

@ -10,4 +10,11 @@ crons.interval(
{} {}
) )
crons.daily(
"auto-pause-internal-lunch",
{ hourUTC: 15, minuteUTC: 0 },
api.tickets.pauseInternalSessionsForLunch,
{}
)
export default crons export default crons

View file

@ -20,10 +20,17 @@ import {
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "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<string, string> = { const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato", NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro", WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento", IN_PROCEDURE: "Em procedimento",
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
}; };
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
@ -293,6 +300,19 @@ function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatus
return {}; 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<string, unknown>): Doc<"tickets"> { function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
const merged = { ...ticketDoc } as Record<string, unknown>; const merged = { ...ticketDoc } as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) { for (const [key, value] of Object.entries(patch)) {
@ -305,6 +325,47 @@ function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unkno
return merged as Doc<"tickets">; return merged as Doc<"tickets">;
} }
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) { function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
if (ticketDoc.firstResponseAt) { if (ticketDoc.firstResponseAt) {
return {}; 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({ export const adjustWorkSummary = mutation({
args: { args: {
ticketId: v.id("tickets"), ticketId: v.id("tickets"),

View file

@ -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`). - **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: - **Sincronização**: `syncRustdeskAccess(machineToken, info)` chama `/api/machines/remote-access`. Há retries automáticos:
```ts ```ts

View file

@ -99,6 +99,7 @@ const PAUSE_REASONS = [
{ value: "NO_CONTACT", label: "Falta de contato" }, { value: "NO_CONTACT", label: "Falta de contato" },
{ value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" }, { value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" },
{ value: "IN_PROCEDURE", label: "Em procedimento" }, { value: "IN_PROCEDURE", label: "Em procedimento" },
{ value: "LUNCH_BREAK", label: "Intervalo de almoço" },
] ]
type CustomerOption = { type CustomerOption = {