Improve custom field timeline and toasts
This commit is contained in:
parent
f7aa17f229
commit
a2f9d4bd1a
9 changed files with 549 additions and 101 deletions
|
|
@ -26,6 +26,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
|||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||
IN_PROCEDURE: "Em procedimento",
|
||||
};
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||
|
||||
|
|
@ -65,6 +66,8 @@ type TemplateSummary = {
|
|||
defaultEnabled: boolean;
|
||||
};
|
||||
|
||||
type TicketFieldScopeMap = Map<string, Doc<"ticketFields">[]>;
|
||||
|
||||
function plainTextLength(html: string): number {
|
||||
try {
|
||||
const text = String(html)
|
||||
|
|
@ -200,6 +203,86 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<Te
|
|||
}));
|
||||
}
|
||||
|
||||
async function fetchTicketFieldsByScopes(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
scopes: string[]
|
||||
): Promise<TicketFieldScopeMap> {
|
||||
const uniqueScopes = Array.from(new Set(scopes));
|
||||
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);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchScopedFormSettings(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
templateKey: string,
|
||||
viewerId: Id<"users">,
|
||||
viewerCompanyId: Id<"companies"> | null
|
||||
): Promise<Doc<"ticketFormSettings">[]> {
|
||||
const tenantSettingsPromise = ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("scope", "tenant"))
|
||||
.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">[]>([]);
|
||||
|
||||
const userSettingsPromise = ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant_template_user", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("userId", viewerId))
|
||||
.collect();
|
||||
|
||||
const [tenantSettings, companySettings, userSettings] = await Promise.all([
|
||||
tenantSettingsPromise,
|
||||
companySettingsPromise,
|
||||
userSettingsPromise,
|
||||
]);
|
||||
|
||||
return [...tenantSettings, ...companySettings, ...userSettings];
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -776,17 +859,11 @@ function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { val
|
|||
return { value };
|
||||
}
|
||||
case "date": {
|
||||
if (typeof raw === "number") {
|
||||
if (!Number.isFinite(raw)) {
|
||||
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
||||
}
|
||||
return { value: raw };
|
||||
}
|
||||
const parsed = Date.parse(String(raw));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
const normalized = normalizeDateOnlyValue(raw);
|
||||
if (!normalized) {
|
||||
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
||||
}
|
||||
return { value: parsed };
|
||||
return { value: normalized };
|
||||
}
|
||||
case "boolean": {
|
||||
if (typeof raw === "boolean") {
|
||||
|
|
@ -883,16 +960,59 @@ async function normalizeCustomFieldValues(
|
|||
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: entry.value,
|
||||
value,
|
||||
displayValue: entry.displayValue,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 = 250;
|
||||
const MIN_TICKETS_LIST_LIMIT = 25;
|
||||
const MAX_TICKETS_LIST_LIMIT = 600;
|
||||
|
|
@ -2387,27 +2507,24 @@ export const listTicketForms = query({
|
|||
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 settings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
const scopes = templates.map((template) => template.key)
|
||||
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
|
||||
|
||||
const fieldDefinitions = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
|
||||
const settingsByTemplate = new Map<string, Doc<"ticketFormSettings">[]>()
|
||||
|
||||
const fieldsByScope = new Map<string, Doc<"ticketFields">[]>()
|
||||
for (const definition of fieldDefinitions) {
|
||||
const scope = (definition.scope ?? "all").trim()
|
||||
if (!fieldsByScope.has(scope)) {
|
||||
fieldsByScope.set(scope, [])
|
||||
}
|
||||
fieldsByScope.get(scope)!.push(definition)
|
||||
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 templates = await fetchTemplateSummaries(ctx, tenantId)
|
||||
const forms = [] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
|
|
@ -2424,14 +2541,13 @@ export const listTicketForms = query({
|
|||
}>
|
||||
|
||||
for (const template of templates) {
|
||||
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||
companyId: viewerCompanyId,
|
||||
userId: viewer.user._id,
|
||||
})
|
||||
const viewerRole = (viewer.role ?? "").toUpperCase()
|
||||
if (viewerRole === "ADMIN" || viewerRole === "AGENT") {
|
||||
enabled = true
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -3477,6 +3593,9 @@ export const updateCustomFields = mutation({
|
|||
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) => ({
|
||||
|
|
@ -3491,6 +3610,40 @@ export const updateCustomFields = mutation({
|
|||
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, {
|
||||
|
|
@ -3505,18 +3658,28 @@ export const updateCustomFields = mutation({
|
|||
actorId,
|
||||
actorName: viewer.user.name,
|
||||
actorAvatar: viewer.user.avatarUrl ?? undefined,
|
||||
fields: normalized.map((field) => ({
|
||||
fieldId: field.fieldId,
|
||||
fieldKey: field.fieldKey,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
})),
|
||||
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: mapCustomFieldsToRecord(normalized),
|
||||
customFields: nextRecord,
|
||||
updatedAt: now,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue