feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings
This commit is contained in:
parent
addd4ce6e8
commit
3bafcc5a0a
45 changed files with 1401 additions and 256 deletions
|
|
@ -6,7 +6,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
|
|||
|
||||
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
||||
|
||||
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
|
||||
const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
|
|
@ -30,6 +30,7 @@ type ImportedQueue = {
|
|||
type ImportedCompany = {
|
||||
slug: string
|
||||
name: string
|
||||
isAvulso?: boolean | null
|
||||
cnpj?: string | null
|
||||
domain?: string | null
|
||||
phone?: string | null
|
||||
|
|
@ -43,6 +44,8 @@ function normalizeRole(role: string | null | undefined) {
|
|||
if (!role) return "AGENT"
|
||||
const normalized = role.toUpperCase()
|
||||
if (VALID_ROLES.has(normalized)) return normalized
|
||||
// map legacy CUSTOMER to MANAGER
|
||||
if (normalized === "CUSTOMER") return "MANAGER"
|
||||
return "AGENT"
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +185,7 @@ async function ensureCompany(
|
|||
tenantId,
|
||||
name: data.name,
|
||||
slug,
|
||||
isAvulso: data.isAvulso ?? undefined,
|
||||
cnpj: data.cnpj ?? undefined,
|
||||
domain: data.domain ?? undefined,
|
||||
phone: data.phone ?? undefined,
|
||||
|
|
@ -195,6 +199,7 @@ async function ensureCompany(
|
|||
if (existing) {
|
||||
const needsPatch =
|
||||
existing.name !== payload.name ||
|
||||
(existing as any).isAvulso !== (payload.isAvulso ?? (existing as any).isAvulso) ||
|
||||
existing.cnpj !== (payload.cnpj ?? undefined) ||
|
||||
existing.domain !== (payload.domain ?? undefined) ||
|
||||
existing.phone !== (payload.phone ?? undefined) ||
|
||||
|
|
@ -203,6 +208,7 @@ async function ensureCompany(
|
|||
if (needsPatch) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
name: payload.name,
|
||||
isAvulso: payload.isAvulso,
|
||||
cnpj: payload.cnpj,
|
||||
domain: payload.domain,
|
||||
phone: payload.phone,
|
||||
|
|
@ -344,6 +350,7 @@ export const exportTenantSnapshot = query({
|
|||
companies: companies.map((company) => ({
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
isAvulso: (company as any).isAvulso ?? false,
|
||||
cnpj: company.cnpj ?? null,
|
||||
domain: company.domain ?? null,
|
||||
phone: company.phone ?? null,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Id } from "./_generated/dataModel";
|
|||
|
||||
import { requireAdmin, requireStaff } from "./rbac";
|
||||
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||
|
||||
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||
NEW: "PENDING",
|
||||
|
|
@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
|||
ON_HOLD: "PAUSED",
|
||||
PAUSED: "PAUSED",
|
||||
RESOLVED: "RESOLVED",
|
||||
CLOSED: "CLOSED",
|
||||
CLOSED: "RESOLVED",
|
||||
};
|
||||
|
||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||
|
|
@ -123,7 +123,7 @@ export const summary = query({
|
|||
}).length;
|
||||
const open = pending.filter((t) => {
|
||||
const status = normalizeStatus(t.status);
|
||||
return status !== "RESOLVED" && status !== "CLOSED";
|
||||
return status !== "RESOLVED";
|
||||
}).length;
|
||||
const breached = 0;
|
||||
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { Id } from "./_generated/dataModel"
|
|||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const CUSTOMER_ROLE = "CUSTOMER"
|
||||
const MANAGER_ROLE = "MANAGER"
|
||||
|
||||
type Ctx = QueryCtx | MutationCtx
|
||||
|
|
@ -45,13 +44,7 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str
|
|||
return result
|
||||
}
|
||||
|
||||
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (result.role !== CUSTOMER_ROLE) {
|
||||
throw new ConvexError("Acesso restrito ao portal do cliente")
|
||||
}
|
||||
return result
|
||||
}
|
||||
// removed customer role; use requireCompanyManager or requireStaff as appropriate
|
||||
|
||||
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
|||
|
||||
import { requireStaff } from "./rbac";
|
||||
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||
|
||||
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||
NEW: "PENDING",
|
||||
|
|
@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
|||
ON_HOLD: "PAUSED",
|
||||
PAUSED: "PAUSED",
|
||||
RESOLVED: "RESOLVED",
|
||||
CLOSED: "CLOSED",
|
||||
CLOSED: "RESOLVED",
|
||||
};
|
||||
|
||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||
|
|
@ -128,7 +128,7 @@ function formatDateKey(timestamp: number) {
|
|||
}
|
||||
|
||||
export const slaOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
|
|
@ -138,7 +138,7 @@ export const slaOverview = query({
|
|||
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||
const resolvedTickets = tickets.filter((ticket) => {
|
||||
const status = normalizeStatus(ticket.status);
|
||||
return status === "RESOLVED" || status === "CLOSED";
|
||||
return status === "RESOLVED";
|
||||
});
|
||||
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||
|
||||
|
|
@ -179,11 +179,17 @@ export const slaOverview = query({
|
|||
});
|
||||
|
||||
export const csatOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
const surveys = await collectCsatSurveys(ctx, tickets);
|
||||
const surveysAll = await collectCsatSurveys(ctx, tickets);
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const endMs = end.getTime() + ONE_DAY_MS;
|
||||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
||||
|
||||
const averageScore = average(surveys.map((item) => item.score));
|
||||
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
||||
|
|
@ -205,28 +211,37 @@ export const csatOverview = query({
|
|||
score: item.score,
|
||||
receivedAt: item.receivedAt,
|
||||
})),
|
||||
rangeDays: days,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backlogOverview = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
|
||||
const statusCounts = tickets.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
||||
// Optional range filter (createdAt) for reporting purposes
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const endMs = end.getTime() + ONE_DAY_MS;
|
||||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs);
|
||||
|
||||
const statusCounts = inRange.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
||||
const status = normalizeStatus(ticket.status);
|
||||
acc[status] = (acc[status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<TicketStatusNormalized, number>);
|
||||
|
||||
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
|
||||
const priorityCounts = inRange.reduce<Record<string, number>>((acc, ticket) => {
|
||||
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||
const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||
|
||||
const queueMap = new Map<string, { name: string; count: number }>();
|
||||
for (const ticket of openTickets) {
|
||||
|
|
@ -245,6 +260,7 @@ export const backlogOverview = query({
|
|||
}
|
||||
|
||||
return {
|
||||
rangeDays: days,
|
||||
statusCounts,
|
||||
priorityCounts,
|
||||
queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default defineSchema({
|
|||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
isAvulso: v.optional(v.boolean()),
|
||||
cnpj: v.optional(v.string()),
|
||||
domain: v.optional(v.string()),
|
||||
phone: v.optional(v.string()),
|
||||
|
|
@ -91,6 +92,8 @@ export default defineSchema({
|
|||
)
|
||||
),
|
||||
totalWorkedMs: v.optional(v.number()),
|
||||
internalWorkedMs: v.optional(v.number()),
|
||||
externalWorkedMs: v.optional(v.number()),
|
||||
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||
})
|
||||
.index("by_tenant_status", ["tenantId", "status"])
|
||||
|
|
@ -141,6 +144,7 @@ export default defineSchema({
|
|||
ticketWorkSessions: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
agentId: v.id("users"),
|
||||
workType: v.optional(v.string()), // INTERNAL | EXTERNAL
|
||||
startedAt: v.number(),
|
||||
stoppedAt: v.optional(v.number()),
|
||||
durationMs: v.optional(v.number()),
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export const seedDemo = mutation({
|
|||
|
||||
function defaultAvatar(name: string, email: string, role: string) {
|
||||
const normalizedRole = role.toUpperCase();
|
||||
if (normalizedRole === "CUSTOMER" || normalizedRole === "MANAGER") {
|
||||
if (normalizedRole === "MANAGER") {
|
||||
return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`;
|
||||
}
|
||||
const first = name.split(" ")[0] ?? email;
|
||||
|
|
@ -130,7 +130,7 @@ export const seedDemo = mutation({
|
|||
avatarUrl?: string;
|
||||
}): Promise<Id<"users">> {
|
||||
const normalizedEmail = params.email.trim().toLowerCase();
|
||||
const normalizedRole = (params.role ?? "CUSTOMER").toUpperCase();
|
||||
const normalizedRole = (params.role ?? "MANAGER").toUpperCase();
|
||||
const desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole);
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
|
|
@ -139,7 +139,7 @@ export const seedDemo = mutation({
|
|||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (existing.name !== params.name) updates.name = params.name;
|
||||
if ((existing.role ?? "CUSTOMER") !== normalizedRole) updates.role = normalizedRole;
|
||||
if ((existing.role ?? "MANAGER") !== normalizedRole) updates.role = normalizedRole;
|
||||
if ((existing.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar;
|
||||
if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
|
|
@ -217,13 +217,13 @@ export const seedDemo = mutation({
|
|||
const joaoAtlasId = await ensureUser({
|
||||
name: "João Pedro Ramos",
|
||||
email: "joao.ramos@atlasengenharia.com.br",
|
||||
role: "CUSTOMER",
|
||||
role: "MANAGER",
|
||||
companyId: atlasCompanyId,
|
||||
});
|
||||
await ensureUser({
|
||||
name: "Aline Rezende",
|
||||
email: "aline.rezende@atlasengenharia.com.br",
|
||||
role: "CUSTOMER",
|
||||
role: "MANAGER",
|
||||
companyId: atlasCompanyId,
|
||||
});
|
||||
|
||||
|
|
@ -237,19 +237,19 @@ export const seedDemo = mutation({
|
|||
const ricardoOmniId = await ensureUser({
|
||||
name: "Ricardo Matos",
|
||||
email: "ricardo.matos@omnisaude.com.br",
|
||||
role: "CUSTOMER",
|
||||
role: "MANAGER",
|
||||
companyId: omniCompanyId,
|
||||
});
|
||||
await ensureUser({
|
||||
name: "Luciana Prado",
|
||||
email: "luciana.prado@omnisaude.com.br",
|
||||
role: "CUSTOMER",
|
||||
role: "MANAGER",
|
||||
companyId: omniCompanyId,
|
||||
});
|
||||
const clienteDemoId = await ensureUser({
|
||||
name: "Cliente Demo",
|
||||
email: "cliente.demo@sistema.dev",
|
||||
role: "CUSTOMER",
|
||||
role: "MANAGER",
|
||||
companyId: omniCompanyId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc } from "./_generated/dataModel";
|
||||
|
||||
import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
||||
import { requireStaff, requireUser } from "./rbac";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
||||
|
|
@ -13,14 +13,13 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
|||
IN_PROCEDURE: "Em procedimento",
|
||||
};
|
||||
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||
|
||||
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
};
|
||||
|
||||
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
||||
|
|
@ -31,7 +30,7 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|||
ON_HOLD: "PAUSED",
|
||||
PAUSED: "PAUSED",
|
||||
RESOLVED: "RESOLVED",
|
||||
CLOSED: "CLOSED",
|
||||
CLOSED: "RESOLVED",
|
||||
};
|
||||
|
||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||
|
|
@ -277,9 +276,7 @@ export const list = query({
|
|||
}
|
||||
let filtered = base;
|
||||
|
||||
if (role === "CUSTOMER") {
|
||||
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
||||
} else if (role === "MANAGER") {
|
||||
if (role === "MANAGER") {
|
||||
if (!user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||
}
|
||||
|
|
@ -310,6 +307,7 @@ export const list = query({
|
|||
const 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 queueName = normalizeQueueName(queue);
|
||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
||||
|
|
@ -342,6 +340,7 @@ export const list = query({
|
|||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queueName,
|
||||
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
|
|
@ -371,11 +370,14 @@ export const list = query({
|
|||
subcategory: subcategorySummary,
|
||||
workSummary: {
|
||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
|
||||
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
|
||||
activeSession: activeSession
|
||||
? {
|
||||
id: activeSession._id,
|
||||
agentId: activeSession.agentId,
|
||||
startedAt: activeSession.startedAt,
|
||||
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
|
|
@ -393,9 +395,7 @@ export const getById = query({
|
|||
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
||||
const t = await ctx.db.get(id);
|
||||
if (!t || t.tenantId !== tenantId) return null;
|
||||
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
||||
throw new ConvexError("Acesso restrito ao solicitante")
|
||||
}
|
||||
// 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
|
||||
|
|
@ -405,6 +405,7 @@ export const getById = query({
|
|||
}
|
||||
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 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;
|
||||
|
|
@ -463,6 +464,7 @@ export const getById = query({
|
|||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queueName,
|
||||
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
|
|
@ -503,11 +505,14 @@ export const getById = query({
|
|||
: null,
|
||||
workSummary: {
|
||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
|
||||
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
|
||||
activeSession: activeSession
|
||||
? {
|
||||
id: activeSession._id,
|
||||
agentId: activeSession.agentId,
|
||||
startedAt: activeSession.startedAt,
|
||||
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
|
|
@ -557,9 +562,7 @@ export const create = mutation({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
|
||||
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
||||
}
|
||||
// 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")
|
||||
|
|
@ -706,12 +709,9 @@ export const addComment = mutation({
|
|||
}
|
||||
|
||||
if (ticketDoc.requesterId === args.authorId) {
|
||||
if (normalizedRole === "CUSTOMER") {
|
||||
await requireCustomer(ctx, args.authorId, ticketDoc.tenantId)
|
||||
if (args.visibility !== "PUBLIC") {
|
||||
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
||||
}
|
||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
||||
// requester commenting: managers restricted to PUBLIC (handled above);
|
||||
// internal staff require staff permission
|
||||
if (STAFF_ROLES.has(normalizedRole)) {
|
||||
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
||||
} else {
|
||||
throw new ConvexError("Autor não possui permissão para comentar")
|
||||
|
|
@ -768,9 +768,7 @@ export const updateComment = mutation({
|
|||
}
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
if (ticketDoc.requesterId === actorId) {
|
||||
if (normalizedRole === "CUSTOMER") {
|
||||
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
||||
if (STAFF_ROLES.has(normalizedRole)) {
|
||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||
} else {
|
||||
throw new ConvexError("Autor não possui permissão para editar")
|
||||
|
|
@ -828,9 +826,7 @@ export const removeCommentAttachment = mutation({
|
|||
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
if (ticketDoc.requesterId === actorId) {
|
||||
if (normalizedRole === "CUSTOMER") {
|
||||
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
||||
if (STAFF_ROLES.has(normalizedRole)) {
|
||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||
} else {
|
||||
throw new ConvexError("Autor não possui permissão para alterar anexos")
|
||||
|
|
@ -1051,11 +1047,14 @@ export const workSummary = query({
|
|||
return {
|
||||
ticketId,
|
||||
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
||||
internalWorkedMs: (ticket as any).internalWorkedMs ?? 0,
|
||||
externalWorkedMs: (ticket as any).externalWorkedMs ?? 0,
|
||||
activeSession: activeSession
|
||||
? {
|
||||
id: activeSession._id,
|
||||
agentId: activeSession.agentId,
|
||||
startedAt: activeSession.startedAt,
|
||||
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
|
@ -1083,8 +1082,8 @@ export const updatePriority = mutation({
|
|||
});
|
||||
|
||||
export const startWork = mutation({
|
||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, actorId }) => {
|
||||
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")
|
||||
|
|
@ -1098,6 +1097,7 @@ export const startWork = mutation({
|
|||
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||
ticketId,
|
||||
agentId: actorId,
|
||||
workType: (workType ?? "INTERNAL").toUpperCase(),
|
||||
startedAt: now,
|
||||
})
|
||||
|
||||
|
|
@ -1111,7 +1111,7 @@ export const startWork = mutation({
|
|||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "WORK_STARTED",
|
||||
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId },
|
||||
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase() },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
|
|
@ -1156,10 +1156,16 @@ export const pauseWork = mutation({
|
|||
pauseNote: note ?? "",
|
||||
})
|
||||
|
||||
const sessionType = ((session as any).workType ?? "INTERNAL").toUpperCase()
|
||||
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
|
||||
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
||||
internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal,
|
||||
externalWorkedMs: ((ticket as any).externalWorkedMs ?? 0) + deltaExternal,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
|
|
@ -1173,6 +1179,7 @@ export const pauseWork = mutation({
|
|||
actorAvatar: actor?.avatarUrl,
|
||||
sessionId: session._id,
|
||||
sessionDurationMs: durationMs,
|
||||
workType: sessionType,
|
||||
pauseReason: reason,
|
||||
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
||||
pauseNote: note ?? "",
|
||||
|
|
@ -1256,7 +1263,7 @@ export const playNext = mutation({
|
|||
}
|
||||
|
||||
candidates = candidates.filter(
|
||||
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
|
||||
(t) => t.status !== "RESOLVED" && !t.assigneeId
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -110,3 +110,17 @@ export const deleteUser = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const assignCompany = mutation({
|
||||
args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, email, companyId, actorId }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
|
||||
.first()
|
||||
if (!user) throw new ConvexError("Usuário não encontrado no Convex")
|
||||
await ctx.db.patch(user._id, { companyId })
|
||||
const updated = await ctx.db.get(user._id)
|
||||
return updated
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue