feat: implement invite onboarding and dynamic ticket fields

This commit is contained in:
esdrasrenan 2025-10-05 21:47:28 -03:00
parent 29a647f6c6
commit f24a7f68ca
34 changed files with 2240 additions and 97 deletions

View file

@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as invites from "../invites.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{
categories: typeof categories;
fields: typeof fields;
files: typeof files;
invites: typeof invites;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;

View file

@ -3,6 +3,8 @@ import type { MutationCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import { Id } from "./_generated/dataModel"
import { requireAdmin } from "./rbac"
type CategorySeed = {
name: string
description?: string
@ -295,10 +297,13 @@ export const ensureDefaults = mutation({
export const createCategory = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
secondary: v.optional(v.array(v.string())),
},
handler: async (ctx, { tenantId, name, description }) => {
handler: async (ctx, { tenantId, actorId, name, description, secondary }) => {
await requireAdmin(ctx, actorId, tenantId)
const trimmed = name.trim()
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a categoria")
@ -321,6 +326,31 @@ export const createCategory = mutation({
createdAt: now,
updatedAt: now,
})
if (secondary?.length) {
let subOrder = 0
for (const item of secondary) {
const value = item.trim()
if (value.length < 2) continue
const subSlug = await ensureUniqueSlug(
ctx,
"ticketSubcategories",
tenantId,
slugify(value),
{ categoryId: id }
)
await ctx.db.insert("ticketSubcategories", {
tenantId,
categoryId: id,
name: value,
slug: subSlug,
order: subOrder,
createdAt: now,
updatedAt: now,
})
subOrder += 1
}
}
return id
},
})
@ -329,10 +359,12 @@ export const updateCategory = mutation({
args: {
categoryId: v.id("ticketCategories"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { categoryId, tenantId, name, description }) => {
handler: async (ctx, { categoryId, tenantId, actorId, name, description }) => {
await requireAdmin(ctx, actorId, tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
@ -354,9 +386,11 @@ export const deleteCategory = mutation({
args: {
categoryId: v.id("ticketCategories"),
tenantId: v.string(),
actorId: v.id("users"),
transferTo: v.optional(v.id("ticketCategories")),
},
handler: async (ctx, { categoryId, tenantId, transferTo }) => {
handler: async (ctx, { categoryId, tenantId, actorId, transferTo }) => {
await requireAdmin(ctx, actorId, tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
@ -412,10 +446,12 @@ export const deleteCategory = mutation({
export const createSubcategory = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
categoryId: v.id("ticketCategories"),
name: v.string(),
},
handler: async (ctx, { tenantId, categoryId, name }) => {
handler: async (ctx, { tenantId, actorId, categoryId, name }) => {
await requireAdmin(ctx, actorId, tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
@ -449,10 +485,12 @@ export const createSubcategory = mutation({
export const updateSubcategory = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
subcategoryId: v.id("ticketSubcategories"),
name: v.string(),
},
handler: async (ctx, { tenantId, subcategoryId, name }) => {
handler: async (ctx, { tenantId, actorId, subcategoryId, name }) => {
await requireAdmin(ctx, actorId, tenantId)
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada")
@ -471,10 +509,12 @@ export const updateSubcategory = mutation({
export const deleteSubcategory = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
subcategoryId: v.id("ticketSubcategories"),
transferTo: v.optional(v.id("ticketSubcategories")),
},
handler: async (ctx, { tenantId, subcategoryId, transferTo }) => {
handler: async (ctx, { tenantId, actorId, subcategoryId, transferTo }) => {
await requireAdmin(ctx, actorId, tenantId)
const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada")

View file

@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireAdmin } from "./rbac";
import { requireAdmin, requireUser } from "./rbac";
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
@ -63,6 +63,30 @@ export const list = query({
},
});
export const listForTenant = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireUser(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
return fields
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: field.required,
options: field.options ?? [],
order: field.order,
}));
},
});
export const create = mutation({
args: {
tenantId: v.string(),

115
web/convex/invites.ts Normal file
View file

@ -0,0 +1,115 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { requireAdmin } from "./rbac";
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const invites = await ctx.db
.query("userInvites")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return invites
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
.map((invite) => ({
id: invite._id,
inviteId: invite.inviteId,
email: invite.email,
name: invite.name ?? null,
role: invite.role,
status: invite.status,
token: invite.token,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
createdById: invite.createdById ?? null,
acceptedAt: invite.acceptedAt ?? null,
acceptedById: invite.acceptedById ?? null,
revokedAt: invite.revokedAt ?? null,
revokedById: invite.revokedById ?? null,
revokedReason: invite.revokedReason ?? null,
}));
},
});
export const sync = mutation({
args: {
tenantId: v.string(),
inviteId: v.string(),
email: v.string(),
name: v.optional(v.string()),
role: v.string(),
status: v.string(),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
createdById: v.optional(v.string()),
acceptedAt: v.optional(v.number()),
acceptedById: v.optional(v.string()),
revokedAt: v.optional(v.number()),
revokedById: v.optional(v.string()),
revokedReason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("userInvites")
.withIndex("by_invite", (q) => q.eq("tenantId", args.tenantId).eq("inviteId", args.inviteId))
.first();
if (!existing) {
const id = await ctx.db.insert("userInvites", {
tenantId: args.tenantId,
inviteId: args.inviteId,
email: args.email,
name: args.name,
role: args.role,
status: args.status,
token: args.token,
expiresAt: args.expiresAt,
createdAt: args.createdAt,
createdById: args.createdById,
acceptedAt: args.acceptedAt,
acceptedById: args.acceptedById,
revokedAt: args.revokedAt,
revokedById: args.revokedById,
revokedReason: args.revokedReason,
});
return await ctx.db.get(id);
}
await ctx.db.patch(existing._id, {
email: args.email,
name: args.name,
role: args.role,
status: args.status,
token: args.token,
expiresAt: args.expiresAt,
createdAt: args.createdAt,
createdById: args.createdById,
acceptedAt: args.acceptedAt,
acceptedById: args.acceptedById,
revokedAt: args.revokedAt,
revokedById: args.revokedById,
revokedReason: args.revokedReason,
});
return await ctx.db.get(existing._id);
},
});
export const remove = mutation({
args: { tenantId: v.string(), inviteId: v.string() },
handler: async (ctx, { tenantId, inviteId }) => {
const existing = await ctx.db
.query("userInvites")
.withIndex("by_invite", (q) => q.eq("tenantId", tenantId).eq("inviteId", inviteId))
.first();
if (existing) {
await ctx.db.delete(existing._id);
}
},
});

View file

@ -60,6 +60,18 @@ export default defineSchema({
updatedAt: v.number(),
createdAt: v.number(),
tags: v.optional(v.array(v.string())),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
fieldKey: v.string(),
label: v.string(),
type: v.string(),
value: v.any(),
displayValue: v.optional(v.string()),
})
)
),
totalWorkedMs: v.optional(v.number()),
activeSessionId: v.optional(v.id("ticketWorkSessions")),
})
@ -153,4 +165,25 @@ export default defineSchema({
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant", ["tenantId"]),
userInvites: defineTable({
tenantId: v.string(),
inviteId: v.string(),
email: v.string(),
name: v.optional(v.string()),
role: v.string(),
status: v.string(),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
createdById: v.optional(v.string()),
acceptedAt: v.optional(v.number()),
acceptedById: v.optional(v.string()),
revokedAt: v.optional(v.number()),
revokedById: v.optional(v.string()),
revokedReason: v.optional(v.string()),
})
.index("by_tenant", ["tenantId"])
.index("by_token", ["tenantId", "token"])
.index("by_invite", ["tenantId", "inviteId"]),
});

View file

@ -1,4 +1,5 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
return teams.map((team) => renameQueueString(team) ?? team);
}
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": {
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)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: parsed };
}
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
): Promise<NormalizedCustomField[]> {
const definitions = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (!definitions.length) {
if (inputs && inputs.length > 0) {
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
}
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 definitions.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) => {
acc[entry.fieldKey] = {
label: entry.label,
type: entry.type,
value: entry.value,
displayValue: entry.displayValue,
};
return acc;
}, {});
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
@ -199,6 +330,10 @@ export const getById = query({
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect();
const customFieldsRecord = mapCustomFieldsToRecord(
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
);
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
@ -290,7 +425,7 @@ export const getById = query({
: null,
},
description: undefined,
customFields: {},
customFields: customFieldsRecord,
timeline: timeline.map((ev) => {
let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
@ -323,6 +458,14 @@ export const create = mutation({
requesterId: v.id("users"),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
},
handler: async (ctx, args) => {
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
@ -342,6 +485,8 @@ export const create = mutation({
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
throw new ConvexError("Subcategoria inválida");
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
@ -374,6 +519,7 @@ export const create = mutation({
tags: [],
slaPolicyId: undefined,
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {