feat: agenda polish, SLA sync, filters
This commit is contained in:
parent
7fb6c65d9a
commit
6ab8a6ce89
40 changed files with 2771 additions and 154 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type * as alerts from "../alerts.js";
|
|||
import type * as alerts_actions from "../alerts_actions.js";
|
||||
import type * as bootstrap from "../bootstrap.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as categorySlas from "../categorySlas.js";
|
||||
import type * as commentTemplates from "../commentTemplates.js";
|
||||
import type * as companies from "../companies.js";
|
||||
import type * as crons from "../crons.js";
|
||||
|
|
@ -58,6 +59,7 @@ declare const fullApi: ApiFromModules<{
|
|||
alerts_actions: typeof alerts_actions;
|
||||
bootstrap: typeof bootstrap;
|
||||
categories: typeof categories;
|
||||
categorySlas: typeof categorySlas;
|
||||
commentTemplates: typeof commentTemplates;
|
||||
companies: typeof companies;
|
||||
crons: typeof crons;
|
||||
|
|
|
|||
169
convex/categorySlas.ts
Normal file
169
convex/categorySlas.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
|
||||
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
|
||||
const VALID_TIME_MODES = ["business", "calendar"] as const
|
||||
|
||||
type CategorySlaRuleInput = {
|
||||
priority: string
|
||||
responseTargetMinutes?: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes?: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
calendarType?: string | null
|
||||
}
|
||||
|
||||
const ruleInput = v.object({
|
||||
priority: v.string(),
|
||||
responseTargetMinutes: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
solutionTargetMinutes: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
calendarType: v.optional(v.string()),
|
||||
})
|
||||
|
||||
function normalizePriority(value: string) {
|
||||
const upper = value.trim().toUpperCase()
|
||||
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
|
||||
}
|
||||
|
||||
function sanitizeTime(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function normalizeMode(value?: string | null) {
|
||||
if (!value) return "calendar"
|
||||
const normalized = value.toLowerCase()
|
||||
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
|
||||
}
|
||||
|
||||
function normalizeThreshold(value?: number | null) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return 0.8
|
||||
}
|
||||
const clamped = Math.min(Math.max(value, 0.1), 0.95)
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
function normalizePauseStatuses(value?: string[] | null) {
|
||||
if (!Array.isArray(value)) return ["PAUSED"]
|
||||
const normalized = new Set<string>()
|
||||
for (const status of value) {
|
||||
if (typeof status !== "string") continue
|
||||
const upper = status.trim().toUpperCase()
|
||||
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
|
||||
normalized.add(upper)
|
||||
}
|
||||
}
|
||||
if (normalized.size === 0) {
|
||||
normalized.add("PAUSED")
|
||||
}
|
||||
return Array.from(normalized)
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, categoryId }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
}
|
||||
const records = await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
|
||||
.collect()
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
categoryName: category.name,
|
||||
rules: records.map((record) => ({
|
||||
priority: record.priority,
|
||||
responseTargetMinutes: record.responseTargetMinutes ?? null,
|
||||
responseMode: record.responseMode ?? "calendar",
|
||||
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
|
||||
solutionMode: record.solutionMode ?? "calendar",
|
||||
alertThreshold: record.alertThreshold ?? 0.8,
|
||||
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const save = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
rules: v.array(ruleInput),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, categoryId, rules }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
}
|
||||
const sanitized = sanitizeRules(rules)
|
||||
const existing = await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
|
||||
.collect()
|
||||
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
|
||||
|
||||
const now = Date.now()
|
||||
for (const rule of sanitized) {
|
||||
await ctx.db.insert("categorySlaSettings", {
|
||||
tenantId,
|
||||
categoryId,
|
||||
priority: rule.priority,
|
||||
responseTargetMinutes: rule.responseTargetMinutes,
|
||||
responseMode: rule.responseMode,
|
||||
solutionTargetMinutes: rule.solutionTargetMinutes,
|
||||
solutionMode: rule.solutionMode,
|
||||
alertThreshold: rule.alertThreshold,
|
||||
pauseStatuses: rule.pauseStatuses,
|
||||
calendarType: rule.calendarType ?? undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
actorId,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function sanitizeRules(rules: CategorySlaRuleInput[]) {
|
||||
const normalized: Record<string, ReturnType<typeof buildRule>> = {}
|
||||
for (const rule of rules) {
|
||||
const built = buildRule(rule)
|
||||
normalized[built.priority] = built
|
||||
}
|
||||
return Object.values(normalized)
|
||||
}
|
||||
|
||||
function buildRule(rule: CategorySlaRuleInput) {
|
||||
const priority = normalizePriority(rule.priority)
|
||||
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
|
||||
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
|
||||
return {
|
||||
priority,
|
||||
responseTargetMinutes,
|
||||
responseMode: normalizeMode(rule.responseMode),
|
||||
solutionTargetMinutes,
|
||||
solutionMode: normalizeMode(rule.solutionMode),
|
||||
alertThreshold: normalizeThreshold(rule.alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
|
||||
calendarType: rule.calendarType ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,67 @@ function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
|
|||
return input
|
||||
}
|
||||
|
||||
type TicketSlaSnapshotRecord = {
|
||||
categoryId?: Id<"ticketCategories">
|
||||
categoryName?: string
|
||||
priority?: string
|
||||
responseTargetMinutes?: number
|
||||
responseMode?: string
|
||||
solutionTargetMinutes?: number
|
||||
solutionMode?: string
|
||||
alertThreshold?: number
|
||||
pauseStatuses?: string[]
|
||||
}
|
||||
|
||||
type ExportedSlaSnapshot = {
|
||||
categoryId?: string
|
||||
categoryName?: string
|
||||
priority?: string
|
||||
responseTargetMinutes?: number
|
||||
responseMode?: string
|
||||
solutionTargetMinutes?: number
|
||||
solutionMode?: string
|
||||
alertThreshold?: number
|
||||
pauseStatuses?: string[]
|
||||
}
|
||||
|
||||
function serializeSlaSnapshot(snapshot?: TicketSlaSnapshotRecord | null): ExportedSlaSnapshot | undefined {
|
||||
if (!snapshot) return undefined
|
||||
const exported = pruneUndefined<ExportedSlaSnapshot>({
|
||||
categoryId: snapshot.categoryId ? String(snapshot.categoryId) : undefined,
|
||||
categoryName: snapshot.categoryName,
|
||||
priority: snapshot.priority,
|
||||
responseTargetMinutes: snapshot.responseTargetMinutes,
|
||||
responseMode: snapshot.responseMode,
|
||||
solutionTargetMinutes: snapshot.solutionTargetMinutes,
|
||||
solutionMode: snapshot.solutionMode,
|
||||
alertThreshold: snapshot.alertThreshold,
|
||||
pauseStatuses: snapshot.pauseStatuses && snapshot.pauseStatuses.length > 0 ? snapshot.pauseStatuses : undefined,
|
||||
})
|
||||
return Object.keys(exported).length > 0 ? exported : undefined
|
||||
}
|
||||
|
||||
function normalizeImportedSlaSnapshot(snapshot: unknown): TicketSlaSnapshotRecord | undefined {
|
||||
if (!snapshot || typeof snapshot !== "object") return undefined
|
||||
const record = snapshot as Record<string, unknown>
|
||||
const pauseStatuses = Array.isArray(record.pauseStatuses)
|
||||
? record.pauseStatuses.filter((value): value is string => typeof value === "string")
|
||||
: undefined
|
||||
|
||||
const normalized = pruneUndefined<TicketSlaSnapshotRecord>({
|
||||
categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined,
|
||||
priority: typeof record.priority === "string" ? record.priority : undefined,
|
||||
responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined,
|
||||
responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined,
|
||||
solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined,
|
||||
solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined,
|
||||
alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined,
|
||||
pauseStatuses: pauseStatuses && pauseStatuses.length > 0 ? pauseStatuses : undefined,
|
||||
})
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
async function ensureUser(
|
||||
ctx: MutationCtx,
|
||||
tenantId: string,
|
||||
|
|
@ -333,6 +394,14 @@ export const exportTenantSnapshot = query({
|
|||
createdAt: ticket.createdAt,
|
||||
updatedAt: ticket.updatedAt,
|
||||
tags: ticket.tags ?? [],
|
||||
slaSnapshot: serializeSlaSnapshot(ticket.slaSnapshot as TicketSlaSnapshotRecord | null),
|
||||
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
|
||||
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
|
||||
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
|
||||
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
|
||||
slaPausedAt: ticket.slaPausedAt ?? undefined,
|
||||
slaPausedBy: ticket.slaPausedBy ?? undefined,
|
||||
slaPausedMs: ticket.slaPausedMs ?? undefined,
|
||||
comments: comments
|
||||
.map((comment) => {
|
||||
const author = userMap.get(comment.authorId)
|
||||
|
|
@ -446,6 +515,14 @@ export const importPrismaSnapshot = mutation({
|
|||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
slaSnapshot: v.optional(v.any()),
|
||||
slaResponseDueAt: v.optional(v.number()),
|
||||
slaSolutionDueAt: v.optional(v.number()),
|
||||
slaResponseStatus: v.optional(v.string()),
|
||||
slaSolutionStatus: v.optional(v.string()),
|
||||
slaPausedAt: v.optional(v.number()),
|
||||
slaPausedBy: v.optional(v.string()),
|
||||
slaPausedMs: v.optional(v.number()),
|
||||
comments: v.array(
|
||||
v.object({
|
||||
authorEmail: v.string(),
|
||||
|
|
@ -513,6 +590,9 @@ export const importPrismaSnapshot = mutation({
|
|||
let eventsInserted = 0
|
||||
|
||||
for (const ticket of snapshot.tickets) {
|
||||
const normalizedSnapshot = normalizeImportedSlaSnapshot(ticket.slaSnapshot)
|
||||
const slaPausedMs = typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined
|
||||
|
||||
const requesterId = await ensureUser(
|
||||
ctx,
|
||||
snapshot.tenantId,
|
||||
|
|
@ -567,6 +647,14 @@ export const importPrismaSnapshot = mutation({
|
|||
updatedAt: ticket.updatedAt,
|
||||
createdAt: ticket.createdAt,
|
||||
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
|
||||
slaSnapshot: normalizedSnapshot,
|
||||
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
|
||||
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
|
||||
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
|
||||
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
|
||||
slaPausedAt: ticket.slaPausedAt ?? undefined,
|
||||
slaPausedBy: ticket.slaPausedBy ?? undefined,
|
||||
slaPausedMs,
|
||||
customFields: undefined,
|
||||
totalWorkedMs: undefined,
|
||||
activeSessionId: undefined,
|
||||
|
|
|
|||
|
|
@ -250,6 +250,26 @@ export default defineSchema({
|
|||
),
|
||||
working: v.optional(v.boolean()),
|
||||
slaPolicyId: v.optional(v.id("slaPolicies")),
|
||||
slaSnapshot: v.optional(
|
||||
v.object({
|
||||
categoryId: v.optional(v.id("ticketCategories")),
|
||||
categoryName: v.optional(v.string()),
|
||||
priority: v.optional(v.string()),
|
||||
responseTargetMinutes: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
solutionTargetMinutes: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
slaResponseDueAt: v.optional(v.number()),
|
||||
slaSolutionDueAt: v.optional(v.number()),
|
||||
slaResponseStatus: v.optional(v.string()),
|
||||
slaSolutionStatus: v.optional(v.string()),
|
||||
slaPausedAt: v.optional(v.number()),
|
||||
slaPausedBy: v.optional(v.string()),
|
||||
slaPausedMs: v.optional(v.number()),
|
||||
dueAt: v.optional(v.number()), // ms since epoch
|
||||
firstResponseAt: v.optional(v.number()),
|
||||
resolvedAt: v.optional(v.number()),
|
||||
|
|
@ -437,6 +457,24 @@ export default defineSchema({
|
|||
.index("by_category_slug", ["categoryId", "slug"])
|
||||
.index("by_tenant_slug", ["tenantId", "slug"]),
|
||||
|
||||
categorySlaSettings: defineTable({
|
||||
tenantId: v.string(),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
priority: v.string(),
|
||||
responseTargetMinutes: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
solutionTargetMinutes: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
calendarType: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
|
||||
.index("by_tenant_category", ["tenantId", "categoryId"]),
|
||||
|
||||
ticketFields: defineTable({
|
||||
tenantId: v.string(),
|
||||
key: v.string(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,19 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|||
CLOSED: "RESOLVED",
|
||||
};
|
||||
|
||||
function normalizePriorityFilter(input: string | string[] | null | undefined): string[] {
|
||||
if (!input) return [];
|
||||
const list = Array.isArray(input) ? input : [input];
|
||||
const set = new Set<string>();
|
||||
for (const entry of list) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const normalized = entry.trim().toUpperCase();
|
||||
if (!normalized) continue;
|
||||
set.add(normalized);
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
const missingRequesterLogCache = new Set<string>();
|
||||
const missingCommentAuthorLogCache = new Set<string>();
|
||||
|
||||
|
|
@ -80,6 +93,249 @@ function plainTextLength(html: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
const SLA_DEFAULT_ALERT_THRESHOLD = 0.8;
|
||||
const BUSINESS_DAY_START_HOUR = 8;
|
||||
const BUSINESS_DAY_END_HOUR = 18;
|
||||
|
||||
type SlaTimeMode = "business" | "calendar";
|
||||
|
||||
type TicketSlaSnapshot = {
|
||||
categoryId?: Id<"ticketCategories">;
|
||||
categoryName?: string;
|
||||
priority: string;
|
||||
responseTargetMinutes?: number;
|
||||
responseMode: SlaTimeMode;
|
||||
solutionTargetMinutes?: number;
|
||||
solutionMode: SlaTimeMode;
|
||||
alertThreshold: number;
|
||||
pauseStatuses: TicketStatusNormalized[];
|
||||
};
|
||||
|
||||
type SlaStatusValue = "pending" | "met" | "breached" | "n/a";
|
||||
|
||||
function normalizeSlaMode(input?: string | null): SlaTimeMode {
|
||||
if (!input) return "calendar";
|
||||
return input.toLowerCase() === "business" ? "business" : "calendar";
|
||||
}
|
||||
|
||||
function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] {
|
||||
if (!Array.isArray(statuses)) {
|
||||
return ["PAUSED"];
|
||||
}
|
||||
const set = new Set<TicketStatusNormalized>();
|
||||
for (const value of statuses) {
|
||||
if (typeof value !== "string") continue;
|
||||
const normalized = normalizeStatus(value);
|
||||
set.add(normalized);
|
||||
}
|
||||
if (set.size === 0) {
|
||||
set.add("PAUSED");
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
async function resolveTicketSlaSnapshot(
|
||||
ctx: AnyCtx,
|
||||
tenantId: string,
|
||||
category: Doc<"ticketCategories"> | null,
|
||||
priority: string
|
||||
): Promise<TicketSlaSnapshot | null> {
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPriority = priority.trim().toUpperCase();
|
||||
const rule =
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||
)
|
||||
.first()) ??
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||
)
|
||||
.first());
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: category._id,
|
||||
categoryName: category.name,
|
||||
priority: normalizedPriority,
|
||||
responseTargetMinutes: rule.responseTargetMinutes ?? undefined,
|
||||
responseMode: normalizeSlaMode(rule.responseMode),
|
||||
solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined,
|
||||
solutionMode: normalizeSlaMode(rule.solutionMode),
|
||||
alertThreshold:
|
||||
typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold)
|
||||
? rule.alertThreshold
|
||||
: SLA_DEFAULT_ALERT_THRESHOLD,
|
||||
pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses),
|
||||
};
|
||||
}
|
||||
|
||||
function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) {
|
||||
return {
|
||||
responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode),
|
||||
solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode),
|
||||
};
|
||||
}
|
||||
|
||||
function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null {
|
||||
if (minutes === null || minutes === undefined || minutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (mode === "calendar") {
|
||||
return startAt + minutes * 60000;
|
||||
}
|
||||
|
||||
let remaining = minutes;
|
||||
let cursor = alignToBusinessStart(new Date(startAt));
|
||||
|
||||
while (remaining > 0) {
|
||||
if (!isBusinessDay(cursor)) {
|
||||
cursor = advanceToNextBusinessStart(cursor);
|
||||
continue;
|
||||
}
|
||||
const endOfDay = new Date(cursor);
|
||||
endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0);
|
||||
const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000;
|
||||
if (minutesAvailable >= remaining) {
|
||||
cursor = new Date(cursor.getTime() + remaining * 60000);
|
||||
remaining = 0;
|
||||
} else {
|
||||
remaining -= minutesAvailable;
|
||||
cursor = advanceToNextBusinessStart(endOfDay);
|
||||
}
|
||||
}
|
||||
|
||||
return cursor.getTime();
|
||||
}
|
||||
|
||||
function alignToBusinessStart(date: Date): Date {
|
||||
let result = new Date(date);
|
||||
if (!isBusinessDay(result)) {
|
||||
return advanceToNextBusinessStart(result);
|
||||
}
|
||||
if (result.getHours() >= BUSINESS_DAY_END_HOUR) {
|
||||
return advanceToNextBusinessStart(result);
|
||||
}
|
||||
if (result.getHours() < BUSINESS_DAY_START_HOUR) {
|
||||
result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function advanceToNextBusinessStart(date: Date): Date {
|
||||
const next = new Date(date);
|
||||
next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
|
||||
next.setDate(next.getDate() + 1);
|
||||
while (!isBusinessDay(next)) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isBusinessDay(date: Date) {
|
||||
const day = date.getDay();
|
||||
return day !== 0 && day !== 6;
|
||||
}
|
||||
|
||||
function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) {
|
||||
if (!snapshot) return {};
|
||||
const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now);
|
||||
return {
|
||||
slaSnapshot: snapshot,
|
||||
slaResponseDueAt: responseDueAt ?? undefined,
|
||||
slaSolutionDueAt: solutionDueAt ?? undefined,
|
||||
slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
|
||||
slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
|
||||
dueAt: solutionDueAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) {
|
||||
const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined;
|
||||
if (!snapshot) return {};
|
||||
const pauseSet = new Set(snapshot.pauseStatuses);
|
||||
const currentlyPaused = typeof ticketDoc.slaPausedAt === "number";
|
||||
|
||||
if (pauseSet.has(nextStatus)) {
|
||||
if (currentlyPaused) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
slaPausedAt: now,
|
||||
slaPausedBy: nextStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentlyPaused) {
|
||||
const pauseStart = ticketDoc.slaPausedAt ?? now;
|
||||
const delta = Math.max(0, now - pauseStart);
|
||||
const patch: Record<string, unknown> = {
|
||||
slaPausedAt: undefined,
|
||||
slaPausedBy: undefined,
|
||||
slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta,
|
||||
};
|
||||
if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") {
|
||||
patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta;
|
||||
}
|
||||
if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") {
|
||||
patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta;
|
||||
patch.dueAt = ticketDoc.slaSolutionDueAt + delta;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
|
||||
const merged = { ...ticketDoc } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) {
|
||||
delete merged[key];
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged as Doc<"tickets">;
|
||||
}
|
||||
|
||||
function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
|
||||
if (ticketDoc.firstResponseAt) {
|
||||
return {};
|
||||
}
|
||||
if (!ticketDoc.slaResponseDueAt) {
|
||||
return {
|
||||
firstResponseAt: now,
|
||||
slaResponseStatus: "n/a",
|
||||
};
|
||||
}
|
||||
const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached";
|
||||
return {
|
||||
firstResponseAt: now,
|
||||
slaResponseStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
|
||||
if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") {
|
||||
return {};
|
||||
}
|
||||
if (!ticketDoc.slaSolutionDueAt) {
|
||||
return { slaSolutionStatus: "n/a" };
|
||||
}
|
||||
const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached";
|
||||
return {
|
||||
slaSolutionStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFormTemplateLabel(
|
||||
templateKey: string | null | undefined,
|
||||
storedLabel: string | null | undefined
|
||||
|
|
@ -223,51 +479,77 @@ async function fetchTicketFieldsByScopes(
|
|||
tenantId: string,
|
||||
scopes: string[]
|
||||
): Promise<TicketFieldScopeMap> {
|
||||
const uniqueScopes = Array.from(new Set(scopes));
|
||||
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
|
||||
if (uniqueScopes.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const scopeSet = new Set(uniqueScopes);
|
||||
const result: TicketFieldScopeMap = new Map();
|
||||
for (const scope of uniqueScopes) {
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", scope))
|
||||
.collect();
|
||||
result.set(scope, fields);
|
||||
const allFields = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
for (const field of allFields) {
|
||||
const scope = field.scope ?? "";
|
||||
if (!scopeSet.has(scope)) {
|
||||
continue;
|
||||
}
|
||||
const current = result.get(scope);
|
||||
if (current) {
|
||||
current.push(field);
|
||||
} else {
|
||||
result.set(scope, [field]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchScopedFormSettings(
|
||||
async function fetchViewerScopedFormSettings(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
templateKey: string,
|
||||
templateKeys: string[],
|
||||
viewerId: Id<"users">,
|
||||
viewerCompanyId: Id<"companies"> | null
|
||||
): Promise<Doc<"ticketFormSettings">[]> {
|
||||
const tenantSettingsPromise = ctx.db
|
||||
): Promise<Map<string, Doc<"ticketFormSettings">[]>> {
|
||||
const uniqueTemplates = Array.from(new Set(templateKeys));
|
||||
if (uniqueTemplates.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const keySet = new Set(uniqueTemplates);
|
||||
const viewerIdStr = String(viewerId);
|
||||
const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null;
|
||||
const scopedMap = new Map<string, Doc<"ticketFormSettings">[]>();
|
||||
|
||||
const allSettings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("scope", "tenant"))
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
const companySettingsPromise = viewerCompanyId
|
||||
? ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_company", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("template", templateKey).eq("companyId", viewerCompanyId)
|
||||
)
|
||||
.collect()
|
||||
: Promise.resolve<Doc<"ticketFormSettings">[]>([]);
|
||||
for (const setting of allSettings) {
|
||||
if (!keySet.has(setting.template)) {
|
||||
continue;
|
||||
}
|
||||
if (setting.scope === "company") {
|
||||
if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) {
|
||||
continue;
|
||||
}
|
||||
} else if (setting.scope === "user") {
|
||||
if (!setting.userId || String(setting.userId) !== viewerIdStr) {
|
||||
continue;
|
||||
}
|
||||
} else if (setting.scope !== "tenant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userSettingsPromise = ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_user", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("userId", viewerId))
|
||||
.collect();
|
||||
if (scopedMap.has(setting.template)) {
|
||||
scopedMap.get(setting.template)!.push(setting);
|
||||
} else {
|
||||
scopedMap.set(setting.template, [setting]);
|
||||
}
|
||||
}
|
||||
|
||||
const [tenantSettings, companySettings, userSettings] = await Promise.all([
|
||||
tenantSettingsPromise,
|
||||
companySettingsPromise,
|
||||
userSettingsPromise,
|
||||
]);
|
||||
|
||||
return [...tenantSettings, ...companySettings, ...userSettings];
|
||||
return scopedMap;
|
||||
}
|
||||
|
||||
function normalizeDateOnlyValue(value: unknown): string | null {
|
||||
|
|
@ -991,34 +1273,36 @@ function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
|
|||
|
||||
type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined;
|
||||
|
||||
function areValuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if ((a === null || a === undefined) && (b === null || b === undefined)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
if (typeof a === "number" && typeof b === "number") {
|
||||
return Number.isNaN(a) && Number.isNaN(b);
|
||||
}
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
|
||||
return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b);
|
||||
}
|
||||
|
||||
function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (!areValuesEqual(a.value ?? null, b.value ?? null)) return false;
|
||||
const prevDisplay = a.displayValue ?? null;
|
||||
const nextDisplay = b.displayValue ?? null;
|
||||
return prevDisplay === nextDisplay;
|
||||
function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string {
|
||||
if (!entry) return "__undefined__";
|
||||
return JSON.stringify({
|
||||
value: normalizeEntryValue(entry.value),
|
||||
displayValue: entry.displayValue ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntryValue(value: unknown): unknown {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === "number" && Number.isNaN(value)) return "__nan__";
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeEntryValue(item));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
Object.keys(record)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
normalized[key] = normalizeEntryValue(record[key]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getCustomFieldRecordEntry(
|
||||
|
|
@ -1066,7 +1350,7 @@ export const list = query({
|
|||
viewerId: v.optional(v.id("users")),
|
||||
tenantId: v.string(),
|
||||
status: v.optional(v.string()),
|
||||
priority: v.optional(v.string()),
|
||||
priority: v.optional(v.union(v.string(), v.array(v.string()))),
|
||||
channel: v.optional(v.string()),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
|
|
@ -1085,7 +1369,9 @@ export const list = query({
|
|||
}
|
||||
|
||||
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
|
||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null;
|
||||
const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
|
||||
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
|
||||
const primaryPriorityFilter = normalizedPriorityFilter.length === 1 ? normalizedPriorityFilter[0] : null;
|
||||
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
|
||||
const searchTerm = args.search?.trim().toLowerCase() ?? null;
|
||||
|
||||
|
|
@ -1098,8 +1384,8 @@ export const list = query({
|
|||
if (normalizedStatusFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter));
|
||||
}
|
||||
if (normalizedPriorityFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter));
|
||||
if (primaryPriorityFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter));
|
||||
}
|
||||
if (normalizedChannelFilter) {
|
||||
working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter));
|
||||
|
|
@ -1188,7 +1474,7 @@ export const list = query({
|
|||
if (role === "MANAGER") {
|
||||
filtered = filtered.filter((t) => t.companyId === user.companyId);
|
||||
}
|
||||
if (normalizedPriorityFilter) filtered = filtered.filter((t) => t.priority === normalizedPriorityFilter);
|
||||
if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority));
|
||||
if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
|
||||
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
|
||||
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
|
||||
|
|
@ -1762,6 +2048,7 @@ export const create = mutation({
|
|||
avatarUrl: requester.avatarUrl ?? undefined,
|
||||
teams: requester.teams ?? undefined,
|
||||
}
|
||||
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
|
||||
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
|
||||
if (!companyDoc && machineDoc?.companyId) {
|
||||
const candidateCompany = await ctx.db.get(machineDoc.companyId)
|
||||
|
|
@ -1795,6 +2082,7 @@ export const create = mutation({
|
|||
}
|
||||
}
|
||||
|
||||
const slaFields = applySlaSnapshot(slaSnapshot, now)
|
||||
const id = await ctx.db.insert("tickets", {
|
||||
tenantId: args.tenantId,
|
||||
reference: nextRef,
|
||||
|
|
@ -1837,6 +2125,7 @@ export const create = mutation({
|
|||
slaPolicyId: undefined,
|
||||
dueAt: undefined,
|
||||
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||
...slaFields,
|
||||
});
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
|
|
@ -1972,8 +2261,13 @@ export const addComment = mutation({
|
|||
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
|
||||
createdAt: now,
|
||||
});
|
||||
// bump ticket updatedAt
|
||||
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
||||
const isStaffResponder =
|
||||
requestedVisibility === "PUBLIC" &&
|
||||
!isRequester &&
|
||||
(normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER");
|
||||
const responsePatch =
|
||||
isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {};
|
||||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||
// Notificação por e-mail: comentário público para o solicitante
|
||||
try {
|
||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
||||
|
|
@ -2139,7 +2433,8 @@ export const updateStatus = mutation({
|
|||
throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
|
||||
const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
|
||||
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch });
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "STATUS_CHANGED",
|
||||
|
|
@ -2206,6 +2501,10 @@ export async function resolveTicketHandler(
|
|||
),
|
||||
).map((id) => id as Id<"tickets">)
|
||||
|
||||
const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
|
||||
const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch);
|
||||
const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now);
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
status: normalizedStatus,
|
||||
resolvedAt: now,
|
||||
|
|
@ -2217,6 +2516,8 @@ export async function resolveTicketHandler(
|
|||
relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
|
||||
activeSessionId: undefined,
|
||||
working: false,
|
||||
...slaPausePatch,
|
||||
...slaSolutionPatch,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
|
|
@ -2324,12 +2625,14 @@ export async function reopenTicketHandler(
|
|||
throw new ConvexError("Usuário não possui permissão para reabrir este chamado")
|
||||
}
|
||||
|
||||
const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now)
|
||||
await ctx.db.patch(ticketId, {
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
reopenedAt: now,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: now,
|
||||
...slaPatch,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
|
|
@ -2529,16 +2832,9 @@ export const listTicketForms = query({
|
|||
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
|
||||
|
||||
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
|
||||
const settingsByTemplate = new Map<string, Doc<"ticketFormSettings">[]>()
|
||||
|
||||
if (!staffOverride) {
|
||||
await Promise.all(
|
||||
templates.map(async (template) => {
|
||||
const scopedSettings = await fetchScopedFormSettings(ctx, tenantId, template.key, viewer.user._id, viewerCompanyId)
|
||||
settingsByTemplate.set(template.key, scopedSettings)
|
||||
})
|
||||
)
|
||||
}
|
||||
const settingsByTemplate = staffOverride
|
||||
? new Map<string, Doc<"ticketFormSettings">[]>()
|
||||
: await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId)
|
||||
|
||||
const forms = [] as Array<{
|
||||
key: string
|
||||
|
|
@ -3272,11 +3568,13 @@ export const startWork = mutation({
|
|||
startedAt: now,
|
||||
})
|
||||
|
||||
const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now);
|
||||
await ctx.db.patch(ticketId, {
|
||||
working: true,
|
||||
activeSessionId: sessionId,
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
updatedAt: now,
|
||||
...slaStartPatch,
|
||||
})
|
||||
|
||||
if (assigneePatched) {
|
||||
|
|
@ -3336,10 +3634,12 @@ export const pauseWork = mutation({
|
|||
const normalizedStatus = normalizeStatus(ticketDoc.status)
|
||||
if (normalizedStatus === "AWAITING_ATTENDANCE") {
|
||||
const now = Date.now()
|
||||
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
|
||||
await ctx.db.patch(ticketId, {
|
||||
status: "PAUSED",
|
||||
working: false,
|
||||
updatedAt: now,
|
||||
...slaPausePatch,
|
||||
})
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
|
|
@ -3380,6 +3680,7 @@ export const pauseWork = mutation({
|
|||
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
|
||||
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
|
||||
|
||||
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
|
||||
await ctx.db.patch(ticketId, {
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
|
|
@ -3388,6 +3689,7 @@ export const pauseWork = mutation({
|
|||
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||
updatedAt: now,
|
||||
...slaPausePatch,
|
||||
})
|
||||
|
||||
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue