feat: implement invite onboarding and dynamic ticket fields
This commit is contained in:
parent
29a647f6c6
commit
f24a7f68ca
34 changed files with 2240 additions and 97 deletions
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
115
web/convex/invites.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue