Auto-pause internal work during lunch
This commit is contained in:
parent
c6a7e0dd0b
commit
ff41a8bd4e
4 changed files with 115 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue