feat: agenda polish, SLA sync, filters

This commit is contained in:
Esdras Renan 2025-11-08 02:34:43 -03:00
parent 7fb6c65d9a
commit 6ab8a6ce89
40 changed files with 2771 additions and 154 deletions

View file

@ -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