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