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

View file

@ -3,6 +3,8 @@ import type { MutationCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values" import { ConvexError, v } from "convex/values"
import { Id } from "./_generated/dataModel" import { Id } from "./_generated/dataModel"
import { requireAdmin } from "./rbac"
type CategorySeed = { type CategorySeed = {
name: string name: string
description?: string description?: string
@ -295,10 +297,13 @@ export const ensureDefaults = mutation({
export const createCategory = mutation({ export const createCategory = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
name: v.string(), name: v.string(),
description: v.optional(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() const trimmed = name.trim()
if (trimmed.length < 2) { if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a categoria") throw new ConvexError("Informe um nome válido para a categoria")
@ -321,6 +326,31 @@ export const createCategory = mutation({
createdAt: now, createdAt: now,
updatedAt: 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 return id
}, },
}) })
@ -329,10 +359,12 @@ export const updateCategory = mutation({
args: { args: {
categoryId: v.id("ticketCategories"), categoryId: v.id("ticketCategories"),
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
name: v.string(), name: v.string(),
description: v.optional(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) const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) { if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada") throw new ConvexError("Categoria não encontrada")
@ -354,9 +386,11 @@ export const deleteCategory = mutation({
args: { args: {
categoryId: v.id("ticketCategories"), categoryId: v.id("ticketCategories"),
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
transferTo: v.optional(v.id("ticketCategories")), 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) const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) { if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada") throw new ConvexError("Categoria não encontrada")
@ -412,10 +446,12 @@ export const deleteCategory = mutation({
export const createSubcategory = mutation({ export const createSubcategory = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
categoryId: v.id("ticketCategories"), categoryId: v.id("ticketCategories"),
name: v.string(), 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) const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) { if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada") throw new ConvexError("Categoria não encontrada")
@ -449,10 +485,12 @@ export const createSubcategory = mutation({
export const updateSubcategory = mutation({ export const updateSubcategory = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
subcategoryId: v.id("ticketSubcategories"), subcategoryId: v.id("ticketSubcategories"),
name: v.string(), 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) const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) { if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada") throw new ConvexError("Subcategoria não encontrada")
@ -471,10 +509,12 @@ export const updateSubcategory = mutation({
export const deleteSubcategory = mutation({ export const deleteSubcategory = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),
actorId: v.id("users"),
subcategoryId: v.id("ticketSubcategories"), subcategoryId: v.id("ticketSubcategories"),
transferTo: v.optional(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) const subcategory = await ctx.db.get(subcategoryId)
if (!subcategory || subcategory.tenantId !== tenantId) { if (!subcategory || subcategory.tenantId !== tenantId) {
throw new ConvexError("Subcategoria não encontrada") 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 { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel"; 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; 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({ export const create = mutation({
args: { args: {
tenantId: v.string(), 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(), updatedAt: v.number(),
createdAt: v.number(), createdAt: v.number(),
tags: v.optional(v.array(v.string())), 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()), totalWorkedMs: v.optional(v.number()),
activeSessionId: v.optional(v.id("ticketWorkSessions")), activeSessionId: v.optional(v.id("ticketWorkSessions")),
}) })
@ -153,4 +165,25 @@ export default defineSchema({
.index("by_tenant_key", ["tenantId", "key"]) .index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"]) .index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant", ["tenantId"]), .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 { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel"; import { Id, type Doc } from "./_generated/dataModel";
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
return teams.map((team) => renameQueueString(team) ?? team); 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({ export const list = query({
args: { args: {
viewerId: v.optional(v.id("users")), viewerId: v.optional(v.id("users")),
@ -199,6 +330,10 @@ export const getById = query({
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect(); .collect();
const customFieldsRecord = mapCustomFieldsToRecord(
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
);
const commentsHydrated = await Promise.all( const commentsHydrated = await Promise.all(
comments.map(async (c) => { comments.map(async (c) => {
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null; const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
@ -290,7 +425,7 @@ export const getById = query({
: null, : null,
}, },
description: undefined, description: undefined,
customFields: {}, customFields: customFieldsRecord,
timeline: timeline.map((ev) => { timeline: timeline.map((ev) => {
let payload = ev.payload; let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in 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"), requesterId: v.id("users"),
categoryId: v.id("ticketCategories"), categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"), subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { role } = await requireUser(ctx, args.actorId, args.tenantId) 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) { if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
throw new ConvexError("Subcategoria inválida"); throw new ConvexError("Subcategoria inválida");
} }
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant) // compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db const existing = await ctx.db
.query("tickets") .query("tickets")
@ -374,6 +519,7 @@ export const create = mutation({
tags: [], tags: [],
slaPolicyId: undefined, slaPolicyId: undefined,
dueAt: undefined, dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
}); });
const requester = await ctx.db.get(args.requesterId); const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {

View file

@ -231,6 +231,43 @@ model AuthAccount {
@@index([userId]) @@index([userId])
} }
model AuthInvite {
id String @id @default(cuid())
email String
name String?
role String @default("agent")
tenantId String
token String @unique
status String @default("pending")
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
acceptedAt DateTime?
acceptedById String?
revokedAt DateTime?
revokedById String?
revokedReason String?
events AuthInviteEvent[]
@@index([tenantId, status])
@@index([tenantId, email])
}
model AuthInviteEvent {
id String @id @default(cuid())
inviteId String
type String
payload Json?
actorId String?
createdAt DateTime @default(now())
invite AuthInvite @relation(fields: [inviteId], references: [id], onDelete: Cascade)
@@index([inviteId, createdAt])
}
model AuthVerification { model AuthVerification {
id String @id @default(cuid()) id String @id @default(cuid())
identifier String identifier String

View file

@ -1,17 +1,22 @@
import { QueuesManager } from "@/components/admin/queues/queues-manager" import { QueuesManager } from "@/components/admin/queues/queues-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function AdminChannelsPage() { export default function AdminChannelsPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Filas e canais</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Filas e canais"
Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento. lead="Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento."
</p> />
</header> }
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<QueuesManager /> <QueuesManager />
</main> </div>
</AppShell>
) )
} }

View file

@ -1,17 +1,24 @@
import { CategoriesManager } from "@/components/admin/categories/categories-manager"
import { FieldsManager } from "@/components/admin/fields/fields-manager" import { FieldsManager } from "@/components/admin/fields/fields-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function AdminFieldsPage() { export default function AdminFieldsPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Campos personalizados</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Categorias e campos personalizados"
Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant. lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets."
</p> />
</header> }
>
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
<CategoriesManager />
<FieldsManager /> <FieldsManager />
</main> </div>
</AppShell>
) )
} }

View file

@ -1,7 +1,10 @@
import { AdminUsersManager } from "@/components/admin/admin-users-manager" import { AdminUsersManager } from "@/components/admin/admin-users-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz" import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@ -31,18 +34,46 @@ async function loadUsers() {
})) }))
} }
async function loadInvites(): Promise<NormalizedInvite[]> {
const invites = await prisma.authInvite.findMany({
orderBy: { createdAt: "desc" },
include: {
events: {
orderBy: { createdAt: "asc" },
},
},
})
const now = new Date()
return invites.map((invite) => normalizeInvite(invite, now))
}
export default async function AdminPage() { export default async function AdminPage() {
const users = await loadUsers() const users = await loadUsers()
const invites = await loadInvites()
const invitesForClient = invites.map((invite) => {
const { events, ...rest } = invite
void events
return rest
})
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<div className="mb-8"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1> <SiteHeader
<p className="mt-2 text-sm text-neutral-600"> title="Administração"
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento. lead="Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento."
</p> />
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<AdminUsersManager
initialUsers={users}
initialInvites={invitesForClient}
roleOptions={ROLE_OPTIONS}
defaultTenantId={DEFAULT_TENANT_ID}
/>
</div> </div>
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} /> </AppShell>
</main>
) )
} }

View file

@ -1,17 +1,22 @@
import { SlasManager } from "@/components/admin/slas/slas-manager" import { SlasManager } from "@/components/admin/slas/slas-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function AdminSlasPage() { export default function AdminSlasPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Políticas de SLA</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Políticas de SLA"
Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço. lead="Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço."
</p> />
</header> }
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<SlasManager /> <SlasManager />
</main> </div>
</AppShell>
) )
} }

View file

@ -1,17 +1,22 @@
import { TeamsManager } from "@/components/admin/teams/teams-manager" import { TeamsManager } from "@/components/admin/teams/teams-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function AdminTeamsPage() { export default function AdminTeamsPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Times e agentes</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Times e agentes"
Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs. lead="Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs."
</p> />
</header> }
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<TeamsManager /> <TeamsManager />
</main> </div>
</AppShell>
) )
} }

View file

@ -0,0 +1,93 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
type RevokePayload = {
reason?: string
}
async function syncInvite(invite: NormalizedInvite) {
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return
const client = new ConvexHttpClient(convexUrl)
await client.mutation(api.invites.sync, {
tenantId: invite.tenantId,
inviteId: invite.id,
email: invite.email,
name: invite.name ?? undefined,
role: invite.role.toUpperCase(),
status: invite.status,
token: invite.token,
expiresAt: Date.parse(invite.expiresAt),
createdAt: Date.parse(invite.createdAt),
createdById: invite.createdById ?? undefined,
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
acceptedById: invite.acceptedById ?? undefined,
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
revokedById: invite.revokedById ?? undefined,
revokedReason: invite.revokedReason ?? undefined,
})
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as Partial<RevokePayload> | null
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
const invite = await prisma.authInvite.findUnique({
where: { id: params.id },
include: { events: { orderBy: { createdAt: "asc" } } },
})
if (!invite) {
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
}
const now = new Date()
const status = computeInviteStatus(invite, now)
if (status === "accepted") {
return NextResponse.json({ error: "Convite já aceito" }, { status: 400 })
}
if (status === "revoked") {
const normalized = normalizeInvite(invite, now)
await syncInvite(normalized)
return NextResponse.json({ invite: normalized })
}
const updated = await prisma.authInvite.update({
where: { id: invite.id },
data: {
status: "revoked",
revokedAt: now,
revokedById: session.user.id ?? null,
revokedReason: reason,
},
})
const event = await prisma.authInviteEvent.create({
data: {
inviteId: invite.id,
type: "revoked",
payload: reason ? { reason } : null,
actorId: session.user.id ?? null,
},
})
const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now)
await syncInvite(normalized)
return NextResponse.json({ invite: normalized })
}

View file

@ -0,0 +1,205 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
const DEFAULT_EXPIRATION_DAYS = 7
function normalizeRole(input: string | null | undefined): RoleOption {
const role = (input ?? "agent").toLowerCase() as RoleOption
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
}
function generateToken() {
return randomBytes(32).toString("hex")
}
async function syncInviteWithConvex(invite: NormalizedInvite) {
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return
const client = new ConvexHttpClient(convexUrl)
await client.mutation(api.invites.sync, {
tenantId: invite.tenantId,
inviteId: invite.id,
email: invite.email,
name: invite.name ?? undefined,
role: invite.role.toUpperCase(),
status: invite.status,
token: invite.token,
expiresAt: Date.parse(invite.expiresAt),
createdAt: Date.parse(invite.createdAt),
createdById: invite.createdById ?? undefined,
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
acceptedById: invite.acceptedById ?? undefined,
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
revokedById: invite.revokedById ?? undefined,
revokedReason: invite.revokedReason ?? undefined,
})
}
async function appendEvent(inviteId: string, type: string, actorId: string | null, payload: unknown = null) {
return prisma.authInviteEvent.create({
data: {
inviteId,
type,
payload,
actorId,
},
})
}
async function refreshInviteStatus(invite: InviteWithEvents, now: Date) {
const computedStatus = computeInviteStatus(invite, now)
if (computedStatus === invite.status) {
return invite
}
const updated = await prisma.authInvite.update({
where: { id: invite.id },
data: { status: computedStatus },
})
const event = await appendEvent(invite.id, computedStatus, null)
const inviteWithEvents: InviteWithEvents = {
...updated,
events: [...invite.events, event],
}
return inviteWithEvents
}
function buildInvitePayload(invite: InviteWithEvents, now: Date) {
const normalized = normalizeInvite(invite, now)
return normalized
}
export async function GET() {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const invites = await prisma.authInvite.findMany({
orderBy: { createdAt: "desc" },
include: {
events: {
orderBy: { createdAt: "asc" },
},
},
})
const now = new Date()
const results: NormalizedInvite[] = []
for (const invite of invites) {
const updatedInvite = await refreshInviteStatus(invite, now)
const normalized = buildInvitePayload(updatedInvite, now)
await syncInviteWithConvex(normalized)
results.push(normalized)
}
return NextResponse.json({ invites: results })
}
type CreateInvitePayload = {
email: string
name?: string
role?: RoleOption
tenantId?: string
expiresInDays?: number
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as Partial<CreateInvitePayload> | null
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""
if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const name = typeof body.name === "string" ? body.name.trim() : undefined
const role = normalizeRole(body.role)
const tenantId = typeof body.tenantId === "string" && body.tenantId.trim() ? body.tenantId.trim() : session.user.tenantId || DEFAULT_TENANT_ID
const expiresInDays = Number.isFinite(body.expiresInDays) ? Math.max(1, Number(body.expiresInDays)) : DEFAULT_EXPIRATION_DAYS
const existing = await prisma.authInvite.findFirst({
where: {
email,
status: { in: ["pending", "accepted"] },
},
include: { events: true },
})
const now = new Date()
if (existing) {
const computed = computeInviteStatus(existing, now)
if (computed === "pending") {
return NextResponse.json({ error: "Já existe um convite pendente para este e-mail" }, { status: 409 })
}
if (computed === "accepted") {
return NextResponse.json({ error: "Este e-mail já possui acesso ativo" }, { status: 409 })
}
if (existing.status !== computed) {
await prisma.authInvite.update({ where: { id: existing.id }, data: { status: computed } })
await appendEvent(existing.id, computed, session.user.id ?? null)
const refreshed = await prisma.authInvite.findUnique({
where: { id: existing.id },
include: { events: true },
})
if (refreshed) {
const normalizedExisting = buildInvitePayload(refreshed, now)
await syncInviteWithConvex(normalizedExisting)
}
}
}
const token = generateToken()
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
const invite = await prisma.authInvite.create({
data: {
email,
name,
role,
tenantId,
token,
status: "pending",
expiresAt,
createdById: session.user.id ?? null,
},
})
const event = await appendEvent(invite.id, "created", session.user.id ?? null, {
role,
tenantId,
expiresAt: expiresAt.toISOString(),
})
const inviteWithEvents: InviteWithEvents = {
...invite,
events: [event],
}
const normalized = buildInvitePayload(inviteWithEvents, now)
await syncInviteWithConvex(normalized)
return NextResponse.json({ invite: normalized })
}

View file

@ -0,0 +1,196 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks types in Next routes
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import {
computeInviteStatus,
normalizeInvite,
normalizeRoleOption,
type NormalizedInvite,
} from "@/server/invite-utils"
type AcceptInvitePayload = {
name?: string
password: string
}
function validatePassword(password: string) {
return password.length >= 8
}
async function syncInvite(invite: NormalizedInvite) {
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return
const client = new ConvexHttpClient(convexUrl)
await client.mutation(api.invites.sync, {
tenantId: invite.tenantId,
inviteId: invite.id,
email: invite.email,
name: invite.name ?? undefined,
role: invite.role.toUpperCase(),
status: invite.status,
token: invite.token,
expiresAt: Date.parse(invite.expiresAt),
createdAt: Date.parse(invite.createdAt),
createdById: invite.createdById ?? undefined,
acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined,
acceptedById: invite.acceptedById ?? undefined,
revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined,
revokedById: invite.revokedById ?? undefined,
revokedReason: invite.revokedReason ?? undefined,
})
}
export async function GET(_request: Request, { params }: { params: { token: string } }) {
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
if (!invite) {
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
}
const now = new Date()
const status = computeInviteStatus(invite, now)
if (status !== invite.status) {
await prisma.authInvite.update({ where: { id: invite.id }, data: { status } })
const event = await prisma.authInviteEvent.create({
data: {
inviteId: invite.id,
type: status,
payload: null,
actorId: null,
},
})
invite.status = status
invite.events.push(event)
}
const normalized = normalizeInvite(invite, now)
await syncInvite(normalized)
return NextResponse.json({ invite: normalized })
}
export async function POST(request: Request, { params }: { params: { token: string } }) {
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
if (!payload || typeof payload.password !== "string") {
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
}
if (!validatePassword(payload.password)) {
return NextResponse.json({ error: "Senha deve conter pelo menos 8 caracteres" }, { status: 400 })
}
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
if (!invite) {
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
}
const now = new Date()
const status = computeInviteStatus(invite, now)
if (status === "expired") {
await prisma.authInvite.update({ where: { id: invite.id }, data: { status: "expired" } })
const event = await prisma.authInviteEvent.create({
data: {
inviteId: invite.id,
type: "expired",
payload: null,
actorId: null,
},
})
invite.status = "expired"
invite.events.push(event)
const normalizedExpired = normalizeInvite(invite, now)
await syncInvite(normalizedExpired)
return NextResponse.json({ error: "Convite expirado" }, { status: 410 })
}
if (status === "revoked") {
return NextResponse.json({ error: "Convite revogado" }, { status: 410 })
}
if (status === "accepted") {
return NextResponse.json({ error: "Convite já utilizado" }, { status: 409 })
}
const existingUser = await prisma.authUser.findUnique({ where: { email: invite.email } })
if (existingUser) {
return NextResponse.json({ error: "Usuário já registrado" }, { status: 409 })
}
const name = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : invite.name || invite.email
const tenantId = invite.tenantId || DEFAULT_TENANT_ID
const role = normalizeRoleOption(invite.role)
const hashedPassword = await hashPassword(payload.password)
const user = await prisma.authUser.create({
data: {
email: invite.email,
name,
role,
tenantId,
accounts: {
create: {
providerId: "credential",
accountId: invite.email,
password: hashedPassword,
},
},
},
})
const updatedInvite = await prisma.authInvite.update({
where: { id: invite.id },
data: {
status: "accepted",
acceptedAt: now,
acceptedById: user.id,
name,
},
})
const event = await prisma.authInviteEvent.create({
data: {
inviteId: invite.id,
type: "accepted",
payload: { userId: user.id },
actorId: user.id,
},
})
const normalized = normalizeInvite({ ...updatedInvite, events: [...invite.events, event] }, now)
await syncInvite(normalized)
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId,
email: invite.email,
name,
avatarUrl: undefined,
role: role.toUpperCase(),
})
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({ success: true })
}

View file

@ -0,0 +1,40 @@
import { notFound } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { prisma } from "@/lib/prisma"
import { normalizeInvite } from "@/server/invite-utils"
import { InviteAcceptForm } from "@/components/invite/invite-accept-form"
export const dynamic = "force-dynamic"
export default async function InvitePage({ params }: { params: { token: string } }) {
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
if (!invite) {
notFound()
}
const normalized = normalizeInvite(invite, new Date())
const { events: unusedEvents, inviteUrl: unusedInviteUrl, ...summary } = normalized
void unusedEvents
void unusedInviteUrl
return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-4 py-16">
<Card className="w-full border border-border/70 shadow-sm">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-2xl font-semibold text-neutral-900">Aceitar convite</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Conclua seu cadastro para acessar a plataforma Sistema de chamados.
</CardDescription>
</CardHeader>
<CardContent>
<InviteAcceptForm invite={summary} />
</CardContent>
</Card>
</main>
)
}

View file

@ -30,7 +30,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
export default function NewTicketPage() { export default function NewTicketPage() {
const router = useRouter() const router = useRouter()
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const queuesRaw = useQuery(
convexUserId ? api.queues.summary : "skip",
queueArgs
) as TicketQueueSummary[] | undefined
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)

View file

@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -28,8 +29,28 @@ type AdminUser = {
updatedAt: string | null updatedAt: string | null
} }
type AdminInvite = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: "pending" | "accepted" | "revoked" | "expired"
token: string
inviteUrl: string
expiresAt: string
createdAt: string
createdById: string | null
acceptedAt: string | null
acceptedById: string | null
revokedAt: string | null
revokedById: string | null
revokedReason: string | null
}
type Props = { type Props = {
initialUsers: AdminUser[] initialUsers: AdminUser[]
initialInvites: AdminInvite[]
roleOptions: readonly RoleOption[] roleOptions: readonly RoleOption[]
defaultTenantId: string defaultTenantId: string
} }
@ -42,29 +63,59 @@ function formatDate(dateIso: string) {
}).format(date) }).format(date)
} }
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) { function formatStatus(status: AdminInvite["status"]) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers) switch (status) {
case "pending":
return "Pendente"
case "accepted":
return "Aceito"
case "revoked":
return "Revogado"
case "expired":
return "Expirado"
default:
return status
}
}
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
const { events: unusedEvents, ...rest } = invite
void unusedEvents
return rest
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
const [users] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [name, setName] = useState("") const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent") const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId) const [tenantId, setTenantId] = useState(defaultTenantId)
const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null) const [expiresInDays, setExpiresInDays] = useState<string>("7")
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions]) const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
if (!email || !email.includes("@")) { if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido") toast.error("Informe um e-mail válido")
return return
} }
const payload = { email, name, role, tenantId } const payload = {
email,
name,
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
}
startTransition(async () => { startTransition(async () => {
try { try {
const response = await fetch("/api/admin/users", { const response = await fetch("/api/admin/invites", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário") throw new Error(data.error ?? "Não foi possível gerar o convite")
} }
const data = (await response.json()) as { const data = (await response.json()) as { invite: AdminInvite }
user: AdminUser const nextInvite = sanitizeInvite(data.invite)
temporaryPassword: string setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
}
setUsers((previous) => [data.user, ...previous])
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
setEmail("") setEmail("")
setName("") setName("")
setRole("agent") setRole("agent")
setTenantId(defaultTenantId) setTenantId(defaultTenantId)
toast.success("Usuário criado com sucesso") setExpiresInDays("7")
setLastInviteLink(nextInvite.inviteUrl)
toast.success("Convite criado com sucesso")
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Falha ao criar usuário" const message = error instanceof Error ? error.message : "Falha ao criar convite"
toast.error(message) toast.error(message)
} }
}) })
} }
function handleCopy(link: string) {
navigator.clipboard
.writeText(link)
.then(() => toast.success("Link de convite copiado"))
.catch(() => toast.error("Não foi possível copiar o link"))
}
async function handleRevoke(inviteId: string) {
const invite = invites.find((item) => item.id === inviteId)
if (!invite || invite.status !== "pending") {
return
}
const confirmed = window.confirm("Deseja revogar este convite?")
if (!confirmed) return
setRevokingId(inviteId)
try {
const response = await fetch(`/api/admin/invites/${inviteId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "Revogado manualmente" }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao revogar convite")
}
const data = (await response.json()) as { invite: AdminInvite }
const updated = sanitizeInvite(data.invite)
setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item)))
toast.success("Convite revogado")
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao revogar convite"
toast.error(message)
} finally {
setRevokingId(null)
}
}
return ( return (
<Tabs defaultValue="users" className="w-full"> <Tabs defaultValue="invites" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1"> <TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger> <TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger> <TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger> <TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="users" className="mt-6 space-y-6"> <TabsContent value="invites" className="mt-6 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Convidar novo usuário</CardTitle> <CardTitle>Gerar convite</CardTitle>
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription> <CardDescription>
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_auto]"> <form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="invite-email">E-mail corporativo</Label> <Label htmlFor="invite-email">E-mail corporativo</Label>
<Input <Input
@ -142,7 +238,15 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
<SelectContent> <SelectContent>
{normalizedRoles.map((item) => ( {normalizedRoles.map((item) => (
<SelectItem key={item} value={item}> <SelectItem key={item} value={item}>
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item} {item === "customer"
? "Cliente"
: item === "admin"
? "Administrador"
: item === "manager"
? "Gestor"
: item === "agent"
? "Agente"
: "Colaborador"}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
onChange={(event) => setTenantId(event.target.value)} onChange={(event) => setTenantId(event.target.value)}
/> />
</div> </div>
<div className="grid gap-2">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
<SelectItem value="30">30 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end"> <div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full"> <Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Criando..." : "Criar acesso"} {isPending ? "Gerando..." : "Gerar convite"}
</Button> </Button>
</div> </div>
</form> </form>
{lastInvite ? ( {lastInviteLink ? (
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700"> <div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
<p className="font-medium">Acesso provisório gerado</p> <div>
<p className="mt-1 text-neutral-600"> <p className="font-medium text-neutral-900">Link de convite pronto</p>
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial <p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>. <p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
Solicite que altere após o primeiro login. </div>
</p> <Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
Copiar link
</Button>
</div> </div>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invites.map((invite) => (
<tr key={invite.id} className="hover:bg-slate-50">
<td className="py-3 pr-4">
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={6} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Equipe cadastrada</CardTitle> <CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</CardDescription> <CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="overflow-x-auto"> <CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm"> <table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">

View file

@ -0,0 +1,555 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks generated types
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketCategory, TicketSubcategory } from "@/lib/schemas/category"
import type { Id } from "@/convex/_generated/dataModel"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string }
| null
export function CategoriesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined
const [categoryName, setCategoryName] = useState("")
const [categoryDescription, setCategoryDescription] = useState("")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
const createCategory = useMutation(api.categories.createCategory)
const deleteCategory = useMutation(api.categories.deleteCategory)
const updateCategory = useMutation(api.categories.updateCategory)
const createSubcategory = useMutation(api.categories.createSubcategory)
const updateSubcategory = useMutation(api.categories.updateSubcategory)
const deleteSubcategory = useMutation(api.categories.deleteSubcategory)
const isCreatingCategory = useMemo(
() => categoryName.trim().length < 2,
[categoryName]
)
function addSubcategory() {
const value = subcategoryDraft.trim()
if (value.length < 2) {
toast.error("Informe um nome válido para a subcategoria")
return
}
const normalized = value.toLowerCase()
const exists = subcategoryList.some((item) => item.toLowerCase() === normalized)
if (exists) {
toast.error("Essa subcategoria já foi adicionada")
return
}
setSubcategoryList((items) => [...items, value])
setSubcategoryDraft("")
}
function removeSubcategory(target: string) {
setSubcategoryList((items) => items.filter((item) => item !== target))
}
async function handleCreateCategory() {
if (!convexUserId) return
const name = categoryName.trim()
if (name.length < 2) {
toast.error("Informe um nome válido para a categoria")
return
}
toast.loading("Criando categoria...", { id: "category:create" })
try {
await createCategory({
tenantId,
actorId: convexUserId as Id<"users">,
name,
description: categoryDescription.trim() || undefined,
secondary: subcategoryList,
})
toast.success("Categoria criada!", { id: "category:create" })
setCategoryName("")
setCategoryDescription("")
setSubcategoryDraft("")
setSubcategoryList([])
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a categoria", { id: "category:create" })
}
}
async function handleDeleteCategory(id: string) {
if (!convexUserId) return
toast.loading("Removendo categoria...", { id: "category:delete" })
try {
await deleteCategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: id as Id<"ticketCategories">,
})
toast.success("Categoria removida", { id: "category:delete" })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a categoria", { id: "category:delete" })
}
}
async function handleUpdateCategory(target: TicketCategory, next: { name: string; description: string }) {
if (!convexUserId) return
const name = next.name.trim()
if (name.length < 2) {
toast.error("Informe um nome válido")
return
}
toast.loading("Atualizando categoria...", { id: `category:update:${target.id}` })
try {
await updateCategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: target.id as Id<"ticketCategories">,
name,
description: next.description.trim() || undefined,
})
toast.success("Categoria atualizada", { id: `category:update:${target.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a categoria", { id: `category:update:${target.id}` })
}
}
async function handleCreateSubcategory(categoryId: string, payload: { name: string }) {
if (!convexUserId) return
const name = payload.name.trim()
if (name.length < 2) {
toast.error("Informe um nome válido para a subcategoria")
return
}
toast.loading("Adicionando subcategoria...", { id: `subcategory:create:${categoryId}` })
try {
await createSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
name,
})
toast.success("Subcategoria criada", { id: `subcategory:create:${categoryId}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a subcategoria", { id: `subcategory:create:${categoryId}` })
}
}
async function handleUpdateSubcategory(target: TicketSubcategory, name: string) {
if (!convexUserId) return
const trimmed = name.trim()
if (trimmed.length < 2) {
toast.error("Informe um nome válido")
return
}
toast.loading("Atualizando subcategoria...", { id: `subcategory:update:${target.id}` })
try {
await updateSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
subcategoryId: target.id as Id<"ticketSubcategories">,
name: trimmed,
})
toast.success("Subcategoria atualizada", { id: `subcategory:update:${target.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a subcategoria", { id: `subcategory:update:${target.id}` })
}
}
async function handleDeleteSubcategory(id: string) {
if (!convexUserId) return
toast.loading("Removendo subcategoria...", { id: `subcategory:delete:${id}` })
try {
await deleteSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
subcategoryId: id as Id<"ticketSubcategories">,
})
toast.success("Subcategoria removida", { id: `subcategory:delete:${id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a subcategoria", { id: `subcategory:delete:${id}` })
}
}
const pendingDelete = deleteState
const isDisabled = !convexUserId
return (
<div className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
<p className="text-sm text-neutral-600">
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
imediatamente para novos atendimentos.
</p>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
<div className="space-y-2">
<Label htmlFor="category-name">Nome da categoria</Label>
<Input
id="category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Ex.: Incidentes"
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category-description">Descrição (opcional)</Label>
<Textarea
id="category-description"
value={categoryDescription}
onChange={(event) => setCategoryDescription(event.target.value)}
placeholder="Contextualize quando usar esta categoria"
rows={3}
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="subcategory-name"
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Lentidão"
disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addSubcategory()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={addSubcategory}
disabled={isDisabled || subcategoryDraft.trim().length < 2}
className="shrink-0"
>
Adicionar subcategoria
</Button>
</div>
{subcategoryList.length ? (
<div className="flex flex-wrap gap-2">
{subcategoryList.map((item) => (
<div
key={item}
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
>
<span>{item}</span>
<button
type="button"
onClick={() => removeSubcategory(item)}
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
disabled={isDisabled}
>
×
</button>
</div>
))}
</div>
) : null}
</div>
</div>
<Button
onClick={handleCreateCategory}
disabled={isDisabled || isCreatingCategory}
className="w-full sm:w-auto"
>
Adicionar categoria
</Button>
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
<p className="font-medium text-neutral-800">Boas práticas</p>
<ul className="list-disc space-y-1 pl-4">
<li>Mantenha nomes concisos e fáceis de entender.</li>
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
</ul>
</div>
</div>
</section>
<div className="space-y-4">
{categories?.length ? (
categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
onUpdate={handleUpdateCategory}
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
onCreateSubcategory={handleCreateSubcategory}
onUpdateSubcategory={handleUpdateSubcategory}
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
disabled={isDisabled}
/>
))
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma categoria cadastrada ainda.
</div>
)}
</div>
<Dialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setDeleteState(null)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Confirmar remoção</DialogTitle>
<DialogDescription>
A remoção é permanente. Certifique-se de que não tickets em aberto associados.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<Label htmlFor="delete-reason">Motivo (opcional)</Label>
<Textarea
id="delete-reason"
rows={3}
placeholder="Descreva o motivo da remoção"
value={pendingDelete?.reason ?? ""}
onChange={(event) =>
setDeleteState((current) =>
current ? { ...current, reason: event.target.value } : current
)
}
/>
<p className="text-xs text-neutral-500">
Caso existam tickets vinculados, será necessário mover para outra categoria antes de continuar.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteState(null)}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingDelete
if (!target) return
if (target.type === "category") {
await handleDeleteCategory(target.targetId)
} else {
await handleDeleteSubcategory(target.targetId)
}
setDeleteState(null)
}}
>
Remover
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
interface CategoryItemProps {
category: TicketCategory
disabled?: boolean
onUpdate: (category: TicketCategory, next: { name: string; description: string }) => Promise<void>
onDelete: () => void
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void
}
function CategoryItem({
category,
disabled,
onUpdate,
onDelete,
onCreateSubcategory,
onUpdateSubcategory,
onDeleteSubcategory,
}: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name)
const [description, setDescription] = useState(category.description ?? "")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const hasSubcategories = category.secondary.length > 0
async function handleSave() {
await onUpdate(category, { name, description })
setIsEditing(false)
}
return (
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
{isEditing ? (
<div className="flex flex-col gap-2">
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} />
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
placeholder="Descrição"
disabled={disabled}
/>
</div>
) : (
<>
<h4 className="text-base font-semibold text-neutral-900">{category.name}</h4>
{category.description ? (
<p className="text-sm text-neutral-600">{category.description}</p>
) : null}
</>
)}
</div>
<div className="flex items-center gap-2">
{hasSubcategories ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
{category.secondary.length} subcategorias
</Badge>
) : null}
{isEditing ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</div>
)}
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Subcategorias</p>
<div className="space-y-2">
{category.secondary.length ? (
category.secondary.map((subcategory) => (
<SubcategoryItem
key={subcategory.id}
subcategory={subcategory}
disabled={disabled}
onUpdate={(value) => onUpdateSubcategory(subcategory, value)}
onDelete={() => onDeleteSubcategory(subcategory.id)}
/>
))
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/60 p-3 text-sm text-neutral-600">
Nenhuma subcategoria cadastrada.
</div>
)}
</div>
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-slate-200 bg-white p-3">
<Label className="text-xs uppercase tracking-wide text-neutral-500">Nova subcategoria</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Configuração"
disabled={disabled}
/>
<Button
size="sm"
className="sm:w-auto"
onClick={async () => {
if (!subcategoryDraft.trim()) return
await onCreateSubcategory(category.id, { name: subcategoryDraft })
setSubcategoryDraft("")
}}
disabled={disabled || subcategoryDraft.trim().length < 2}
>
Adicionar
</Button>
</div>
</div>
</div>
</div>
)
}
interface SubcategoryItemProps {
subcategory: TicketSubcategory
disabled?: boolean
onUpdate: (nextValue: string) => Promise<void>
onDelete: () => void
}
function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: SubcategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(subcategory.name)
async function handleSave() {
await onUpdate(name)
setIsEditing(false)
}
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-sm">
{isEditing ? (
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} className="max-w-sm" />
) : (
<span className="text-sm font-medium text-neutral-800">{subcategory.name}</span>
)}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Renomear
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</>
)}
</div>
</div>
)
}

View file

@ -15,6 +15,7 @@ import {
Timer, Timer,
Plug, Plug,
Layers3, Layers3,
UserPlus,
Settings, Settings,
} from "lucide-react" } from "lucide-react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
@ -78,6 +79,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
title: "Administração", title: "Administração",
requiredRole: "admin", requiredRole: "admin",
items: [ items: [
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" }, { title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" }, { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },

View file

@ -0,0 +1,174 @@
"use client"
import { useMemo, useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { RoleOption } from "@/lib/authz"
type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
type InviteSummary = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: InviteStatus
token: string
expiresAt: string
}
function formatDate(dateIso: string) {
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(dateIso))
}
function statusLabel(status: InviteStatus) {
switch (status) {
case "pending":
return "Pendente"
case "accepted":
return "Aceito"
case "revoked":
return "Revogado"
case "expired":
return "Expirado"
default:
return status
}
}
function statusVariant(status: InviteStatus) {
if (status === "pending") return "secondary"
if (status === "accepted") return "default"
if (status === "revoked") return "destructive"
return "outline"
}
export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
const router = useRouter()
const [name, setName] = useState(invite.name ?? "")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isPending, startTransition] = useTransition()
const formattedExpiry = useMemo(() => formatDate(invite.expiresAt), [invite.expiresAt])
const isDisabled = invite.status !== "pending"
function validate() {
if (!password || password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres")
return false
}
if (password !== confirmPassword) {
toast.error("As senhas não coincidem")
return false
}
return true
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isDisabled) return
if (!validate()) return
startTransition(async () => {
try {
const response = await fetch(`/api/invites/${invite.token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível aceitar o convite")
}
toast.success("Convite aceito! Faça login para começar.")
router.push(`/login?email=${encodeURIComponent(invite.email)}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao aceitar convite"
toast.error(message)
}
})
}
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
{statusLabel(invite.status)}
</Badge>
<div className="space-y-1">
<p className="text-sm text-neutral-600">
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
</p>
<p className="text-xs text-neutral-500">
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
</p>
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
</div>
</div>
{isDisabled ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-600">
<p>
Este convite encontra-se <span className="font-semibold text-neutral-900">{statusLabel(invite.status).toLowerCase()}</span>.
Solicite um novo convite à equipe administradora caso precise de acesso.
</p>
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome completo</Label>
<Input
id="invite-name"
placeholder="Seu nome"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
disabled={isDisabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password">Defina uma senha</Label>
<Input
id="invite-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Mínimo de 8 caracteres"
autoComplete="new-password"
disabled={isDisabled}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password-confirm">Confirme a senha</Label>
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
disabled={isDisabled}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isDisabled || isPending}>
{isPending ? "Processando..." : "Ativar acesso"}
</Button>
</form>
</div>
)
}

View file

@ -55,7 +55,13 @@ export function NewTicketDialog() {
mode: "onTouched", mode: "onTouched",
}) })
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const queuesRaw = useQuery(
convexUserId ? api.queues.summary : "skip",
queueArgs
) as TicketQueueSummary[] | undefined
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)

View file

@ -32,7 +32,12 @@ const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border b
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter() const router = useRouter()
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [] const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const queueSummary = (
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
) ?? []
const playNext = useMutation(api.tickets.playNext) const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined) const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)

View file

@ -7,13 +7,19 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
interface TicketQueueSummaryProps { interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[] queues?: TicketQueueSummary[]
} }
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) { export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) const { convexUserId } = useAuth()
const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const fromServer = useQuery(convexUserId ? api.queues.summary : "skip", queueArgs)
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? []) const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) { if (!queues && fromServer === undefined) {

View file

@ -66,7 +66,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const pauseWork = useMutation(api.tickets.pauseWork) const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories) const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? [] const queueArgs = convexUserId
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
const queues = (
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
) ?? []
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId) const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const [status] = useState<TicketStatus>(ticket.status) const [status] = useState<TicketStatus>(ticket.status)
const workSummaryRemote = useQuery( const workSummaryRemote = useQuery(

View file

@ -18,7 +18,7 @@ export function TicketsView() {
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const queues = useQuery( const queues = useQuery(
api.queues.summary, convexUserId ? api.queues.summary : "skip",
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketQueueSummary[] | undefined ) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery( const ticketsRaw = useQuery(

View file

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-neutral-400 selection:bg-neutral-900 selection:text-white aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex min-h-[80px] w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-2 text-base text-neutral-800 shadow-sm transition-colors outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 md:text-sm",
"focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -86,9 +86,16 @@ const serverEventSchema = z.object({
createdAt: z.number(), createdAt: z.number(),
}); });
const serverCustomFieldValueSchema = z.object({
label: z.string(),
type: z.string(),
value: z.any().optional(),
displayValue: z.string().optional(),
});
const serverTicketWithDetailsSchema = serverTicketSchema.extend({ const serverTicketWithDetailsSchema = serverTicketSchema.extend({
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
customFields: z.record(z.string(), z.any()).optional(), customFields: z.record(z.string(), serverCustomFieldValueSchema).optional(),
timeline: z.array(serverEventSchema), timeline: z.array(serverEventSchema),
comments: z.array(serverCommentSchema), comments: z.array(serverCommentSchema),
}); });
@ -126,9 +133,27 @@ export function mapTicketsFromServerList(arr: unknown[]) {
export function mapTicketWithDetailsFromServer(input: unknown) { export function mapTicketWithDetailsFromServer(input: unknown) {
const s = serverTicketWithDetailsSchema.parse(input); const s = serverTicketWithDetailsSchema.parse(input);
const customFields = Object.entries(s.customFields ?? {}).reduce<
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
>(
(acc, [key, value]) => {
let parsedValue: unknown = value.value;
if (value.type === "date" && typeof value.value === "number") {
parsedValue = new Date(value.value);
}
acc[key] = {
label: value.label,
type: value.type,
value: parsedValue,
displayValue: value.displayValue,
};
return acc;
},
{}
);
const ui = { const ui = {
...s, ...s,
customFields: (s.customFields ?? {}) as Record<string, unknown>, customFields,
category: s.category ?? undefined, category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined, subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined,

View file

@ -276,9 +276,9 @@ export const ticketDetails = tickets.map((ticket) => ({
description: description:
"Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.", "Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.",
customFields: { customFields: {
ambiente: "Produção", ambiente: { label: "Ambiente", type: "select", value: "producao", displayValue: "Produção" },
categoria: "Incidente", categoria: { label: "Categoria", type: "text", value: "Incidente" },
impacto: "Alto", impacto: { label: "Impacto", type: "select", value: "alto", displayValue: "Alto" },
}, },
timeline: timeline:
timelineByTicket[ticket.id] ?? timelineByTicket[ticket.id] ??

View file

@ -78,6 +78,17 @@ export const ticketEventSchema = z.object({
}) })
export type TicketEvent = z.infer<typeof ticketEventSchema> export type TicketEvent = z.infer<typeof ticketEventSchema>
export const ticketFieldTypeSchema = z.enum(["text", "number", "select", "date", "boolean"])
export type TicketFieldType = z.infer<typeof ticketFieldTypeSchema>
export const ticketCustomFieldValueSchema = z.object({
label: z.string(),
type: ticketFieldTypeSchema,
value: z.union([z.string(), z.number(), z.boolean(), z.coerce.date()]).optional(),
displayValue: z.string().optional(),
})
export type TicketCustomFieldValue = z.infer<typeof ticketCustomFieldValueSchema>
export const ticketSchema = z.object({ export const ticketSchema = z.object({
id: z.string(), id: z.string(),
reference: z.number(), reference: z.number(),
@ -131,7 +142,7 @@ export type Ticket = z.infer<typeof ticketSchema>
export const ticketWithDetailsSchema = ticketSchema.extend({ export const ticketWithDetailsSchema = ticketSchema.extend({
description: z.string().optional(), description: z.string().optional(),
customFields: z.record(z.string(), z.any()).optional(), customFields: z.record(z.string(), ticketCustomFieldValueSchema).optional(),
timeline: z.array(ticketEventSchema), timeline: z.array(ticketEventSchema),
comments: z.array(ticketCommentSchema), comments: z.array(ticketCommentSchema),
}) })

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { computeInviteStatus, normalizeInvite, normalizeRoleOption } from "@/server/invite-utils"
const baseInvite = {
id: "invite-1",
email: "user@sistema.dev",
name: "Usuário Teste",
role: "agent",
tenantId: "tenant-1",
token: "token",
status: "pending",
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
createdById: "admin-1",
acceptedAt: null,
acceptedById: null,
revokedAt: null,
revokedById: null,
revokedReason: null,
} as const
describe("invite-utils", () => {
it("computes pending status when invite is valid", () => {
const status = computeInviteStatus(baseInvite)
expect(status).toBe("pending")
})
it("computes expired status when invite is past expiration", () => {
const expiredInvite = { ...baseInvite, expiresAt: new Date(Date.now() - 1) }
const status = computeInviteStatus(expiredInvite)
expect(status).toBe("expired")
})
it("honors revoked status regardless of expiration", () => {
const revoked = { ...baseInvite, status: "revoked" as const, expiresAt: new Date(Date.now() - 1) }
const status = computeInviteStatus(revoked)
expect(status).toBe("revoked")
})
it("normalizes invite payload with defaults", () => {
const normalized = normalizeInvite({ ...baseInvite, events: [] })
expect(normalized.email).toBe(baseInvite.email)
expect(normalized.status).toBe("pending")
expect(normalized.events).toHaveLength(0)
})
it("normalizes role falling back to agent", () => {
expect(normalizeRoleOption("admin")).toBe("admin")
expect(normalizeRoleOption("unknown")).toBe("agent")
})
})

View file

@ -0,0 +1,98 @@
import type { AuthInvite, AuthInviteEvent } from "@prisma/client"
import { ROLE_OPTIONS, type RoleOption, normalizeRole } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
export type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
export type InviteWithEvents = AuthInvite & {
events: AuthInviteEvent[]
}
export type InviteEventPayload = {
id: string
type: string
createdAt: string
actorId: string | null
payload: unknown
}
export type NormalizedInvite = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: InviteStatus
token: string
inviteUrl: string
expiresAt: string
createdAt: string
createdById: string | null
acceptedAt: string | null
acceptedById: string | null
revokedAt: string | null
revokedById: string | null
revokedReason: string | null
events: InviteEventPayload[]
}
const DEFAULT_APP_URL = env.NEXT_PUBLIC_APP_URL ?? env.BETTER_AUTH_URL ?? "http://localhost:3000"
export function computeInviteStatus(invite: AuthInvite, now: Date = new Date()): InviteStatus {
if (invite.status === "revoked") return "revoked"
if (invite.status === "accepted") return "accepted"
if (invite.status === "expired") return "expired"
if (invite.expiresAt.getTime() <= now.getTime()) {
return "expired"
}
return "pending"
}
export function buildInviteUrl(token: string) {
const base = DEFAULT_APP_URL.endsWith("/") ? DEFAULT_APP_URL.slice(0, -1) : DEFAULT_APP_URL
return `${base}/invite/${token}`
}
export function normalizeRoleOption(role?: string | null): RoleOption {
const normalized = normalizeRole(role)
if (normalized && (ROLE_OPTIONS as readonly string[]).includes(normalized)) {
return normalized as RoleOption
}
return "agent"
}
export function normalizeInvite(invite: InviteWithEvents, now: Date = new Date()): NormalizedInvite {
const status = computeInviteStatus(invite, now)
const inviteUrl = buildInviteUrl(invite.token)
return {
id: invite.id,
email: invite.email,
name: invite.name ?? null,
role: normalizeRoleOption(invite.role),
tenantId: invite.tenantId ?? DEFAULT_TENANT_ID,
status,
token: invite.token,
inviteUrl,
expiresAt: invite.expiresAt.toISOString(),
createdAt: invite.createdAt.toISOString(),
createdById: invite.createdById ?? null,
acceptedAt: invite.acceptedAt ? invite.acceptedAt.toISOString() : null,
acceptedById: invite.acceptedById ?? null,
revokedAt: invite.revokedAt ? invite.revokedAt.toISOString() : null,
revokedById: invite.revokedById ?? null,
revokedReason: invite.revokedReason ?? null,
events: invite.events
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((event) => ({
id: event.id,
type: event.type,
createdAt: event.createdAt.toISOString(),
actorId: event.actorId ?? null,
payload: event.payload,
})),
}
}

View file

@ -12,6 +12,7 @@ export default defineConfig({
environment: "node", environment: "node",
globals: true, globals: true,
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts"],
setupFiles: ["./vitest.setup.ts"],
}, },
}) })

3
web/vitest.setup.ts Normal file
View file

@ -0,0 +1,3 @@
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL