4350 lines
146 KiB
TypeScript
4350 lines
146 KiB
TypeScript
// CI touch: enable server-side assignee filtering and trigger redeploy
|
|
import { mutation, query } from "./_generated/server";
|
|
import { api } from "./_generated/api";
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Id, type Doc, type TableNames } from "./_generated/dataModel";
|
|
|
|
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
|
import {
|
|
OPTIONAL_ADMISSION_FIELD_KEYS,
|
|
TICKET_FORM_CONFIG,
|
|
TICKET_FORM_DEFAULT_FIELDS,
|
|
type TicketFormFieldSeed,
|
|
} from "./ticketForms.config";
|
|
import {
|
|
ensureTicketFormTemplatesForTenant,
|
|
getTemplateByKey,
|
|
normalizeFormTemplateKey,
|
|
} from "./ticketFormTemplates";
|
|
|
|
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<string, string> = {
|
|
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}$/;
|
|
|
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
|
|
|
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
|
PENDING: "Pendente",
|
|
AWAITING_ATTENDANCE: "Em andamento",
|
|
PAUSED: "Pausado",
|
|
RESOLVED: "Resolvido",
|
|
};
|
|
|
|
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|
NEW: "PENDING",
|
|
PENDING: "PENDING",
|
|
OPEN: "AWAITING_ATTENDANCE",
|
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
|
ON_HOLD: "PAUSED",
|
|
PAUSED: "PAUSED",
|
|
RESOLVED: "RESOLVED",
|
|
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>();
|
|
|
|
// Character limits (generous but bounded)
|
|
const MAX_SUMMARY_CHARS = 600;
|
|
const MAX_COMMENT_CHARS = 20000;
|
|
const DEFAULT_REOPEN_DAYS = 7;
|
|
const MAX_REOPEN_DAYS = 14;
|
|
|
|
type AnyCtx = QueryCtx | MutationCtx;
|
|
|
|
type TemplateSummary = {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
defaultEnabled: boolean;
|
|
};
|
|
|
|
type TicketFieldScopeMap = Map<string, Doc<"ticketFields">[]>;
|
|
|
|
function plainTextLength(html: string): number {
|
|
try {
|
|
const text = String(html)
|
|
.replace(/<[^>]*>/g, "") // strip tags
|
|
.replace(/ /g, " ")
|
|
.trim();
|
|
return text.length;
|
|
} catch {
|
|
return String(html ?? "").length;
|
|
}
|
|
}
|
|
|
|
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 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"> {
|
|
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">;
|
|
}
|
|
|
|
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 {};
|
|
}
|
|
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
|
|
): string | null {
|
|
if (storedLabel && storedLabel.trim().length > 0) {
|
|
return storedLabel.trim();
|
|
}
|
|
const normalizedKey = templateKey?.trim();
|
|
if (!normalizedKey) {
|
|
return null;
|
|
}
|
|
const fallback = TICKET_FORM_CONFIG.find((entry) => entry.key === normalizedKey);
|
|
return fallback ? fallback.label : null;
|
|
}
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function resolveReopenWindowDays(input?: number | null): number {
|
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
|
return DEFAULT_REOPEN_DAYS;
|
|
}
|
|
const rounded = Math.round(input);
|
|
if (rounded < 1) return 1;
|
|
if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS;
|
|
return rounded;
|
|
}
|
|
|
|
function computeReopenDeadline(now: number, windowDays: number): number {
|
|
return now + windowDays * 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null {
|
|
if (typeof ticket.reopenDeadline === "number") {
|
|
return ticket.reopenDeadline;
|
|
}
|
|
if (typeof ticket.closedAt === "number") {
|
|
return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
|
|
}
|
|
if (typeof ticket.resolvedAt === "number") {
|
|
return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean {
|
|
const deadline = inferExistingReopenDeadline(ticket);
|
|
if (!deadline) {
|
|
return true;
|
|
}
|
|
return now <= deadline;
|
|
}
|
|
|
|
function findLatestSetting<T extends { updatedAt: number }>(entries: T[], predicate: (entry: T) => boolean): T | null {
|
|
let latest: T | null = null;
|
|
for (const entry of entries) {
|
|
if (!predicate(entry)) continue;
|
|
if (!latest || entry.updatedAt > latest.updatedAt) {
|
|
latest = entry;
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function resolveFormEnabled(
|
|
template: string,
|
|
baseEnabled: boolean,
|
|
settings: Doc<"ticketFormSettings">[],
|
|
context: { companyId?: Id<"companies"> | null; userId: Id<"users"> }
|
|
): boolean {
|
|
const scoped = settings.filter((setting) => setting.template === template)
|
|
if (scoped.length === 0) {
|
|
return baseEnabled
|
|
}
|
|
const userSetting = findLatestSetting(scoped, (setting) => {
|
|
if (setting.scope !== "user") {
|
|
return false
|
|
}
|
|
if (!setting.userId) {
|
|
return false
|
|
}
|
|
return String(setting.userId) === String(context.userId)
|
|
})
|
|
if (userSetting) {
|
|
return userSetting.enabled ?? baseEnabled
|
|
}
|
|
const companyId = context.companyId ? String(context.companyId) : null
|
|
if (companyId) {
|
|
const companySetting = findLatestSetting(scoped, (setting) => {
|
|
if (setting.scope !== "company") {
|
|
return false
|
|
}
|
|
if (!setting.companyId) {
|
|
return false
|
|
}
|
|
return String(setting.companyId) === companyId
|
|
})
|
|
if (companySetting) {
|
|
return companySetting.enabled ?? baseEnabled
|
|
}
|
|
}
|
|
const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant")
|
|
if (tenantSetting) {
|
|
return tenantSetting.enabled ?? baseEnabled
|
|
}
|
|
return baseEnabled
|
|
}
|
|
|
|
async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<TemplateSummary[]> {
|
|
const templates = await ctx.db
|
|
.query("ticketFormTemplates")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
if (!templates.length) {
|
|
return TICKET_FORM_CONFIG.map((template) => ({
|
|
key: template.key,
|
|
label: template.label,
|
|
description: template.description,
|
|
defaultEnabled: template.defaultEnabled,
|
|
}));
|
|
}
|
|
return templates
|
|
.filter((tpl) => tpl.isArchived !== true)
|
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
|
|
.map((tpl) => ({
|
|
key: tpl.key,
|
|
label: tpl.label,
|
|
description: tpl.description ?? "",
|
|
defaultEnabled: tpl.defaultEnabled ?? true,
|
|
}));
|
|
}
|
|
|
|
async function fetchTicketFieldsByScopes(
|
|
ctx: QueryCtx,
|
|
tenantId: string,
|
|
scopes: string[],
|
|
companyId: Id<"companies"> | null
|
|
): Promise<TicketFieldScopeMap> {
|
|
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
|
|
if (uniqueScopes.length === 0) {
|
|
return new Map();
|
|
}
|
|
const scopeSet = new Set(uniqueScopes);
|
|
const companyIdStr = companyId ? String(companyId) : null;
|
|
const result: TicketFieldScopeMap = new Map();
|
|
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 fieldCompanyId = field.companyId ? String(field.companyId) : null;
|
|
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
|
|
continue;
|
|
}
|
|
const current = result.get(scope);
|
|
if (current) {
|
|
current.push(field);
|
|
} else {
|
|
result.set(scope, [field]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function fetchViewerScopedFormSettings(
|
|
ctx: QueryCtx,
|
|
tenantId: string,
|
|
templateKeys: string[],
|
|
viewerId: Id<"users">,
|
|
viewerCompanyId: Id<"companies"> | null
|
|
): 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", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
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;
|
|
}
|
|
|
|
if (scopedMap.has(setting.template)) {
|
|
scopedMap.get(setting.template)!.push(setting);
|
|
} else {
|
|
scopedMap.set(setting.template, [setting]);
|
|
}
|
|
}
|
|
|
|
return scopedMap;
|
|
}
|
|
|
|
function normalizeDateOnlyValue(value: unknown): string | null {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (DATE_ONLY_REGEX.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
const parsed = new Date(trimmed);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return null;
|
|
}
|
|
return parsed.toISOString().slice(0, 10);
|
|
}
|
|
const date =
|
|
value instanceof Date
|
|
? value
|
|
: typeof value === "number"
|
|
? new Date(value)
|
|
: null;
|
|
if (!date || Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
|
|
await ensureTicketFormTemplatesForTenant(ctx, tenantId);
|
|
const now = Date.now();
|
|
for (const template of TICKET_FORM_CONFIG) {
|
|
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
|
|
if (!defaults.length) {
|
|
continue;
|
|
}
|
|
const existing = await ctx.db
|
|
.query("ticketFields")
|
|
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
|
|
.collect();
|
|
if (template.key === "admissao") {
|
|
for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
|
|
const field = existing.find((f) => f.key === key);
|
|
if (!field) continue;
|
|
const updates: Partial<Doc<"ticketFields">> = {};
|
|
if (field.required) {
|
|
updates.required = false;
|
|
}
|
|
if (key === "colaborador_patrimonio") {
|
|
const desiredLabel = "Patrimônio do computador (se houver)";
|
|
if ((field.label ?? "").trim() !== desiredLabel) {
|
|
updates.label = desiredLabel;
|
|
}
|
|
}
|
|
if (Object.keys(updates).length) {
|
|
await ctx.db.patch(field._id, {
|
|
...updates,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const existingKeys = new Set(existing.map((field) => field.key));
|
|
let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
|
|
for (const field of defaults) {
|
|
if (existingKeys.has(field.key)) {
|
|
// Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima
|
|
continue;
|
|
}
|
|
order += 1;
|
|
await ctx.db.insert("ticketFields", {
|
|
tenantId,
|
|
key: field.key,
|
|
label: field.label,
|
|
description: field.description ?? "",
|
|
type: field.type,
|
|
required: Boolean(field.required),
|
|
options: field.options?.map((option) => ({
|
|
value: option.value,
|
|
label: option.label,
|
|
})),
|
|
scope: template.key,
|
|
companyId: field.companyId ?? undefined,
|
|
order,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function buildAssigneeChangeComment(
|
|
reason: string,
|
|
context: { previousName: string; nextName: string },
|
|
): string {
|
|
const normalized = reason.replace(/\r\n/g, "\n").trim();
|
|
const lines = normalized
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
const previous = escapeHtml(context.previousName || "Não atribuído");
|
|
const next = escapeHtml(context.nextName || "Não atribuído");
|
|
const reasonHtml = lines.length
|
|
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
|
|
: `<p>—</p>`;
|
|
return `<p><strong>Responsável atualizado:</strong> ${previous} → ${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
|
|
}
|
|
|
|
function truncateSubject(subject: string) {
|
|
if (subject.length <= 60) return subject
|
|
return `${subject.slice(0, 57)}…`
|
|
}
|
|
|
|
const TICKET_MENTION_ANCHOR_CLASSES =
|
|
"ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200"
|
|
const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900"
|
|
const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400"
|
|
const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700"
|
|
const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full"
|
|
const TICKET_MENTION_STATUS_TONE: Record<TicketStatusNormalized, string> = {
|
|
PENDING: "bg-amber-400",
|
|
AWAITING_ATTENDANCE: "bg-sky-500",
|
|
PAUSED: "bg-violet-500",
|
|
RESOLVED: "bg-emerald-500",
|
|
}
|
|
|
|
function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
|
|
const reference = ticket.reference
|
|
const subject = escapeHtml(ticket.subject ?? "")
|
|
const truncated = truncateSubject(subject)
|
|
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
|
|
const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase()
|
|
const normalizedStatus = normalizeStatus(status)
|
|
const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400"
|
|
const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}`
|
|
return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" status="${normalizedStatus}" href="/tickets/${String(ticket._id)}" class="${TICKET_MENTION_ANCHOR_CLASSES}" rel="noopener noreferrer" target="_self" title="Chamado #${reference}${subject ? ` • ${subject}` : ""}"><span class="${dotClass}"></span><span class="${TICKET_MENTION_REF_CLASSES}">#${reference}</span><span class="${TICKET_MENTION_SEP_CLASSES}">•</span><span class="${TICKET_MENTION_SUBJECT_CLASSES}">${truncated}</span></a>`
|
|
}
|
|
|
|
function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
|
|
if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true
|
|
if (viewerRole === "COLLABORATOR") {
|
|
return String(ticket.requesterId) === String(viewerId)
|
|
}
|
|
if (viewerRole === "MANAGER") {
|
|
// Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante
|
|
return String(ticket.requesterId) === String(viewerId)
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function normalizeTicketMentions(
|
|
ctx: MutationCtx,
|
|
html: string,
|
|
viewer: { user: Doc<"users">; role: string },
|
|
tenantId: string,
|
|
): Promise<string> {
|
|
if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) {
|
|
return html
|
|
}
|
|
|
|
const mentionPattern = /<a\b[^>]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi
|
|
const matches = Array.from(html.matchAll(mentionPattern))
|
|
if (!matches.length) {
|
|
return html
|
|
}
|
|
|
|
let output = html
|
|
|
|
const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi
|
|
|
|
for (const match of matches) {
|
|
const full = match[0]
|
|
attributePattern.lastIndex = 0
|
|
const attributes: Record<string, string> = {}
|
|
let attrMatch: RegExpExecArray | null
|
|
while ((attrMatch = attributePattern.exec(full)) !== null) {
|
|
attributes[attrMatch[1]] = attrMatch[2]
|
|
}
|
|
|
|
let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null
|
|
if (!ticketIdRaw && attributes.href) {
|
|
const hrefPath = attributes.href.split("?")[0]
|
|
const segments = hrefPath.split("/").filter(Boolean)
|
|
ticketIdRaw = segments.pop() ?? null
|
|
}
|
|
let replacement = ""
|
|
|
|
if (ticketIdRaw) {
|
|
const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">)
|
|
if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) {
|
|
replacement = buildTicketMentionAnchor(ticket)
|
|
} else {
|
|
const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
|
replacement = escapeHtml(inner || `#${ticketIdRaw}`)
|
|
}
|
|
} else {
|
|
replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim())
|
|
}
|
|
|
|
output = output.replace(full, replacement)
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
|
if (!status) return "PENDING";
|
|
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
|
return normalized ?? "PENDING";
|
|
}
|
|
|
|
function formatWorkDuration(ms: number): string {
|
|
if (!Number.isFinite(ms) || ms <= 0) {
|
|
return "0m";
|
|
}
|
|
const totalMinutes = Math.round(ms / 60000);
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = Math.abs(totalMinutes % 60);
|
|
const parts: string[] = [];
|
|
if (hours > 0) parts.push(`${hours}h`);
|
|
if (minutes > 0) parts.push(`${minutes}m`);
|
|
if (parts.length === 0) {
|
|
return "0m";
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function formatWorkDelta(deltaMs: number): string {
|
|
if (deltaMs === 0) return "0m";
|
|
const sign = deltaMs > 0 ? "+" : "-";
|
|
const absolute = formatWorkDuration(Math.abs(deltaMs));
|
|
return `${sign}${absolute}`;
|
|
}
|
|
|
|
type AgentWorkTotals = {
|
|
agentId: Id<"users">;
|
|
agentName: string | null;
|
|
agentEmail: string | null;
|
|
avatarUrl: string | null;
|
|
totalWorkedMs: number;
|
|
internalWorkedMs: number;
|
|
externalWorkedMs: number;
|
|
};
|
|
|
|
async function computeAgentWorkTotals(
|
|
ctx: MutationCtx | QueryCtx,
|
|
ticketId: Id<"tickets">,
|
|
referenceNow: number,
|
|
): Promise<AgentWorkTotals[]> {
|
|
const sessions = await ctx.db
|
|
.query("ticketWorkSessions")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.collect();
|
|
|
|
if (!sessions.length) {
|
|
return [];
|
|
}
|
|
|
|
const totals = new Map<
|
|
string,
|
|
{ totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number }
|
|
>();
|
|
|
|
for (const session of sessions) {
|
|
const baseDuration = typeof session.durationMs === "number"
|
|
? session.durationMs
|
|
: typeof session.stoppedAt === "number"
|
|
? session.stoppedAt - session.startedAt
|
|
: referenceNow - session.startedAt;
|
|
const durationMs = Math.max(0, baseDuration);
|
|
if (durationMs <= 0) continue;
|
|
const key = session.agentId as string;
|
|
const bucket = totals.get(key) ?? {
|
|
totalWorkedMs: 0,
|
|
internalWorkedMs: 0,
|
|
externalWorkedMs: 0,
|
|
};
|
|
bucket.totalWorkedMs += durationMs;
|
|
const workType = (session.workType ?? "INTERNAL").toUpperCase();
|
|
if (workType === "EXTERNAL") {
|
|
bucket.externalWorkedMs += durationMs;
|
|
} else {
|
|
bucket.internalWorkedMs += durationMs;
|
|
}
|
|
totals.set(key, bucket);
|
|
}
|
|
|
|
if (totals.size === 0) {
|
|
return [];
|
|
}
|
|
|
|
const agentIds = Array.from(totals.keys());
|
|
const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">)));
|
|
|
|
return agentIds
|
|
.map((agentId, index) => {
|
|
const bucket = totals.get(agentId)!;
|
|
const agentDoc = agents[index] as Doc<"users"> | null;
|
|
return {
|
|
agentId: agentId as Id<"users">,
|
|
agentName: agentDoc?.name ?? null,
|
|
agentEmail: agentDoc?.email ?? null,
|
|
avatarUrl: agentDoc?.avatarUrl ?? null,
|
|
totalWorkedMs: bucket.totalWorkedMs,
|
|
internalWorkedMs: bucket.internalWorkedMs,
|
|
externalWorkedMs: bucket.externalWorkedMs,
|
|
};
|
|
})
|
|
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs);
|
|
}
|
|
|
|
async function ensureManagerTicketAccess(
|
|
ctx: MutationCtx | QueryCtx,
|
|
manager: Doc<"users">,
|
|
ticket: Doc<"tickets">,
|
|
): Promise<Doc<"users"> | null> {
|
|
if (!manager.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
if (ticket.companyId && ticket.companyId === manager.companyId) {
|
|
return null
|
|
}
|
|
const requester = await ctx.db.get(ticket.requesterId)
|
|
if (!requester || requester.companyId !== manager.companyId) {
|
|
throw new ConvexError("Acesso restrito à empresa")
|
|
}
|
|
return requester as Doc<"users">
|
|
}
|
|
|
|
async function requireTicketStaff(
|
|
ctx: MutationCtx | QueryCtx,
|
|
actorId: Id<"users">,
|
|
ticket: Doc<"tickets">
|
|
) {
|
|
const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
|
|
if (viewer.role === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, viewer.user, ticket)
|
|
}
|
|
return viewer
|
|
}
|
|
|
|
type TicketChatParticipant = {
|
|
user: Doc<"users">;
|
|
role: string | null;
|
|
kind: "staff" | "manager" | "requester";
|
|
};
|
|
|
|
async function requireTicketChatParticipant(
|
|
ctx: MutationCtx | QueryCtx,
|
|
actorId: Id<"users">,
|
|
ticket: Doc<"tickets">
|
|
): Promise<TicketChatParticipant> {
|
|
const viewer = await requireUser(ctx, actorId, ticket.tenantId);
|
|
const normalizedRole = viewer.role ?? "";
|
|
if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") {
|
|
return { user: viewer.user, role: normalizedRole, kind: "staff" };
|
|
}
|
|
if (normalizedRole === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, viewer.user, ticket);
|
|
return { user: viewer.user, role: normalizedRole, kind: "manager" };
|
|
}
|
|
if (normalizedRole === "COLLABORATOR") {
|
|
if (String(ticket.requesterId) !== String(viewer.user._id)) {
|
|
throw new ConvexError("Apenas o solicitante pode conversar neste chamado");
|
|
}
|
|
return { user: viewer.user, role: normalizedRole, kind: "requester" };
|
|
}
|
|
throw new ConvexError("Usuário não possui acesso ao chat deste chamado");
|
|
}
|
|
|
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
|
"Suporte N1": "Chamados",
|
|
"suporte-n1": "Chamados",
|
|
chamados: "Chamados",
|
|
"Suporte N2": "Laboratório",
|
|
"suporte-n2": "Laboratório",
|
|
laboratorio: "Laboratório",
|
|
Laboratorio: "Laboratório",
|
|
visitas: "Visitas",
|
|
};
|
|
|
|
function renameQueueString(value?: string | null): string | null {
|
|
if (!value) return value ?? null;
|
|
const direct = QUEUE_RENAME_LOOKUP[value];
|
|
if (direct) return direct;
|
|
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
|
|
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
|
|
}
|
|
|
|
function normalizeQueueName(queue?: Doc<"queues"> | null): string | null {
|
|
if (!queue) return null;
|
|
const normalized = renameQueueString(queue.name);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
if (queue.slug) {
|
|
const fromSlug = renameQueueString(queue.slug);
|
|
if (fromSlug) return fromSlug;
|
|
}
|
|
return queue.name;
|
|
}
|
|
|
|
function normalizeTeams(teams?: string[] | null): string[] {
|
|
if (!teams) return [];
|
|
return teams.map((team) => renameQueueString(team) ?? team);
|
|
}
|
|
|
|
type RequesterFallbackContext = {
|
|
ticketId?: Id<"tickets">;
|
|
fallbackName?: string | null;
|
|
fallbackEmail?: string | null;
|
|
};
|
|
|
|
function buildRequesterSummary(
|
|
requester: Doc<"users"> | null,
|
|
requesterId: Id<"users">,
|
|
context?: RequesterFallbackContext,
|
|
) {
|
|
if (requester) {
|
|
return {
|
|
id: requester._id,
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl,
|
|
teams: normalizeTeams(requester.teams),
|
|
};
|
|
}
|
|
|
|
const idString = String(requesterId);
|
|
const fallbackName =
|
|
typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0
|
|
? context.fallbackName.trim()
|
|
: "Solicitante não encontrado";
|
|
const fallbackEmailCandidate =
|
|
typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@")
|
|
? context.fallbackEmail
|
|
: null;
|
|
const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`;
|
|
|
|
if (process.env.NODE_ENV !== "test") {
|
|
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
|
|
const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`;
|
|
if (!missingRequesterLogCache.has(cacheKey)) {
|
|
missingRequesterLogCache.add(cacheKey);
|
|
console.warn(
|
|
`[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: requesterId,
|
|
name: fallbackName,
|
|
email: fallbackEmail,
|
|
teams: [],
|
|
};
|
|
}
|
|
|
|
type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] };
|
|
type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean };
|
|
|
|
function buildRequesterFromSnapshot(
|
|
requesterId: Id<"users">,
|
|
snapshot: UserSnapshot | null | undefined,
|
|
fallback?: RequesterFallbackContext
|
|
) {
|
|
if (snapshot) {
|
|
const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado")
|
|
const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
|
|
const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`)
|
|
return {
|
|
id: requesterId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot.teams ?? []),
|
|
}
|
|
}
|
|
return buildRequesterSummary(null, requesterId, fallback)
|
|
}
|
|
|
|
function buildAssigneeFromSnapshot(
|
|
assigneeId: Id<"users">,
|
|
snapshot: UserSnapshot | null | undefined
|
|
) {
|
|
const name = snapshot?.name?.trim?.() || "Usuário removido"
|
|
const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
|
|
const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid`
|
|
return {
|
|
id: assigneeId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot?.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot?.teams ?? []),
|
|
}
|
|
}
|
|
|
|
function buildCompanyFromSnapshot(
|
|
companyId: Id<"companies"> | undefined,
|
|
snapshot: CompanySnapshot | null | undefined
|
|
) {
|
|
if (!snapshot) return null
|
|
return {
|
|
id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">,
|
|
name: snapshot.name,
|
|
isAvulso: Boolean(snapshot.isAvulso ?? false),
|
|
}
|
|
}
|
|
|
|
type CommentAuthorFallbackContext = {
|
|
ticketId?: Id<"tickets">;
|
|
commentId?: Id<"ticketComments">;
|
|
};
|
|
|
|
type CommentAuthorSnapshot = {
|
|
name: string;
|
|
email?: string;
|
|
avatarUrl?: string;
|
|
teams?: string[];
|
|
};
|
|
|
|
export function buildCommentAuthorSummary(
|
|
comment: Doc<"ticketComments">,
|
|
author: Doc<"users"> | null,
|
|
context?: CommentAuthorFallbackContext,
|
|
) {
|
|
if (author) {
|
|
return {
|
|
id: author._id,
|
|
name: author.name,
|
|
email: author.email,
|
|
avatarUrl: author.avatarUrl,
|
|
teams: normalizeTeams(author.teams),
|
|
};
|
|
}
|
|
|
|
if (process.env.NODE_ENV !== "test") {
|
|
const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
|
|
const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : "";
|
|
const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"];
|
|
if (context?.commentId) cacheKeyParts.push(String(context.commentId));
|
|
const cacheKey = cacheKeyParts.join(":");
|
|
if (!missingCommentAuthorLogCache.has(cacheKey)) {
|
|
missingCommentAuthorLogCache.add(cacheKey);
|
|
console.warn(
|
|
`[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const idString = String(comment.authorId);
|
|
const fallbackName = "Usuário removido";
|
|
const fallbackEmail = `author-${idString}@example.invalid`;
|
|
const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined;
|
|
if (snapshot) {
|
|
const name =
|
|
typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
|
? snapshot.name.trim()
|
|
: fallbackName;
|
|
const emailCandidate =
|
|
typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null;
|
|
const email = emailCandidate ?? fallbackEmail;
|
|
return {
|
|
id: comment.authorId,
|
|
name,
|
|
email,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: normalizeTeams(snapshot.teams ?? []),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: comment.authorId,
|
|
name: fallbackName,
|
|
email: fallbackEmail,
|
|
teams: [],
|
|
};
|
|
}
|
|
|
|
type CustomFieldInput = {
|
|
fieldId: Id<"ticketFields">;
|
|
value: unknown;
|
|
};
|
|
|
|
type NormalizedCustomField = {
|
|
fieldId: Id<"ticketFields">;
|
|
fieldKey: string;
|
|
label: string;
|
|
type: string;
|
|
value: unknown;
|
|
displayValue?: string;
|
|
};
|
|
|
|
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
|
|
switch (field.type) {
|
|
case "text":
|
|
return { value: String(raw).trim() };
|
|
case "number": {
|
|
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
|
|
if (!Number.isFinite(value)) {
|
|
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
|
|
}
|
|
return { value };
|
|
}
|
|
case "date": {
|
|
const normalized = normalizeDateOnlyValue(raw);
|
|
if (!normalized) {
|
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
|
}
|
|
return { value: normalized };
|
|
}
|
|
case "boolean": {
|
|
if (typeof raw === "boolean") {
|
|
return { value: raw };
|
|
}
|
|
if (typeof raw === "string") {
|
|
const normalized = raw.toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return { value: true };
|
|
if (normalized === "false" || normalized === "0") return { value: false };
|
|
}
|
|
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
|
|
}
|
|
case "select": {
|
|
if (!field.options || field.options.length === 0) {
|
|
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
|
|
}
|
|
const value = String(raw);
|
|
const option = field.options.find((opt) => opt.value === value);
|
|
if (!option) {
|
|
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
|
|
}
|
|
return { value: option.value, displayValue: option.label ?? option.value };
|
|
}
|
|
default:
|
|
return { value: raw };
|
|
}
|
|
}
|
|
|
|
async function normalizeCustomFieldValues(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
tenantId: string,
|
|
inputs: CustomFieldInput[] | undefined,
|
|
scope?: string | null
|
|
): Promise<NormalizedCustomField[]> {
|
|
const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null;
|
|
const definitions = await ctx.db
|
|
.query("ticketFields")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect();
|
|
|
|
const scopedDefinitions = definitions.filter((definition) => {
|
|
const fieldScope = (definition.scope ?? "all").toLowerCase();
|
|
if (fieldScope === "all" || fieldScope.length === 0) {
|
|
return true;
|
|
}
|
|
if (!normalizedScope) {
|
|
return false;
|
|
}
|
|
return fieldScope === normalizedScope;
|
|
});
|
|
|
|
if (!scopedDefinitions.length) {
|
|
if (inputs && inputs.length > 0) {
|
|
throw new ConvexError("Campos personalizados não configurados para este formulário");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const provided = new Map<Id<"ticketFields">, unknown>();
|
|
for (const entry of inputs ?? []) {
|
|
provided.set(entry.fieldId, entry.value);
|
|
}
|
|
|
|
const normalized: NormalizedCustomField[] = [];
|
|
|
|
for (const definition of scopedDefinitions.sort((a, b) => a.order - b.order)) {
|
|
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
|
|
const isMissing =
|
|
raw === undefined ||
|
|
raw === null ||
|
|
(typeof raw === "string" && raw.trim().length === 0);
|
|
|
|
if (isMissing) {
|
|
if (definition.required) {
|
|
throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const { value, displayValue } = coerceCustomFieldValue(definition, raw);
|
|
normalized.push({
|
|
fieldId: definition._id,
|
|
fieldKey: definition.key,
|
|
label: definition.label,
|
|
type: definition.type,
|
|
value,
|
|
displayValue,
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
|
|
if (!entries || entries.length === 0) return {};
|
|
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
|
|
let value: unknown = entry.value;
|
|
if (entry.type === "date") {
|
|
value = normalizeDateOnlyValue(entry.value) ?? entry.value;
|
|
}
|
|
acc[entry.fieldKey] = {
|
|
label: entry.label,
|
|
type: entry.type,
|
|
value,
|
|
displayValue: entry.displayValue,
|
|
};
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined;
|
|
|
|
function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
|
|
return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b);
|
|
}
|
|
|
|
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(
|
|
record: Record<string, { label: string; type: string; value: unknown; displayValue?: string }>,
|
|
key: string
|
|
): CustomFieldRecordEntry {
|
|
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
|
|
}
|
|
|
|
const DEFAULT_TICKETS_LIST_LIMIT = 120;
|
|
const MIN_TICKETS_LIST_LIMIT = 25;
|
|
const MAX_TICKETS_LIST_LIMIT = 400;
|
|
const MAX_FETCH_LIMIT = 400;
|
|
const BASE_FETCH_PADDING = 50;
|
|
const SEARCH_FETCH_PADDING = 200;
|
|
|
|
function clampTicketLimit(limit: number) {
|
|
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
|
|
return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit)));
|
|
}
|
|
|
|
function computeFetchLimit(limit: number, hasSearch: boolean) {
|
|
const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING;
|
|
const target = limit + padding;
|
|
return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
|
|
}
|
|
|
|
async function loadDocs<TableName extends TableNames>(
|
|
ctx: QueryCtx,
|
|
ids: (Id<TableName> | null | undefined)[],
|
|
): Promise<Map<string, Doc<TableName>>> {
|
|
const uniqueIds = Array.from(
|
|
new Set(ids.filter((value): value is Id<TableName> => Boolean(value))),
|
|
);
|
|
if (uniqueIds.length === 0) {
|
|
return new Map();
|
|
}
|
|
const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id)));
|
|
const map = new Map<string, Doc<TableName>>();
|
|
docs.forEach((doc, index) => {
|
|
if (doc) {
|
|
map.set(String(uniqueIds[index]), doc);
|
|
}
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function dedupeTicketsById(tickets: Doc<"tickets">[]) {
|
|
const seen = new Set<string>();
|
|
const result: Doc<"tickets">[] = [];
|
|
for (const ticket of tickets) {
|
|
const key = String(ticket._id);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(ticket);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export const list = query({
|
|
args: {
|
|
viewerId: v.optional(v.id("users")),
|
|
tenantId: v.string(),
|
|
status: 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")),
|
|
requesterId: v.optional(v.id("users")),
|
|
search: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
if (!args.viewerId) {
|
|
return [];
|
|
}
|
|
const viewerId = args.viewerId as Id<"users">;
|
|
const { user, role } = await requireUser(ctx, viewerId, args.tenantId);
|
|
if (role === "MANAGER" && !user.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada");
|
|
}
|
|
|
|
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
|
|
const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
|
|
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
|
|
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
|
|
const searchTerm = args.search?.trim().toLowerCase() ?? null;
|
|
|
|
const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT;
|
|
const requestedLimit = clampTicketLimit(requestedLimitRaw);
|
|
const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm));
|
|
|
|
let base: Doc<"tickets">[] = [];
|
|
|
|
if (role === "MANAGER") {
|
|
const baseQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
} else if (args.assigneeId) {
|
|
const baseQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
} else if (args.requesterId) {
|
|
const baseQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
} else if (args.queueId) {
|
|
const baseQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
} else if (normalizedStatusFilter) {
|
|
const baseQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
} else if (role === "COLLABORATOR") {
|
|
const viewerEmail = user.email.trim().toLowerCase();
|
|
const directQuery = ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId));
|
|
const directTickets = await directQuery.order("desc").take(fetchLimit);
|
|
|
|
let combined = directTickets;
|
|
if (directTickets.length < fetchLimit) {
|
|
const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
|
|
const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
|
|
const fallbackMatches = fallbackRaw.filter((ticket) => {
|
|
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
|
|
if (typeof snapshotEmail !== "string") return false;
|
|
return snapshotEmail.trim().toLowerCase() === viewerEmail;
|
|
});
|
|
combined = dedupeTicketsById([...directTickets, ...fallbackMatches]);
|
|
}
|
|
base = combined.slice(0, fetchLimit);
|
|
} else {
|
|
const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
|
|
base = await baseQuery.order("desc").take(fetchLimit);
|
|
}
|
|
|
|
let filtered = base;
|
|
|
|
if (role === "MANAGER") {
|
|
filtered = filtered.filter((t) => t.companyId === user.companyId);
|
|
}
|
|
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));
|
|
if (normalizedStatusFilter) {
|
|
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
|
}
|
|
if (searchTerm) {
|
|
filtered = filtered.filter(
|
|
(t) =>
|
|
t.subject.toLowerCase().includes(searchTerm) ||
|
|
t.summary?.toLowerCase().includes(searchTerm) ||
|
|
`#${t.reference}`.toLowerCase().includes(searchTerm)
|
|
);
|
|
}
|
|
|
|
const limited = filtered.slice(0, requestedLimit);
|
|
if (limited.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const [
|
|
requesterDocs,
|
|
assigneeDocs,
|
|
queueDocs,
|
|
companyDocs,
|
|
machineDocs,
|
|
activeSessionDocs,
|
|
categoryDocs,
|
|
subcategoryDocs,
|
|
] = await Promise.all([
|
|
loadDocs(ctx, limited.map((t) => t.requesterId)),
|
|
loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)),
|
|
loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)),
|
|
]);
|
|
|
|
const serverNow = Date.now();
|
|
const result = limited.map((t) => {
|
|
const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined;
|
|
const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null;
|
|
const requesterSummary = requesterDoc
|
|
? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id })
|
|
: buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id });
|
|
|
|
const assigneeDoc = t.assigneeId
|
|
? assigneeDocs.get(String(t.assigneeId)) ?? null
|
|
: null;
|
|
const assigneeSummary = t.assigneeId
|
|
? assigneeDoc
|
|
? {
|
|
id: assigneeDoc._id,
|
|
name: assigneeDoc.name,
|
|
email: assigneeDoc.email,
|
|
avatarUrl: assigneeDoc.avatarUrl,
|
|
teams: normalizeTeams(assigneeDoc.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
|
: null;
|
|
|
|
const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null;
|
|
const queueName = normalizeQueueName(queueDoc);
|
|
|
|
const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null;
|
|
const companySummary = companyDoc
|
|
? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false }
|
|
: t.companyId || t.companySnapshot
|
|
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
|
: null;
|
|
|
|
const machineSnapshot = t.machineSnapshot as
|
|
| {
|
|
hostname?: string;
|
|
persona?: string;
|
|
assignedUserName?: string;
|
|
assignedUserEmail?: string;
|
|
status?: string;
|
|
}
|
|
| undefined;
|
|
const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null;
|
|
let machineSummary:
|
|
| {
|
|
id: Id<"machines"> | null;
|
|
hostname: string | null;
|
|
persona: string | null;
|
|
assignedUserName: string | null;
|
|
assignedUserEmail: string | null;
|
|
status: string | null;
|
|
}
|
|
| null = null;
|
|
if (t.machineId) {
|
|
machineSummary = {
|
|
id: t.machineId,
|
|
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
|
|
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
|
|
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
|
|
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
|
|
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
|
|
};
|
|
} else if (machineSnapshot) {
|
|
machineSummary = {
|
|
id: null,
|
|
hostname: machineSnapshot.hostname ?? null,
|
|
persona: machineSnapshot.persona ?? null,
|
|
assignedUserName: machineSnapshot.assignedUserName ?? null,
|
|
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
|
|
status: machineSnapshot.status ?? null,
|
|
};
|
|
}
|
|
|
|
const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null;
|
|
const categorySummary = categoryDoc
|
|
? { id: categoryDoc._id, name: categoryDoc.name }
|
|
: null;
|
|
|
|
const subcategoryDoc = t.subcategoryId
|
|
? subcategoryDocs.get(String(t.subcategoryId)) ?? null
|
|
: null;
|
|
const subcategorySummary = subcategoryDoc
|
|
? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId }
|
|
: null;
|
|
|
|
const activeSessionDoc = t.activeSessionId
|
|
? activeSessionDocs.get(String(t.activeSessionId)) ?? null
|
|
: null;
|
|
const activeSession = activeSessionDoc
|
|
? {
|
|
id: activeSessionDoc._id,
|
|
agentId: activeSessionDoc.agentId,
|
|
startedAt: activeSessionDoc.startedAt,
|
|
workType: activeSessionDoc.workType ?? "INTERNAL",
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
id: t._id,
|
|
reference: t.reference,
|
|
tenantId: t.tenantId,
|
|
subject: t.subject,
|
|
summary: t.summary,
|
|
status: normalizeStatus(t.status),
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
|
|
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
|
|
csatComment:
|
|
typeof t.csatComment === "string" && t.csatComment.trim().length > 0
|
|
? t.csatComment.trim()
|
|
: null,
|
|
csatRatedAt: t.csatRatedAt ?? null,
|
|
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
|
formTemplate: t.formTemplate ?? null,
|
|
formTemplateLabel: resolveFormTemplateLabel(
|
|
t.formTemplate ?? null,
|
|
t.formTemplateLabel ?? null,
|
|
),
|
|
company: companySummary,
|
|
requester: requesterSummary,
|
|
assignee: assigneeSummary,
|
|
slaPolicy: null,
|
|
dueAt: t.dueAt ?? null,
|
|
firstResponseAt: t.firstResponseAt ?? null,
|
|
resolvedAt: t.resolvedAt ?? null,
|
|
updatedAt: t.updatedAt,
|
|
createdAt: t.createdAt,
|
|
tags: t.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
category: categorySummary,
|
|
subcategory: subcategorySummary,
|
|
machine: machineSummary,
|
|
workSummary: {
|
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession,
|
|
},
|
|
};
|
|
});
|
|
|
|
// sort by updatedAt desc
|
|
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
},
|
|
});
|
|
|
|
export const getById = query({
|
|
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
|
|
handler: async (ctx, { tenantId, id, viewerId }) => {
|
|
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
|
const t = await ctx.db.get(id);
|
|
if (!t || t.tenantId !== tenantId) return null;
|
|
if (role === "COLLABORATOR") {
|
|
const isOwnerById = String(t.requesterId) === String(viewerId)
|
|
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
|
|
const viewerEmail = user.email.trim().toLowerCase()
|
|
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
|
|
if (!isOwnerById && !isOwnerByEmail) {
|
|
return null
|
|
}
|
|
}
|
|
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
|
let requester: Doc<"users"> | null = null
|
|
if (role === "MANAGER") {
|
|
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
|
|
}
|
|
if (!requester) {
|
|
requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
|
|
}
|
|
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
|
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
|
const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
|
|
const machineSnapshot = t.machineSnapshot as
|
|
| {
|
|
hostname?: string
|
|
persona?: string
|
|
assignedUserName?: string
|
|
assignedUserEmail?: string
|
|
status?: string
|
|
}
|
|
| undefined;
|
|
let machineSummary:
|
|
| {
|
|
id: Id<"machines"> | null
|
|
hostname: string | null
|
|
persona: string | null
|
|
assignedUserName: string | null
|
|
assignedUserEmail: string | null
|
|
status: string | null
|
|
}
|
|
| null = null;
|
|
if (t.machineId) {
|
|
const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null;
|
|
machineSummary = {
|
|
id: t.machineId,
|
|
hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
|
|
persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
|
|
assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
|
|
assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
|
|
status: machineDoc?.status ?? machineSnapshot?.status ?? null,
|
|
};
|
|
} else if (machineSnapshot) {
|
|
machineSummary = {
|
|
id: null,
|
|
hostname: machineSnapshot.hostname ?? null,
|
|
persona: machineSnapshot.persona ?? null,
|
|
assignedUserName: machineSnapshot.assignedUserName ?? null,
|
|
assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
|
|
status: machineSnapshot.status ?? null,
|
|
};
|
|
}
|
|
const queueName = normalizeQueueName(queue);
|
|
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
|
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
|
.collect();
|
|
const canViewInternalComments = role === "ADMIN" || role === "AGENT";
|
|
const visibleComments = canViewInternalComments
|
|
? comments
|
|
: comments.filter((comment) => comment.visibility !== "INTERNAL");
|
|
const visibleCommentKeys = new Set(
|
|
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
|
|
)
|
|
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
|
|
const serverNow = Date.now()
|
|
|
|
let timelineRecords = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
|
.collect();
|
|
|
|
if (!(role === "ADMIN" || role === "AGENT")) {
|
|
timelineRecords = timelineRecords.filter((event) => {
|
|
const payload = (event.payload ?? {}) as Record<string, unknown>
|
|
switch (event.type) {
|
|
case "CREATED":
|
|
return true
|
|
case "QUEUE_CHANGED":
|
|
return true
|
|
case "ASSIGNEE_CHANGED":
|
|
return true
|
|
case "CATEGORY_CHANGED":
|
|
return true
|
|
case "COMMENT_ADDED": {
|
|
const authorIdRaw = (payload as { authorId?: string }).authorId
|
|
if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) {
|
|
const key = `${event.createdAt}:${authorIdRaw}`
|
|
if (visibleCommentKeys.has(key)) {
|
|
return true
|
|
}
|
|
}
|
|
return visibleCommentTimestamps.has(event.createdAt)
|
|
}
|
|
case "STATUS_CHANGED": {
|
|
const toLabelRaw = (payload as { toLabel?: string }).toLabel
|
|
const toRaw = (payload as { to?: string }).to
|
|
const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0
|
|
? toLabelRaw.trim()
|
|
: typeof toRaw === "string"
|
|
? toRaw.trim()
|
|
: "").toUpperCase()
|
|
if (!normalized) return false
|
|
return (
|
|
normalized === "RESOLVED" ||
|
|
normalized === "RESOLVIDO" ||
|
|
normalized === "CLOSED" ||
|
|
normalized === "FINALIZADO" ||
|
|
normalized === "FINALIZED"
|
|
)
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
const customFieldsRecord = mapCustomFieldsToRecord(
|
|
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
|
);
|
|
|
|
const commentsHydrated = await Promise.all(
|
|
visibleComments.map(async (c) => {
|
|
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
|
|
const attachments = await Promise.all(
|
|
(c.attachments ?? []).map(async (att) => ({
|
|
id: att.storageId,
|
|
name: att.name,
|
|
size: att.size,
|
|
type: att.type,
|
|
url: await ctx.storage.getUrl(att.storageId),
|
|
}))
|
|
);
|
|
const authorSummary = buildCommentAuthorSummary(c, author, {
|
|
ticketId: t._id,
|
|
commentId: c._id,
|
|
});
|
|
return {
|
|
id: c._id,
|
|
author: authorSummary,
|
|
visibility: c.visibility,
|
|
body: c.body,
|
|
attachments,
|
|
createdAt: c.createdAt,
|
|
updatedAt: c.updatedAt,
|
|
};
|
|
})
|
|
);
|
|
|
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
|
const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow);
|
|
|
|
return {
|
|
id: t._id,
|
|
reference: t.reference,
|
|
tenantId: t.tenantId,
|
|
subject: t.subject,
|
|
summary: t.summary,
|
|
status: normalizeStatus(t.status),
|
|
priority: t.priority,
|
|
channel: t.channel,
|
|
queue: queueName,
|
|
company: company
|
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
|
: t.companyId || t.companySnapshot
|
|
? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
|
|
: null,
|
|
requester: requester
|
|
? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
|
|
: buildRequesterFromSnapshot(
|
|
t.requesterId,
|
|
t.requesterSnapshot ?? undefined,
|
|
{ ticketId: t._id }
|
|
),
|
|
assignee: t.assigneeId
|
|
? assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
|
|
: null,
|
|
slaPolicy: null,
|
|
dueAt: t.dueAt ?? null,
|
|
firstResponseAt: t.firstResponseAt ?? null,
|
|
resolvedAt: t.resolvedAt ?? null,
|
|
updatedAt: t.updatedAt,
|
|
createdAt: t.createdAt,
|
|
tags: t.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
|
|
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
|
|
csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
|
|
csatRatedAt: t.csatRatedAt ?? null,
|
|
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
|
machine: machineSummary,
|
|
category: category
|
|
? {
|
|
id: category._id,
|
|
name: category.name,
|
|
}
|
|
: null,
|
|
subcategory: subcategory
|
|
? {
|
|
id: subcategory._id,
|
|
name: subcategory.name,
|
|
categoryId: subcategory.categoryId,
|
|
}
|
|
: null,
|
|
workSummary: {
|
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
|
internalWorkedMs: t.internalWorkedMs ?? 0,
|
|
externalWorkedMs: t.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
workType: activeSession.workType ?? "INTERNAL",
|
|
}
|
|
: null,
|
|
perAgentTotals: perAgentTotals.map((item) => ({
|
|
agentId: item.agentId,
|
|
agentName: item.agentName,
|
|
agentEmail: item.agentEmail,
|
|
avatarUrl: item.avatarUrl,
|
|
totalWorkedMs: item.totalWorkedMs,
|
|
internalWorkedMs: item.internalWorkedMs,
|
|
externalWorkedMs: item.externalWorkedMs,
|
|
})),
|
|
},
|
|
formTemplate: t.formTemplate ?? null,
|
|
formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null),
|
|
chatEnabled: Boolean(t.chatEnabled),
|
|
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
|
|
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
|
reopenDeadline: t.reopenDeadline ?? null,
|
|
reopenedAt: t.reopenedAt ?? null,
|
|
description: undefined,
|
|
customFields: customFieldsRecord,
|
|
timeline: timelineRecords.map((ev) => {
|
|
let payload = ev.payload;
|
|
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
|
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
|
if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
|
|
payload = { ...payload, queueName: normalized };
|
|
}
|
|
}
|
|
return {
|
|
id: ev._id,
|
|
type: ev.type,
|
|
payload,
|
|
createdAt: ev.createdAt,
|
|
};
|
|
}),
|
|
comments: commentsHydrated,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
actorId: v.id("users"),
|
|
tenantId: v.string(),
|
|
subject: v.string(),
|
|
summary: v.optional(v.string()),
|
|
priority: v.string(),
|
|
channel: v.string(),
|
|
queueId: v.optional(v.id("queues")),
|
|
requesterId: v.id("users"),
|
|
assigneeId: v.optional(v.id("users")),
|
|
categoryId: v.id("ticketCategories"),
|
|
subcategoryId: v.id("ticketSubcategories"),
|
|
machineId: v.optional(v.id("machines")),
|
|
customFields: v.optional(
|
|
v.array(
|
|
v.object({
|
|
fieldId: v.id("ticketFields"),
|
|
value: v.any(),
|
|
})
|
|
)
|
|
),
|
|
formTemplate: v.optional(v.string()),
|
|
chatEnabled: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
|
// no customer role; managers validated below
|
|
|
|
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
|
|
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
|
}
|
|
|
|
let initialAssigneeId: Id<"users"> | undefined
|
|
let initialAssignee: Doc<"users"> | null = null
|
|
|
|
if (args.assigneeId) {
|
|
const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null
|
|
if (!assignee || assignee.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase()
|
|
if (!STAFF_ROLES.has(normalizedAssigneeRole)) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
initialAssigneeId = assignee._id
|
|
initialAssignee = assignee
|
|
} else if (role && INTERNAL_STAFF_ROLES.has(role)) {
|
|
initialAssigneeId = actorUser._id
|
|
initialAssignee = actorUser
|
|
}
|
|
|
|
const subject = args.subject.trim();
|
|
if (subject.length < 3) {
|
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
|
}
|
|
if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
|
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
|
|
}
|
|
const category = await ctx.db.get(args.categoryId);
|
|
if (!category || category.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Categoria inválida");
|
|
}
|
|
const subcategory = await ctx.db.get(args.subcategoryId);
|
|
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Subcategoria inválida");
|
|
}
|
|
|
|
const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
|
|
if (!requester || requester.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Solicitante inválido")
|
|
}
|
|
if (role === "MANAGER") {
|
|
if (!actorUser.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
if (requester.companyId !== actorUser.companyId) {
|
|
throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
|
|
}
|
|
}
|
|
|
|
let machineDoc: Doc<"machines"> | null = null
|
|
if (args.machineId) {
|
|
const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
|
|
if (!machine || machine.tenantId !== args.tenantId) {
|
|
throw new ConvexError("Dispositivo inválida para este chamado")
|
|
}
|
|
machineDoc = machine
|
|
}
|
|
|
|
let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
|
|
let formTemplateLabel: string | null = null;
|
|
if (formTemplateKey) {
|
|
const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey);
|
|
if (templateDoc && templateDoc.isArchived !== true) {
|
|
formTemplateLabel = templateDoc.label;
|
|
} else {
|
|
const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey);
|
|
if (fallbackTemplate) {
|
|
formTemplateLabel = fallbackTemplate.label;
|
|
} else {
|
|
formTemplateKey = null;
|
|
}
|
|
}
|
|
}
|
|
const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
|
|
const normalizedCustomFields = await normalizeCustomFieldValues(
|
|
ctx,
|
|
args.tenantId,
|
|
args.customFields ?? undefined,
|
|
formTemplateKey,
|
|
);
|
|
// compute next reference (simple monotonic counter per tenant)
|
|
const existing = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
|
|
.order("desc")
|
|
.take(1);
|
|
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
|
const now = Date.now();
|
|
const initialStatus: TicketStatusNormalized = "PENDING";
|
|
const requesterSnapshot = {
|
|
name: requester.name,
|
|
email: requester.email,
|
|
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)
|
|
if (candidateCompany && candidateCompany.tenantId === args.tenantId) {
|
|
companyDoc = candidateCompany as Doc<"companies">
|
|
}
|
|
}
|
|
const companySnapshot = companyDoc
|
|
? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
|
|
: undefined
|
|
|
|
const assigneeSnapshot = initialAssignee
|
|
? {
|
|
name: initialAssignee.name,
|
|
email: initialAssignee.email,
|
|
avatarUrl: initialAssignee.avatarUrl ?? undefined,
|
|
teams: initialAssignee.teams ?? undefined,
|
|
}
|
|
: undefined
|
|
|
|
// default queue: if none provided, prefer "Chamados"
|
|
let resolvedQueueId = args.queueId as Id<"queues"> | undefined
|
|
if (!resolvedQueueId) {
|
|
const queues = await ctx.db
|
|
.query("queues")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
|
.collect()
|
|
const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
|
|
if (preferred) {
|
|
resolvedQueueId = preferred._id as Id<"queues">
|
|
}
|
|
}
|
|
|
|
const slaFields = applySlaSnapshot(slaSnapshot, now)
|
|
const id = await ctx.db.insert("tickets", {
|
|
tenantId: args.tenantId,
|
|
reference: nextRef,
|
|
subject,
|
|
summary: args.summary?.trim() || undefined,
|
|
status: initialStatus,
|
|
priority: args.priority,
|
|
channel: args.channel,
|
|
queueId: resolvedQueueId,
|
|
categoryId: args.categoryId,
|
|
subcategoryId: args.subcategoryId,
|
|
requesterId: args.requesterId,
|
|
requesterSnapshot,
|
|
assigneeId: initialAssigneeId,
|
|
assigneeSnapshot,
|
|
companyId: companyDoc?._id ?? requester.companyId ?? undefined,
|
|
companySnapshot,
|
|
machineId: machineDoc?._id ?? undefined,
|
|
machineSnapshot: machineDoc
|
|
? {
|
|
hostname: machineDoc.hostname ?? undefined,
|
|
persona: machineDoc.persona ?? undefined,
|
|
assignedUserName: machineDoc.assignedUserName ?? undefined,
|
|
assignedUserEmail: machineDoc.assignedUserEmail ?? undefined,
|
|
status: machineDoc.status ?? undefined,
|
|
}
|
|
: undefined,
|
|
formTemplate: formTemplateKey ?? undefined,
|
|
formTemplateLabel: formTemplateLabel ?? undefined,
|
|
chatEnabled,
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
totalWorkedMs: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
firstResponseAt: undefined,
|
|
resolvedAt: undefined,
|
|
closedAt: undefined,
|
|
tags: [],
|
|
slaPolicyId: undefined,
|
|
dueAt: undefined,
|
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
|
...slaFields,
|
|
});
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: id,
|
|
type: "CREATED",
|
|
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
|
|
if (initialAssigneeId && initialAssignee) {
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: id,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: {
|
|
assigneeId: initialAssigneeId,
|
|
assigneeName: initialAssignee.name,
|
|
actorId: args.actorId,
|
|
actorName: actorUser.name,
|
|
actorAvatar: actorUser.avatarUrl ?? undefined,
|
|
previousAssigneeId: null,
|
|
previousAssigneeName: "Não atribuído",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const addComment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
authorId: v.id("users"),
|
|
visibility: v.string(),
|
|
body: v.string(),
|
|
attachments: v.optional(
|
|
v.array(
|
|
v.object({
|
|
storageId: v.id("_storage"),
|
|
name: v.string(),
|
|
size: v.optional(v.number()),
|
|
type: v.optional(v.string()),
|
|
})
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ticket = await ctx.db.get(args.ticketId);
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
|
|
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
|
|
if (!author || author.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
|
|
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
|
|
|
const requestedVisibility = (args.visibility ?? "").toUpperCase()
|
|
if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
|
|
throw new ConvexError("Visibilidade inválida")
|
|
}
|
|
|
|
if (normalizedRole === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, author, ticketDoc)
|
|
if (requestedVisibility !== "PUBLIC") {
|
|
throw new ConvexError("Gestores só podem registrar comentários públicos")
|
|
}
|
|
}
|
|
const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
|
|
if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
|
|
throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
|
|
}
|
|
|
|
// Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável.
|
|
// O solicitante (colaborador) pode comentar sempre.
|
|
const isRequester = String(ticketDoc.requesterId) === String(author._id)
|
|
const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
|
|
const hasAssignee = Boolean(ticketDoc.assigneeId)
|
|
// Gestores podem comentar mesmo sem responsável; admin/agent só com responsável
|
|
if (!isRequester && isAdminOrAgent && !hasAssignee) {
|
|
throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.")
|
|
}
|
|
|
|
if (ticketDoc.requesterId === args.authorId) {
|
|
// O próprio solicitante pode comentar seu ticket.
|
|
// Comentários internos já são bloqueados acima para quem não é STAFF.
|
|
// Portanto, nada a fazer aqui.
|
|
} else {
|
|
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
|
}
|
|
|
|
const attachments = args.attachments ?? []
|
|
if (attachments.length > 5) {
|
|
throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
|
|
}
|
|
const maxAttachmentSize = 5 * 1024 * 1024
|
|
for (const attachment of attachments) {
|
|
if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
|
|
throw new ConvexError("Cada anexo pode ter até 5MB")
|
|
}
|
|
}
|
|
|
|
const authorSnapshot: CommentAuthorSnapshot = {
|
|
name: author.name,
|
|
email: author.email,
|
|
avatarUrl: author.avatarUrl ?? undefined,
|
|
teams: author.teams ?? undefined,
|
|
};
|
|
|
|
const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId)
|
|
const bodyPlainLen = plainTextLength(normalizedBody)
|
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
}
|
|
|
|
const now = Date.now();
|
|
const id = await ctx.db.insert("ticketComments", {
|
|
ticketId: args.ticketId,
|
|
authorId: args.authorId,
|
|
visibility: requestedVisibility,
|
|
body: normalizedBody,
|
|
authorSnapshot,
|
|
attachments,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: args.ticketId,
|
|
type: "COMMENT_ADDED",
|
|
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
|
|
createdAt: 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
|
|
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
|
if (typeof schedulerRunAfter === "function") {
|
|
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
|
to: snapshotEmail,
|
|
ticketId: String(ticketDoc._id),
|
|
reference: ticketDoc.reference ?? 0,
|
|
subject: ticketDoc.subject ?? "",
|
|
})
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
|
|
}
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const updateComment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
commentId: v.id("ticketComments"),
|
|
actorId: v.id("users"),
|
|
body: v.string(),
|
|
},
|
|
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
const comment = await ctx.db.get(commentId);
|
|
if (!comment || comment.ticketId !== ticketId) {
|
|
throw new ConvexError("Comentário não encontrado");
|
|
}
|
|
if (comment.authorId !== actorId) {
|
|
throw new ConvexError("Você não tem permissão para editar este comentário");
|
|
}
|
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
|
if (ticketDoc.requesterId === actorId) {
|
|
if (STAFF_ROLES.has(normalizedRole)) {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
} else {
|
|
throw new ConvexError("Autor não possui permissão para editar")
|
|
}
|
|
} else {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
}
|
|
|
|
const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId)
|
|
const bodyPlainLen = plainTextLength(normalizedBody)
|
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(commentId, {
|
|
body: normalizedBody,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "COMMENT_EDITED",
|
|
payload: {
|
|
commentId,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
|
},
|
|
});
|
|
|
|
export const removeCommentAttachment = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
commentId: v.id("ticketComments"),
|
|
attachmentId: v.id("_storage"),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
if (!actor || actor.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Autor do comentário inválido")
|
|
}
|
|
const comment = await ctx.db.get(commentId);
|
|
if (!comment || comment.ticketId !== ticketId) {
|
|
throw new ConvexError("Comentário não encontrado");
|
|
}
|
|
if (comment.authorId !== actorId) {
|
|
throw new ConvexError("Você não pode alterar anexos de outro usuário")
|
|
}
|
|
|
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
|
if (ticketDoc.requesterId === actorId) {
|
|
if (STAFF_ROLES.has(normalizedRole)) {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
} else {
|
|
throw new ConvexError("Autor não possui permissão para alterar anexos")
|
|
}
|
|
} else {
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
}
|
|
|
|
const attachments = comment.attachments ?? [];
|
|
const target = attachments.find((att) => att.storageId === attachmentId);
|
|
if (!target) {
|
|
throw new ConvexError("Anexo não encontrado");
|
|
}
|
|
|
|
await ctx.storage.delete(attachmentId);
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(commentId, {
|
|
attachments: attachments.filter((att) => att.storageId !== attachmentId),
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ATTACHMENT_REMOVED",
|
|
payload: {
|
|
attachmentId,
|
|
attachmentName: target.name,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
|
},
|
|
});
|
|
|
|
export const updateStatus = mutation({
|
|
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, status, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const normalizedStatus = normalizeStatus(status)
|
|
if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) {
|
|
throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
|
|
}
|
|
const now = Date.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",
|
|
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export async function resolveTicketHandler(
|
|
ctx: MutationCtx,
|
|
{ ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: {
|
|
ticketId: Id<"tickets">
|
|
actorId: Id<"users">
|
|
resolvedWithTicketId?: Id<"tickets">
|
|
relatedTicketIds?: Id<"tickets">[]
|
|
reopenWindowDays?: number | null
|
|
}
|
|
) {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const now = Date.now()
|
|
|
|
const baseRelated = new Set<string>()
|
|
for (const rel of relatedTicketIds ?? []) {
|
|
if (String(rel) === String(ticketId)) continue
|
|
baseRelated.add(String(rel))
|
|
}
|
|
if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) {
|
|
baseRelated.add(String(resolvedWithTicketId))
|
|
}
|
|
|
|
const linkedTickets: Doc<"tickets">[] = []
|
|
for (const id of baseRelated) {
|
|
const related = await ctx.db.get(id as Id<"tickets">)
|
|
if (!related) continue
|
|
if (related.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Chamado vinculado pertence a outro tenant")
|
|
}
|
|
linkedTickets.push(related as Doc<"tickets">)
|
|
}
|
|
|
|
const resolvedWith =
|
|
resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)
|
|
? (await ctx.db.get(resolvedWithTicketId)) ?? null
|
|
: null
|
|
if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Chamado vinculado pertence a outro tenant")
|
|
}
|
|
if (resolvedWithTicketId && !resolvedWith) {
|
|
throw new ConvexError("Chamado vinculado não encontrado")
|
|
}
|
|
|
|
const reopenDays = resolveReopenWindowDays(reopenWindowDays)
|
|
const reopenDeadline = computeReopenDeadline(now, reopenDays)
|
|
const normalizedStatus = "RESOLVED"
|
|
const relatedIdList = Array.from(
|
|
new Set<string>(
|
|
linkedTickets.map((rel) => String(rel._id)),
|
|
),
|
|
).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,
|
|
closedAt: now,
|
|
updatedAt: now,
|
|
reopenDeadline,
|
|
reopenedAt: undefined,
|
|
resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined,
|
|
relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
|
|
activeSessionId: undefined,
|
|
working: false,
|
|
...slaPausePatch,
|
|
...slaSolutionPatch,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "STATUS_CHANGED",
|
|
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
|
createdAt: now,
|
|
})
|
|
|
|
// Notificação por e-mail: encerramento do chamado
|
|
try {
|
|
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
|
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
|
if (email) {
|
|
const schedulerRunAfter = ctx.scheduler?.runAfter
|
|
if (typeof schedulerRunAfter === "function") {
|
|
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
|
to: email,
|
|
ticketId: String(ticketId),
|
|
reference: ticketDoc.reference ?? 0,
|
|
subject: ticketDoc.subject ?? "",
|
|
})
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
|
|
}
|
|
|
|
for (const rel of linkedTickets) {
|
|
const existing = new Set<string>((rel.relatedTicketIds ?? []).map((value) => String(value)))
|
|
existing.add(String(ticketId))
|
|
await ctx.db.patch(rel._id, {
|
|
relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">),
|
|
updatedAt: now,
|
|
})
|
|
const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related"
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "TICKET_LINKED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
linkedTicketId: rel._id,
|
|
linkedReference: rel.reference ?? null,
|
|
linkedSubject: rel.subject ?? null,
|
|
kind: linkKind,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: rel._id,
|
|
type: "TICKET_LINKED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
linkedTicketId: ticketId,
|
|
linkedReference: ticketDoc.reference ?? null,
|
|
linkedSubject: ticketDoc.subject ?? null,
|
|
kind: linkKind === "resolved_with" ? "resolution_parent" : "related",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
|
|
}
|
|
|
|
export const resolveTicket = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
resolvedWithTicketId: v.optional(v.id("tickets")),
|
|
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
|
reopenWindowDays: v.optional(v.number()),
|
|
},
|
|
handler: resolveTicketHandler,
|
|
})
|
|
|
|
export async function reopenTicketHandler(
|
|
ctx: MutationCtx,
|
|
{ ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> }
|
|
) {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId)
|
|
const normalizedRole = viewer.role ?? ""
|
|
const now = Date.now()
|
|
const status = normalizeStatus(ticketDoc.status)
|
|
if (status !== "RESOLVED") {
|
|
throw new ConvexError("Somente chamados resolvidos podem ser reabertos")
|
|
}
|
|
if (!isWithinReopenWindow(ticketDoc, now)) {
|
|
throw new ConvexError("O prazo para reabrir este chamado expirou")
|
|
}
|
|
if (normalizedRole === "COLLABORATOR") {
|
|
if (String(ticketDoc.requesterId) !== String(actorId)) {
|
|
throw new ConvexError("Somente o solicitante pode reabrir este chamado")
|
|
}
|
|
} else if (normalizedRole === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc)
|
|
} else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
|
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", {
|
|
ticketId,
|
|
type: "TICKET_REOPENED",
|
|
payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole },
|
|
createdAt: now,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "STATUS_CHANGED",
|
|
payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId },
|
|
createdAt: now,
|
|
})
|
|
|
|
return { ok: true, reopenedAt: now }
|
|
}
|
|
|
|
export const reopenTicket = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: reopenTicketHandler,
|
|
})
|
|
|
|
export const changeAssignee = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
assigneeId: v.id("users"),
|
|
actorId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const viewerUser = viewer.user
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
|
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Responsável inválido")
|
|
}
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem reatribuir chamados")
|
|
}
|
|
const normalizedStatus = normalizeStatus(ticketDoc.status)
|
|
if (normalizedStatus === "AWAITING_ATTENDANCE" || ticketDoc.activeSessionId) {
|
|
throw new ConvexError("Pause o atendimento antes de reatribuir o chamado")
|
|
}
|
|
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
|
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
|
|
}
|
|
|
|
const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim()
|
|
if (normalizedReason.length > 0 && normalizedReason.length < 5) {
|
|
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco")
|
|
}
|
|
if (normalizedReason.length > 1000) {
|
|
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
|
|
}
|
|
const previousAssigneeName =
|
|
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
|
|
"Não atribuído"
|
|
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
|
|
|
|
const now = Date.now();
|
|
const assigneeSnapshot = {
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl ?? undefined,
|
|
teams: assignee.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(ticketId, { assigneeId, assigneeSnapshot, updatedAt: now });
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: {
|
|
assigneeId,
|
|
assigneeName: assignee.name,
|
|
actorId,
|
|
actorName: viewerUser.name,
|
|
actorAvatar: viewerUser.avatarUrl ?? undefined,
|
|
previousAssigneeId: currentAssigneeId,
|
|
previousAssigneeName,
|
|
reason: normalizedReason.length > 0 ? normalizedReason : undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
if (normalizedReason.length > 0) {
|
|
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
|
previousName: previousAssigneeName,
|
|
nextName: nextAssigneeName,
|
|
})
|
|
const commentPlainLength = plainTextLength(commentBody)
|
|
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
}
|
|
const authorSnapshot: CommentAuthorSnapshot = {
|
|
name: viewerUser.name,
|
|
email: viewerUser.email,
|
|
avatarUrl: viewerUser.avatarUrl ?? undefined,
|
|
teams: viewerUser.teams ?? undefined,
|
|
}
|
|
await ctx.db.insert("ticketComments", {
|
|
ticketId,
|
|
authorId: actorId,
|
|
visibility: "INTERNAL",
|
|
body: commentBody,
|
|
authorSnapshot,
|
|
attachments: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "COMMENT_ADDED",
|
|
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
|
|
createdAt: now,
|
|
})
|
|
}
|
|
},
|
|
});
|
|
|
|
export const listChatMessages = query({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
viewerId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { ticketId, viewerId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
await requireTicketChatParticipant(ctx, viewerId, ticketDoc)
|
|
const now = Date.now()
|
|
const status = normalizeStatus(ticketDoc.status)
|
|
const chatEnabled = Boolean(ticketDoc.chatEnabled)
|
|
const withinWindow = isWithinReopenWindow(ticketDoc, now)
|
|
const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow)
|
|
const messages = await ctx.db
|
|
.query("ticketChatMessages")
|
|
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
|
.collect()
|
|
|
|
return {
|
|
ticketId: String(ticketId),
|
|
chatEnabled,
|
|
status,
|
|
canPost,
|
|
reopenDeadline: ticketDoc.reopenDeadline ?? null,
|
|
messages: messages
|
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
.map((message) => ({
|
|
id: message._id,
|
|
body: message.body,
|
|
createdAt: message.createdAt,
|
|
updatedAt: message.updatedAt,
|
|
authorId: String(message.authorId),
|
|
authorName: message.authorSnapshot?.name ?? null,
|
|
authorEmail: message.authorSnapshot?.email ?? null,
|
|
attachments: (message.attachments ?? []).map((attachment) => ({
|
|
storageId: attachment.storageId,
|
|
name: attachment.name,
|
|
size: attachment.size ?? null,
|
|
type: attachment.type ?? null,
|
|
})),
|
|
readBy: (message.readBy ?? []).map((entry) => ({
|
|
userId: String(entry.userId),
|
|
readAt: entry.readAt,
|
|
})),
|
|
})),
|
|
}
|
|
},
|
|
})
|
|
|
|
export const listTicketForms = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
companyId: v.optional(v.id("companies")),
|
|
},
|
|
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
|
const viewer = await requireUser(ctx, viewerId, tenantId)
|
|
const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
|
|
const viewerRole = (viewer.role ?? "").toUpperCase()
|
|
const templates = await fetchTemplateSummaries(ctx, tenantId)
|
|
|
|
const scopes = templates.map((template) => template.key)
|
|
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)
|
|
|
|
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
|
|
const settingsByTemplate = staffOverride
|
|
? new Map<string, Doc<"ticketFormSettings">[]>()
|
|
: await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId)
|
|
|
|
const forms = [] as Array<{
|
|
key: string
|
|
label: string
|
|
description: string
|
|
fields: Array<{
|
|
id: Id<"ticketFields">
|
|
key: string
|
|
label: string
|
|
type: string
|
|
required: boolean
|
|
description: string
|
|
options: { value: string; label: string }[]
|
|
}>
|
|
}>
|
|
|
|
for (const template of templates) {
|
|
const templateSettings = settingsByTemplate.get(template.key) ?? []
|
|
let enabled = staffOverride
|
|
? true
|
|
: resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, {
|
|
companyId: viewerCompanyId,
|
|
userId: viewer.user._id,
|
|
})
|
|
if (!enabled) {
|
|
continue
|
|
}
|
|
const scopedFields = fieldsByScope.get(template.key) ?? []
|
|
forms.push({
|
|
key: template.key,
|
|
label: template.label,
|
|
description: template.description,
|
|
fields: scopedFields
|
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
.map((field) => ({
|
|
id: field._id,
|
|
key: field.key,
|
|
label: field.label,
|
|
type: field.type,
|
|
required: Boolean(field.required),
|
|
description: field.description ?? "",
|
|
options: field.options ?? [],
|
|
})),
|
|
})
|
|
}
|
|
|
|
return forms
|
|
},
|
|
})
|
|
|
|
export const findByReference = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
reference: v.number(),
|
|
},
|
|
handler: async (ctx, { tenantId, viewerId, reference }) => {
|
|
const viewer = await requireUser(ctx, viewerId, tenantId)
|
|
const ticket = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference))
|
|
.first()
|
|
if (!ticket) {
|
|
return null
|
|
}
|
|
const normalizedRole = viewer.role ?? ""
|
|
if (normalizedRole === "MANAGER") {
|
|
await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">)
|
|
} else if (normalizedRole === "COLLABORATOR") {
|
|
if (String(ticket.requesterId) !== String(viewer.user._id)) {
|
|
return null
|
|
}
|
|
} else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
|
return null
|
|
}
|
|
return {
|
|
id: ticket._id,
|
|
reference: ticket.reference,
|
|
subject: ticket.subject,
|
|
status: ticket.status,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const postChatMessage = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
body: v.string(),
|
|
attachments: v.optional(
|
|
v.array(
|
|
v.object({
|
|
storageId: v.id("_storage"),
|
|
name: v.string(),
|
|
size: v.optional(v.number()),
|
|
type: v.optional(v.string()),
|
|
})
|
|
)
|
|
),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, body, attachments }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
if (!ticketDoc.chatEnabled) {
|
|
throw new ConvexError("Chat não habilitado para este chamado")
|
|
}
|
|
const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc)
|
|
const now = Date.now()
|
|
if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") {
|
|
throw new ConvexError("O chat deste chamado está encerrado")
|
|
}
|
|
|
|
const trimmedBody = body.replace(/\r\n/g, "\n").trim()
|
|
if (trimmedBody.length === 0) {
|
|
throw new ConvexError("Digite uma mensagem para enviar no chat")
|
|
}
|
|
if (trimmedBody.length > 4000) {
|
|
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
|
|
}
|
|
|
|
const files = attachments ?? []
|
|
if (files.length > 5) {
|
|
throw new ConvexError("Envie até 5 arquivos por mensagem")
|
|
}
|
|
const maxAttachmentSize = 5 * 1024 * 1024
|
|
for (const file of files) {
|
|
if (typeof file.size === "number" && file.size > maxAttachmentSize) {
|
|
throw new ConvexError("Cada arquivo pode ter até 5MB")
|
|
}
|
|
}
|
|
|
|
const normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId)
|
|
const plainLength = plainTextLength(normalizedBody)
|
|
if (plainLength === 0) {
|
|
throw new ConvexError("A mensagem está vazia após a formatação")
|
|
}
|
|
if (plainLength > 4000) {
|
|
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
|
|
}
|
|
|
|
const authorSnapshot: CommentAuthorSnapshot = {
|
|
name: participant.user.name,
|
|
email: participant.user.email,
|
|
avatarUrl: participant.user.avatarUrl ?? undefined,
|
|
teams: participant.user.teams ?? undefined,
|
|
}
|
|
|
|
const messageId = await ctx.db.insert("ticketChatMessages", {
|
|
ticketId,
|
|
tenantId: ticketDoc.tenantId,
|
|
companyId: ticketDoc.companyId ?? undefined,
|
|
authorId: actorId,
|
|
authorSnapshot,
|
|
body: normalizedBody,
|
|
attachments: files,
|
|
notifiedAt: undefined,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
readBy: [{ userId: actorId, readAt: now }],
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CHAT_MESSAGE_ADDED",
|
|
payload: {
|
|
messageId,
|
|
authorId: actorId,
|
|
authorName: participant.user.name,
|
|
actorRole: participant.role ?? null,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
await ctx.db.patch(ticketId, { updatedAt: now })
|
|
|
|
return { ok: true, messageId }
|
|
},
|
|
})
|
|
|
|
export const markChatRead = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
messageIds: v.array(v.id("ticketChatMessages")),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, messageIds }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
await requireTicketChatParticipant(ctx, actorId, ticketDoc)
|
|
const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id))))
|
|
const now = Date.now()
|
|
for (const id of uniqueIds) {
|
|
const message = await ctx.db.get(id as Id<"ticketChatMessages">)
|
|
if (!message || String(message.ticketId) !== String(ticketId)) {
|
|
continue
|
|
}
|
|
const readBy = new Map<string, { userId: Id<"users">; readAt: number }>()
|
|
for (const entry of message.readBy ?? []) {
|
|
readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt })
|
|
}
|
|
readBy.set(String(actorId), { userId: actorId, readAt: now })
|
|
await ctx.db.patch(id as Id<"ticketChatMessages">, {
|
|
readBy: Array.from(readBy.values()),
|
|
updatedAt: now,
|
|
})
|
|
}
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
export const ensureTicketFormDefaults = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId }) => {
|
|
await requireUser(ctx, actorId, tenantId);
|
|
await ensureTicketFormDefaultsForTenant(ctx, tenantId);
|
|
return { ok: true };
|
|
},
|
|
});
|
|
|
|
export async function submitCsatHandler(
|
|
ctx: MutationCtx,
|
|
{ ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }
|
|
) {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
|
|
const normalizedStatus = normalizeStatus(ticket.status)
|
|
if (normalizedStatus !== "RESOLVED") {
|
|
throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado")
|
|
}
|
|
|
|
const viewer = await requireUser(ctx, actorId, ticket.tenantId)
|
|
const normalizedRole = (viewer.role ?? "").toUpperCase()
|
|
if (normalizedRole !== "COLLABORATOR") {
|
|
throw new ConvexError("Somente o solicitante pode avaliar o chamado")
|
|
}
|
|
|
|
const viewerEmail = viewer.user.email.trim().toLowerCase()
|
|
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null
|
|
const isOwnerById = String(ticket.requesterId) === String(viewer.user._id)
|
|
const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false
|
|
if (!isOwnerById && !isOwnerByEmail) {
|
|
throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado")
|
|
}
|
|
|
|
if (typeof ticket.csatScore === "number") {
|
|
throw new ConvexError("Este chamado já possui uma avaliação registrada")
|
|
}
|
|
|
|
if (!Number.isFinite(score)) {
|
|
throw new ConvexError("Pontuação inválida")
|
|
}
|
|
const resolvedMaxScore =
|
|
Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5
|
|
const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score)))
|
|
const normalizedComment =
|
|
typeof comment === "string"
|
|
? comment
|
|
.replace(/\r\n/g, "\n")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.join("\n")
|
|
.trim()
|
|
: ""
|
|
if (normalizedComment.length > 2000) {
|
|
throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)")
|
|
}
|
|
|
|
const now = Date.now()
|
|
|
|
let csatAssigneeId: Id<"users"> | undefined
|
|
let csatAssigneeSnapshot:
|
|
| {
|
|
name: string
|
|
email?: string
|
|
avatarUrl?: string
|
|
teams?: string[]
|
|
}
|
|
| undefined
|
|
|
|
if (ticket.assigneeId) {
|
|
const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null
|
|
if (assigneeDoc) {
|
|
csatAssigneeId = assigneeDoc._id
|
|
csatAssigneeSnapshot = {
|
|
name: assigneeDoc.name,
|
|
email: assigneeDoc.email,
|
|
avatarUrl: assigneeDoc.avatarUrl ?? undefined,
|
|
teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined,
|
|
}
|
|
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
|
|
const snapshot = ticket.assigneeSnapshot as {
|
|
name?: string
|
|
email?: string
|
|
avatarUrl?: string
|
|
teams?: string[]
|
|
}
|
|
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
|
|
csatAssigneeId = ticket.assigneeId
|
|
csatAssigneeSnapshot = {
|
|
name: snapshot.name,
|
|
email: snapshot.email ?? undefined,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: snapshot.teams ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
|
|
const snapshot = ticket.assigneeSnapshot as {
|
|
name?: string
|
|
email?: string
|
|
avatarUrl?: string
|
|
teams?: string[]
|
|
}
|
|
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
|
|
csatAssigneeSnapshot = {
|
|
name: snapshot.name,
|
|
email: snapshot.email ?? undefined,
|
|
avatarUrl: snapshot.avatarUrl ?? undefined,
|
|
teams: snapshot.teams ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
csatScore: normalizedScore,
|
|
csatMaxScore: resolvedMaxScore,
|
|
csatComment: normalizedComment.length > 0 ? normalizedComment : undefined,
|
|
csatRatedAt: now,
|
|
csatRatedBy: actorId,
|
|
csatAssigneeId,
|
|
csatAssigneeSnapshot,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CSAT_RATED",
|
|
payload: {
|
|
score: normalizedScore,
|
|
maxScore: resolvedMaxScore,
|
|
comment: normalizedComment.length > 0 ? normalizedComment : undefined,
|
|
ratedBy: actorId,
|
|
assigneeId: csatAssigneeId ?? null,
|
|
assigneeName: csatAssigneeSnapshot?.name ?? null,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return {
|
|
ok: true,
|
|
score: normalizedScore,
|
|
maxScore: resolvedMaxScore,
|
|
comment: normalizedComment.length > 0 ? normalizedComment : null,
|
|
ratedAt: now,
|
|
}
|
|
}
|
|
|
|
export const submitCsat = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
score: v.number(),
|
|
maxScore: v.optional(v.number()),
|
|
comment: v.optional(v.string()),
|
|
},
|
|
handler: submitCsatHandler,
|
|
})
|
|
|
|
export const changeRequester = mutation({
|
|
args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, requesterId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const viewerRole = (viewer.role ?? "AGENT").toUpperCase()
|
|
const actor = viewer.user
|
|
|
|
if (String(ticketDoc.requesterId) === String(requesterId)) {
|
|
return { status: "unchanged" }
|
|
}
|
|
|
|
const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null
|
|
if (!requester || requester.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Solicitante inválido")
|
|
}
|
|
|
|
if (viewerRole === "MANAGER") {
|
|
if (!actor.companyId) {
|
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
|
}
|
|
if (requester.companyId !== actor.companyId) {
|
|
throw new ConvexError("Gestores só podem alterar para usuários da própria empresa")
|
|
}
|
|
}
|
|
|
|
const now = Date.now()
|
|
const requesterSnapshot = {
|
|
name: requester.name,
|
|
email: requester.email,
|
|
avatarUrl: requester.avatarUrl ?? undefined,
|
|
teams: requester.teams ?? undefined,
|
|
}
|
|
|
|
let companyId: Id<"companies"> | undefined
|
|
let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined
|
|
|
|
if (requester.companyId) {
|
|
const company = await ctx.db.get(requester.companyId)
|
|
if (company) {
|
|
companyId = company._id as Id<"companies">
|
|
companySnapshot = {
|
|
name: company.name,
|
|
slug: company.slug ?? undefined,
|
|
isAvulso: company.isAvulso ?? undefined,
|
|
}
|
|
}
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {
|
|
requesterId,
|
|
requesterSnapshot,
|
|
updatedAt: now,
|
|
}
|
|
if (companyId) {
|
|
patch["companyId"] = companyId
|
|
patch["companySnapshot"] = companySnapshot
|
|
} else {
|
|
patch["companyId"] = undefined
|
|
patch["companySnapshot"] = undefined
|
|
}
|
|
|
|
await ctx.db.patch(ticketId, patch)
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "REQUESTER_CHANGED",
|
|
payload: {
|
|
requesterId,
|
|
requesterName: requester.name,
|
|
requesterEmail: requester.email,
|
|
companyId: companyId ?? null,
|
|
companyName: companySnapshot?.name ?? null,
|
|
actorId,
|
|
actorName: actor.name,
|
|
actorAvatar: actor.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "updated" }
|
|
},
|
|
})
|
|
|
|
export const purgeTicketsForUsers = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
userIds: v.array(v.id("users")),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, userIds }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
if (userIds.length === 0) {
|
|
return { deleted: 0 }
|
|
}
|
|
const uniqueIds = Array.from(new Set(userIds.map((id) => id)))
|
|
let deleted = 0
|
|
for (const userId of uniqueIds) {
|
|
const requesterTickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", userId))
|
|
.collect()
|
|
for (const ticket of requesterTickets) {
|
|
await ctx.db.delete(ticket._id)
|
|
deleted += 1
|
|
}
|
|
const assigneeTickets = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", tenantId).eq("assigneeId", userId))
|
|
.collect()
|
|
for (const ticket of assigneeTickets) {
|
|
await ctx.db.delete(ticket._id)
|
|
deleted += 1
|
|
}
|
|
}
|
|
return { deleted }
|
|
},
|
|
})
|
|
|
|
|
|
export const changeQueue = mutation({
|
|
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem alterar a fila do chamado")
|
|
}
|
|
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
|
if (!queue || queue.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Fila inválida")
|
|
}
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
|
const queueName = normalizeQueueName(queue);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "QUEUE_CHANGED",
|
|
payload: { queueId, queueName, actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateCategories = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
categoryId: v.union(v.id("ticketCategories"), v.null()),
|
|
subcategoryId: v.union(v.id("ticketSubcategories"), v.null()),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
if (viewer.role === "MANAGER") {
|
|
throw new ConvexError("Gestores não podem alterar a categorização do chamado")
|
|
}
|
|
|
|
if (categoryId === null) {
|
|
if (subcategoryId !== null) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
|
|
return { status: "unchanged" }
|
|
}
|
|
const now = Date.now()
|
|
await ctx.db.patch(ticketId, {
|
|
categoryId: undefined,
|
|
subcategoryId: undefined,
|
|
updatedAt: now,
|
|
})
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CATEGORY_CHANGED",
|
|
payload: {
|
|
categoryId: null,
|
|
categoryName: null,
|
|
subcategoryId: null,
|
|
subcategoryName: null,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
return { status: "cleared" }
|
|
}
|
|
|
|
const category = await ctx.db.get(categoryId)
|
|
if (!category || category.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Categoria inválida")
|
|
}
|
|
|
|
let subcategoryName: string | null = null
|
|
if (subcategoryId !== null) {
|
|
const subcategory = await ctx.db.get(subcategoryId)
|
|
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
|
|
throw new ConvexError("Subcategoria inválida")
|
|
}
|
|
subcategoryName = subcategory.name
|
|
}
|
|
|
|
if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
|
|
return { status: "unchanged" }
|
|
}
|
|
|
|
const now = Date.now()
|
|
await ctx.db.patch(ticketId, {
|
|
categoryId,
|
|
subcategoryId: subcategoryId ?? undefined,
|
|
updatedAt: now,
|
|
})
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CATEGORY_CHANGED",
|
|
payload: {
|
|
categoryId,
|
|
categoryName: category.name,
|
|
subcategoryId,
|
|
subcategoryName,
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "updated" }
|
|
},
|
|
})
|
|
|
|
export const workSummary = query({
|
|
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
|
|
handler: async (ctx, { ticketId, viewerId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) return null
|
|
await requireStaff(ctx, viewerId, ticket.tenantId)
|
|
|
|
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
|
const serverNow = Date.now()
|
|
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow)
|
|
return {
|
|
ticketId,
|
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
|
internalWorkedMs: ticket.internalWorkedMs ?? 0,
|
|
externalWorkedMs: ticket.externalWorkedMs ?? 0,
|
|
serverNow,
|
|
activeSession: activeSession
|
|
? {
|
|
id: activeSession._id,
|
|
agentId: activeSession.agentId,
|
|
startedAt: activeSession.startedAt,
|
|
workType: activeSession.workType ?? "INTERNAL",
|
|
}
|
|
: null,
|
|
perAgentTotals: perAgentTotals.map((item) => ({
|
|
agentId: item.agentId,
|
|
agentName: item.agentName,
|
|
agentEmail: item.agentEmail,
|
|
avatarUrl: item.avatarUrl,
|
|
totalWorkedMs: item.totalWorkedMs,
|
|
internalWorkedMs: item.internalWorkedMs,
|
|
externalWorkedMs: item.externalWorkedMs,
|
|
})),
|
|
}
|
|
},
|
|
})
|
|
|
|
export const updatePriority = mutation({
|
|
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, priority, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
|
const now = Date.now();
|
|
await ctx.db.patch(ticketId, { priority, updatedAt: now });
|
|
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "PRIORITY_CHANGED",
|
|
payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const startWork = mutation({
|
|
args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) },
|
|
handler: async (ctx, { ticketId, actorId, workType }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
const currentAssigneeId = ticketDoc.assigneeId ?? null
|
|
const now = Date.now()
|
|
|
|
if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
|
|
}
|
|
|
|
if (ticketDoc.activeSessionId) {
|
|
const session = await ctx.db.get(ticketDoc.activeSessionId)
|
|
return {
|
|
status: "already_started",
|
|
sessionId: ticketDoc.activeSessionId,
|
|
startedAt: session?.startedAt ?? now,
|
|
serverNow: now,
|
|
}
|
|
}
|
|
|
|
let assigneePatched = false
|
|
const previousAssigneeIdForStart = currentAssigneeId
|
|
const previousAssigneeNameForStart =
|
|
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? "Não atribuído"
|
|
|
|
if (!currentAssigneeId) {
|
|
const assigneeSnapshot = {
|
|
name: viewer.user.name,
|
|
email: viewer.user.email,
|
|
avatarUrl: viewer.user.avatarUrl ?? undefined,
|
|
teams: viewer.user.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(ticketId, { assigneeId: actorId, assigneeSnapshot, updatedAt: now })
|
|
ticketDoc.assigneeId = actorId
|
|
assigneePatched = true
|
|
}
|
|
|
|
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
|
ticketId,
|
|
agentId: actorId,
|
|
workType: (workType ?? "INTERNAL").toUpperCase(),
|
|
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) {
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: {
|
|
assigneeId: actorId,
|
|
assigneeName: viewer.user.name,
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
actorAvatar: viewer.user.avatarUrl ?? undefined,
|
|
previousAssigneeId: previousAssigneeIdForStart,
|
|
previousAssigneeName: previousAssigneeNameForStart,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_STARTED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
actorAvatar: viewer.user.avatarUrl,
|
|
sessionId,
|
|
workType: (workType ?? "INTERNAL").toUpperCase(),
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return { status: "started", sessionId, startedAt: now, serverNow: now }
|
|
},
|
|
})
|
|
|
|
export const pauseWork = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
reason: v.string(),
|
|
note: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, reason, note }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const isAdmin = viewer.role === "ADMIN"
|
|
if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
|
|
throw new ConvexError("Somente o responsável atual pode pausar este chamado")
|
|
}
|
|
|
|
if (!ticketDoc.activeSessionId) {
|
|
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,
|
|
type: "STATUS_CHANGED",
|
|
payload: {
|
|
to: "PAUSED",
|
|
toLabel: STATUS_LABELS.PAUSED,
|
|
actorId,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now }
|
|
}
|
|
return { status: "already_paused" }
|
|
}
|
|
|
|
if (!PAUSE_REASON_LABELS[reason]) {
|
|
throw new ConvexError("Motivo de pausa inválido")
|
|
}
|
|
|
|
const session = await ctx.db.get(ticketDoc.activeSessionId)
|
|
if (!session) {
|
|
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
|
|
return { status: "session_missing" }
|
|
}
|
|
|
|
const now = Date.now()
|
|
const durationMs = now - session.startedAt
|
|
|
|
await ctx.db.patch(ticketDoc.activeSessionId, {
|
|
stoppedAt: now,
|
|
durationMs,
|
|
pauseReason: reason,
|
|
pauseNote: note ?? "",
|
|
})
|
|
|
|
const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
|
|
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,
|
|
status: "PAUSED",
|
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
|
updatedAt: now,
|
|
...slaPausePatch,
|
|
})
|
|
|
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_PAUSED",
|
|
payload: {
|
|
actorId,
|
|
actorName: actor?.name,
|
|
actorAvatar: actor?.avatarUrl,
|
|
sessionId: session._id,
|
|
sessionDurationMs: durationMs,
|
|
workType: sessionType,
|
|
pauseReason: reason,
|
|
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
|
pauseNote: note ?? "",
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return {
|
|
status: "paused",
|
|
durationMs,
|
|
pauseReason: reason,
|
|
pauseNote: note ?? "",
|
|
serverNow: now,
|
|
}
|
|
},
|
|
})
|
|
|
|
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"),
|
|
actorId: v.id("users"),
|
|
internalWorkedMs: v.number(),
|
|
externalWorkedMs: v.number(),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const normalizedRole = (viewer.role ?? "").toUpperCase()
|
|
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
|
throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.")
|
|
}
|
|
if (ticketDoc.activeSessionId) {
|
|
throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.")
|
|
}
|
|
|
|
const trimmedReason = reason.trim()
|
|
if (trimmedReason.length < 5) {
|
|
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.")
|
|
}
|
|
if (trimmedReason.length > 1000) {
|
|
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).")
|
|
}
|
|
|
|
const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0)
|
|
const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0)
|
|
const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal)
|
|
|
|
const nextInternal = Math.max(0, Math.round(internalWorkedMs))
|
|
const nextExternal = Math.max(0, Math.round(externalWorkedMs))
|
|
const nextTotal = nextInternal + nextExternal
|
|
|
|
const deltaInternal = nextInternal - previousInternal
|
|
const deltaExternal = nextExternal - previousExternal
|
|
const deltaTotal = nextTotal - previousTotal
|
|
|
|
const now = Date.now()
|
|
await ctx.db.patch(ticketId, {
|
|
internalWorkedMs: nextInternal,
|
|
externalWorkedMs: nextExternal,
|
|
totalWorkedMs: nextTotal,
|
|
updatedAt: now,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "WORK_ADJUSTED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
actorAvatar: viewer.user.avatarUrl,
|
|
previousInternalMs: previousInternal,
|
|
previousExternalMs: previousExternal,
|
|
previousTotalMs: previousTotal,
|
|
nextInternalMs: nextInternal,
|
|
nextExternalMs: nextExternal,
|
|
nextTotalMs: nextTotal,
|
|
deltaInternalMs: deltaInternal,
|
|
deltaExternalMs: deltaExternal,
|
|
deltaTotalMs: deltaTotal,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
const bodyHtml = [
|
|
"<p><strong>Ajuste manual de horas</strong></p>",
|
|
"<ul>",
|
|
`<li>Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} → ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
|
|
`<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} → ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
|
|
`<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} → ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})</li>`,
|
|
"</ul>",
|
|
`<p><strong>Motivo:</strong> ${escapeHtml(trimmedReason)}</p>`,
|
|
].join("")
|
|
|
|
const authorSnapshot: CommentAuthorSnapshot = {
|
|
name: viewer.user.name,
|
|
email: viewer.user.email,
|
|
avatarUrl: viewer.user.avatarUrl ?? undefined,
|
|
teams: viewer.user.teams ?? undefined,
|
|
}
|
|
|
|
await ctx.db.insert("ticketComments", {
|
|
ticketId,
|
|
authorId: actorId,
|
|
visibility: "INTERNAL",
|
|
body: bodyHtml,
|
|
authorSnapshot,
|
|
attachments: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
|
|
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now)
|
|
|
|
return {
|
|
ticketId,
|
|
totalWorkedMs: nextTotal,
|
|
internalWorkedMs: nextInternal,
|
|
externalWorkedMs: nextExternal,
|
|
serverNow: now,
|
|
perAgentTotals: perAgentTotals.map((item) => ({
|
|
agentId: item.agentId,
|
|
agentName: item.agentName,
|
|
agentEmail: item.agentEmail,
|
|
avatarUrl: item.avatarUrl,
|
|
totalWorkedMs: item.totalWorkedMs,
|
|
internalWorkedMs: item.internalWorkedMs,
|
|
externalWorkedMs: item.externalWorkedMs,
|
|
})),
|
|
}
|
|
},
|
|
})
|
|
|
|
export const updateSubject = mutation({
|
|
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, subject, actorId }) => {
|
|
const now = Date.now();
|
|
const t = await ctx.db.get(ticketId);
|
|
if (!t) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, t.tenantId)
|
|
const trimmed = subject.trim();
|
|
if (trimmed.length < 3) {
|
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
|
}
|
|
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
|
|
const actor = await ctx.db.get(actorId);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "SUBJECT_CHANGED",
|
|
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateSummary = mutation({
|
|
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, summary, actorId }) => {
|
|
const now = Date.now();
|
|
const t = await ctx.db.get(ticketId);
|
|
if (!t) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireStaff(ctx, actorId, t.tenantId)
|
|
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
|
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
|
|
}
|
|
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
|
const actor = await ctx.db.get(actorId);
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "SUMMARY_CHANGED",
|
|
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateCustomFields = mutation({
|
|
args: {
|
|
ticketId: v.id("tickets"),
|
|
actorId: v.id("users"),
|
|
fields: v.array(
|
|
v.object({
|
|
fieldId: v.id("ticketFields"),
|
|
value: v.any(),
|
|
})
|
|
),
|
|
},
|
|
handler: async (ctx, { ticketId, actorId, fields }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
const ticketDoc = ticket as Doc<"tickets">
|
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
|
const normalizedRole = (viewer.role ?? "").toUpperCase()
|
|
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
|
throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.")
|
|
}
|
|
|
|
const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? []
|
|
const previousRecord = mapCustomFieldsToRecord(previousEntries)
|
|
|
|
const sanitizedInputs: CustomFieldInput[] = fields
|
|
.filter((entry) => entry.value !== undefined)
|
|
.map((entry) => ({
|
|
fieldId: entry.fieldId,
|
|
value: entry.value,
|
|
}))
|
|
|
|
const normalized = await normalizeCustomFieldValues(
|
|
ctx,
|
|
ticketDoc.tenantId,
|
|
sanitizedInputs,
|
|
ticketDoc.formTemplate ?? null
|
|
)
|
|
|
|
const nextRecord = mapCustomFieldsToRecord(normalized)
|
|
const metaByFieldKey = new Map<
|
|
string,
|
|
{ fieldId?: Id<"ticketFields">; label: string; type: string }
|
|
>()
|
|
for (const entry of previousEntries) {
|
|
metaByFieldKey.set(entry.fieldKey, {
|
|
fieldId: entry.fieldId,
|
|
label: entry.label,
|
|
type: entry.type,
|
|
})
|
|
}
|
|
for (const entry of normalized) {
|
|
metaByFieldKey.set(entry.fieldKey, {
|
|
fieldId: entry.fieldId,
|
|
label: entry.label,
|
|
type: entry.type,
|
|
})
|
|
}
|
|
|
|
const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)]
|
|
const changedKeys = Array.from(new Set(keyOrder)).filter((key) => {
|
|
const previousEntry = getCustomFieldRecordEntry(previousRecord, key)
|
|
const nextEntry = getCustomFieldRecordEntry(nextRecord, key)
|
|
return !areCustomFieldEntriesEqual(previousEntry, nextEntry)
|
|
})
|
|
|
|
if (changedKeys.length === 0) {
|
|
return {
|
|
customFields: previousRecord,
|
|
updatedAt: ticketDoc.updatedAt ?? Date.now(),
|
|
}
|
|
}
|
|
|
|
const now = Date.now()
|
|
|
|
await ctx.db.patch(ticketId, {
|
|
customFields: normalized.length > 0 ? normalized : undefined,
|
|
updatedAt: now,
|
|
})
|
|
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId,
|
|
type: "CUSTOM_FIELDS_UPDATED",
|
|
payload: {
|
|
actorId,
|
|
actorName: viewer.user.name,
|
|
actorAvatar: viewer.user.avatarUrl ?? undefined,
|
|
fields: changedKeys.map((fieldKey) => {
|
|
const meta = metaByFieldKey.get(fieldKey)
|
|
const previous = getCustomFieldRecordEntry(previousRecord, fieldKey)
|
|
const next = getCustomFieldRecordEntry(nextRecord, fieldKey)
|
|
return {
|
|
fieldId: meta?.fieldId,
|
|
fieldKey,
|
|
label: meta?.label ?? fieldKey,
|
|
type: meta?.type ?? "text",
|
|
previousValue: previous?.value ?? null,
|
|
nextValue: next?.value ?? null,
|
|
previousDisplayValue: previous?.displayValue ?? null,
|
|
nextDisplayValue: next?.displayValue ?? null,
|
|
changeType: !previous ? "added" : !next ? "removed" : "updated",
|
|
}
|
|
}),
|
|
},
|
|
createdAt: now,
|
|
})
|
|
|
|
return {
|
|
customFields: nextRecord,
|
|
updatedAt: now,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const playNext = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
queueId: v.optional(v.id("queues")),
|
|
agentId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { tenantId, queueId, agentId }) => {
|
|
const { user: agent } = await requireStaff(ctx, agentId, tenantId)
|
|
// Find eligible tickets: not resolved/closed and not assigned
|
|
let candidates: Doc<"tickets">[] = []
|
|
if (queueId) {
|
|
candidates = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
|
|
.collect()
|
|
} else {
|
|
candidates = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
}
|
|
|
|
candidates = candidates.filter(
|
|
(t) => t.status !== "RESOLVED" && !t.assigneeId
|
|
);
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
// prioritize by priority then createdAt
|
|
const rank: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
|
candidates.sort((a, b) => {
|
|
const pa = rank[a.priority] ?? 999
|
|
const pb = rank[b.priority] ?? 999
|
|
if (pa !== pb) return pa - pb
|
|
return a.createdAt - b.createdAt
|
|
})
|
|
|
|
const chosen = candidates[0];
|
|
const now = Date.now();
|
|
const currentStatus = normalizeStatus(chosen.status);
|
|
const nextStatus: TicketStatusNormalized = currentStatus;
|
|
const assigneeSnapshot = {
|
|
name: agent.name,
|
|
email: agent.email,
|
|
avatarUrl: agent.avatarUrl ?? undefined,
|
|
teams: agent.teams ?? undefined,
|
|
}
|
|
await ctx.db.patch(chosen._id, {
|
|
assigneeId: agentId,
|
|
assigneeSnapshot,
|
|
status: nextStatus,
|
|
working: false,
|
|
activeSessionId: undefined,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: chosen._id,
|
|
type: "ASSIGNEE_CHANGED",
|
|
payload: { assigneeId: agentId, assigneeName: agent.name },
|
|
createdAt: now,
|
|
});
|
|
|
|
// hydrate minimal public ticket like in list
|
|
const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null
|
|
const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null
|
|
const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
|
|
const queueName = normalizeQueueName(queue)
|
|
return {
|
|
id: chosen._id,
|
|
reference: chosen.reference,
|
|
tenantId: chosen.tenantId,
|
|
subject: chosen.subject,
|
|
summary: chosen.summary,
|
|
status: nextStatus,
|
|
priority: chosen.priority,
|
|
channel: chosen.channel,
|
|
queue: queueName,
|
|
requester: requester
|
|
? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id })
|
|
: buildRequesterFromSnapshot(
|
|
chosen.requesterId,
|
|
chosen.requesterSnapshot ?? undefined,
|
|
{ ticketId: chosen._id }
|
|
),
|
|
assignee: chosen.assigneeId
|
|
? assignee
|
|
? {
|
|
id: assignee._id,
|
|
name: assignee.name,
|
|
email: assignee.email,
|
|
avatarUrl: assignee.avatarUrl,
|
|
teams: normalizeTeams(assignee.teams),
|
|
}
|
|
: buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined)
|
|
: null,
|
|
slaPolicy: null,
|
|
dueAt: chosen.dueAt ?? null,
|
|
firstResponseAt: chosen.firstResponseAt ?? null,
|
|
resolvedAt: chosen.resolvedAt ?? null,
|
|
updatedAt: chosen.updatedAt,
|
|
createdAt: chosen.createdAt,
|
|
tags: chosen.tags ?? [],
|
|
lastTimelineEntry: null,
|
|
metrics: null,
|
|
}
|
|
},
|
|
});
|
|
|
|
export const remove = mutation({
|
|
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
|
handler: async (ctx, { ticketId, actorId }) => {
|
|
const ticket = await ctx.db.get(ticketId)
|
|
if (!ticket) {
|
|
throw new ConvexError("Ticket não encontrado")
|
|
}
|
|
await requireAdmin(ctx, actorId, ticket.tenantId)
|
|
// delete comments (and attachments)
|
|
const comments = await ctx.db
|
|
.query("ticketComments")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.collect();
|
|
for (const c of comments) {
|
|
for (const att of c.attachments ?? []) {
|
|
try { await ctx.storage.delete(att.storageId); } catch {}
|
|
}
|
|
await ctx.db.delete(c._id);
|
|
}
|
|
// delete events
|
|
const events = await ctx.db
|
|
.query("ticketEvents")
|
|
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
|
|
.collect();
|
|
for (const ev of events) await ctx.db.delete(ev._id);
|
|
// delete ticket
|
|
await ctx.db.delete(ticketId);
|
|
// (optional) event is moot after deletion
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const reassignTicketsByEmail = mutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
actorId: v.id("users"),
|
|
fromEmail: v.string(),
|
|
toUserId: v.id("users"),
|
|
dryRun: v.optional(v.boolean()),
|
|
limit: v.optional(v.number()),
|
|
updateSnapshot: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => {
|
|
await requireAdmin(ctx, actorId, tenantId)
|
|
|
|
const normalizedFrom = fromEmail.trim().toLowerCase()
|
|
if (!normalizedFrom || !normalizedFrom.includes("@")) {
|
|
throw new ConvexError("E-mail de origem inválido")
|
|
}
|
|
|
|
const toUser = await ctx.db.get(toUserId)
|
|
if (!toUser || toUser.tenantId !== tenantId) {
|
|
throw new ConvexError("Usuário de destino inválido para o tenant")
|
|
}
|
|
|
|
// Coletar tickets por requesterId (quando possível via usuário antigo)
|
|
const fromUser = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom))
|
|
.first()
|
|
|
|
const byRequesterId: Doc<"tickets">[] = fromUser
|
|
? await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id))
|
|
.collect()
|
|
: []
|
|
|
|
// Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo
|
|
const allTenant = await ctx.db
|
|
.query("tickets")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
|
|
const bySnapshotEmail = allTenant.filter((t) => {
|
|
const rs = t.requesterSnapshot as { email?: string } | undefined
|
|
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
|
if (!email || email !== normalizedFrom) return false
|
|
// Evita duplicar os já coletados por requesterId
|
|
if (fromUser && t.requesterId === fromUser._id) return false
|
|
return true
|
|
})
|
|
|
|
const candidatesMap = new Map<string, Doc<"tickets">>()
|
|
for (const t of byRequesterId) candidatesMap.set(String(t._id), t)
|
|
for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t)
|
|
const candidates = Array.from(candidatesMap.values())
|
|
|
|
const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length))
|
|
const toProcess = candidates.slice(0, maxToProcess)
|
|
|
|
if (dryRun) {
|
|
return {
|
|
dryRun: true as const,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
candidates: candidates.length,
|
|
willUpdate: toProcess.length,
|
|
}
|
|
}
|
|
|
|
const now = Date.now()
|
|
let updated = 0
|
|
for (const t of toProcess) {
|
|
const patch: Record<string, unknown> = { requesterId: toUserId, updatedAt: now }
|
|
if (updateSnapshot) {
|
|
patch.requesterSnapshot = {
|
|
name: toUser.name,
|
|
email: toUser.email,
|
|
avatarUrl: toUser.avatarUrl ?? undefined,
|
|
teams: toUser.teams ?? undefined,
|
|
}
|
|
}
|
|
await ctx.db.patch(t._id, patch)
|
|
await ctx.db.insert("ticketEvents", {
|
|
ticketId: t._id,
|
|
type: "REQUESTER_CHANGED",
|
|
payload: {
|
|
fromUserId: fromUser?._id ?? null,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
toUserName: toUser.name,
|
|
},
|
|
createdAt: now,
|
|
})
|
|
updated += 1
|
|
}
|
|
|
|
return {
|
|
dryRun: false as const,
|
|
fromEmail: normalizedFrom,
|
|
toUserId,
|
|
candidates: candidates.length,
|
|
updated,
|
|
}
|
|
},
|
|
})
|