feat: implement invite onboarding and dynamic ticket fields

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -1,4 +1,5 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] {
return teams.map((team) => renameQueueString(team) ?? team);
}
type CustomFieldInput = {
fieldId: Id<"ticketFields">;
value: unknown;
};
type NormalizedCustomField = {
fieldId: Id<"ticketFields">;
fieldKey: string;
label: string;
type: string;
value: unknown;
displayValue?: string;
};
function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
switch (field.type) {
case "text":
return { value: String(raw).trim() };
case "number": {
const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
if (!Number.isFinite(value)) {
throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
}
return { value };
}
case "date": {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: raw };
}
const parsed = Date.parse(String(raw));
if (!Number.isFinite(parsed)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: parsed };
}
case "boolean": {
if (typeof raw === "boolean") {
return { value: raw };
}
if (typeof raw === "string") {
const normalized = raw.toLowerCase();
if (normalized === "true" || normalized === "1") return { value: true };
if (normalized === "false" || normalized === "0") return { value: false };
}
throw new ConvexError(`Valor inválido para o campo ${field.label}`);
}
case "select": {
if (!field.options || field.options.length === 0) {
throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
}
const value = String(raw);
const option = field.options.find((opt) => opt.value === value);
if (!option) {
throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
}
return { value: option.value, displayValue: option.label ?? option.value };
}
default:
return { value: raw };
}
}
async function normalizeCustomFieldValues(
ctx: Pick<MutationCtx, "db">,
tenantId: string,
inputs: CustomFieldInput[] | undefined
): Promise<NormalizedCustomField[]> {
const definitions = await ctx.db
.query("ticketFields")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (!definitions.length) {
if (inputs && inputs.length > 0) {
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
}
return [];
}
const provided = new Map<Id<"ticketFields">, unknown>();
for (const entry of inputs ?? []) {
provided.set(entry.fieldId, entry.value);
}
const normalized: NormalizedCustomField[] = [];
for (const definition of definitions.sort((a, b) => a.order - b.order)) {
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
const isMissing =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0);
if (isMissing) {
if (definition.required) {
throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
}
continue;
}
const { value, displayValue } = coerceCustomFieldValue(definition, raw);
normalized.push({
fieldId: definition._id,
fieldKey: definition.key,
label: definition.label,
type: definition.type,
value,
displayValue,
});
}
return normalized;
}
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
if (!entries || entries.length === 0) return {};
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
acc[entry.fieldKey] = {
label: entry.label,
type: entry.type,
value: entry.value,
displayValue: entry.displayValue,
};
return acc;
}, {});
}
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
@ -199,6 +330,10 @@ export const getById = query({
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect();
const customFieldsRecord = mapCustomFieldsToRecord(
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
);
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
@ -290,7 +425,7 @@ export const getById = query({
: null,
},
description: undefined,
customFields: {},
customFields: customFieldsRecord,
timeline: timeline.map((ev) => {
let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
@ -323,6 +458,14 @@ export const create = mutation({
requesterId: v.id("users"),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("ticketFields"),
value: v.any(),
})
)
),
},
handler: async (ctx, args) => {
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
@ -342,6 +485,8 @@ export const create = mutation({
if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
throw new ConvexError("Subcategoria inválida");
}
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
@ -374,6 +519,7 @@ export const create = mutation({
tags: [],
slaPolicyId: undefined,
dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {

View file

@ -231,6 +231,43 @@ model AuthAccount {
@@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 {
id String @id @default(cuid())
identifier String

View file

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

View file

@ -1,7 +1,10 @@
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma"
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
export const runtime = "nodejs"
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() {
const users = await loadUsers()
const invites = await loadInvites()
const invitesForClient = invites.map((invite) => {
const { events, ...rest } = invite
void events
return rest
})
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<div className="mb-8">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1>
<p className="mt-2 text-sm text-neutral-600">
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento.
</p>
<AppShell
header={
<SiteHeader
title="Administração"
lead="Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento."
/>
}
>
<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>
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} />
</main>
</AppShell>
)
}

View file

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

View file

@ -1,17 +1,22 @@
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 default function AdminTeamsPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Times e agentes</h1>
<p className="text-sm text-neutral-600">
Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs.
</p>
</header>
<TeamsManager />
</main>
<AppShell
header={
<SiteHeader
title="Times e agentes"
lead="Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<TeamsManager />
</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() {
const router = useRouter()
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 create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)

View file

@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@ -28,8 +29,28 @@ type AdminUser = {
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 = {
initialUsers: AdminUser[]
initialInvites: AdminInvite[]
roleOptions: readonly RoleOption[]
defaultTenantId: string
}
@ -42,29 +63,59 @@ function formatDate(dateIso: string) {
}).format(date)
}
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
function formatStatus(status: AdminInvite["status"]) {
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 [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
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 normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = { email, name, role, tenantId }
const payload = {
email,
name,
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
}
startTransition(async () => {
try {
const response = await fetch("/api/admin/users", {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
if (!response.ok) {
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 {
user: AdminUser
temporaryPassword: string
}
setUsers((previous) => [data.user, ...previous])
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
const data = (await response.json()) as { invite: AdminInvite }
const nextInvite = sanitizeInvite(data.invite)
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
setEmail("")
setName("")
setRole("agent")
setTenantId(defaultTenantId)
toast.success("Usuário criado com sucesso")
setExpiresInDays("7")
setLastInviteLink(nextInvite.inviteUrl)
toast.success("Convite criado com sucesso")
} 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)
}
})
}
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 (
<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">
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-6 space-y-6">
<TabsContent value="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Convidar novo usuário</CardTitle>
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
</CardDescription>
</CardHeader>
<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">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
@ -142,7 +238,15 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
<SelectContent>
{normalizedRoles.map((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>
))}
</SelectContent>
@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }
onChange={(event) => setTenantId(event.target.value)}
/>
</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">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Criando..." : "Criar acesso"}
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInvite ? (
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
<p className="font-medium">Acesso provisório gerado</p>
<p className="mt-1 text-neutral-600">
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
Solicite que altere após o primeiro login.
</p>
{lastInviteLink ? (
<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">
<div>
<p className="font-medium text-neutral-900">Link de convite pronto</p>
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
</div>
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
Copiar link
</Button>
</div>
) : null}
</CardContent>
</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>
<CardHeader>
<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>
<CardContent className="overflow-x-auto">
<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,
Plug,
Layers3,
UserPlus,
Settings,
} from "lucide-react"
import { usePathname } from "next/navigation"
@ -78,6 +79,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
title: "Administração",
requiredRole: "admin",
items: [
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, 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",
})
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 create = useMutation(api.tickets.create)
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) {
const router = useRouter()
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 [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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
}
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) ?? [])
if (!queues && fromServer === undefined) {

View file

@ -66,7 +66,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories)
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 [status] = useState<TicketStatus>(ticket.status)
const workSummaryRemote = useQuery(

View file

@ -18,7 +18,7 @@ export function TicketsView() {
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const queues = useQuery(
api.queues.summary,
convexUserId ? api.queues.summary : "skip",
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketQueueSummary[] | undefined
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(),
});
const serverCustomFieldValueSchema = z.object({
label: z.string(),
type: z.string(),
value: z.any().optional(),
displayValue: z.string().optional(),
});
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
description: z.string().optional().nullable(),
customFields: z.record(z.string(), z.any()).optional(),
customFields: z.record(z.string(), serverCustomFieldValueSchema).optional(),
timeline: z.array(serverEventSchema),
comments: z.array(serverCommentSchema),
});
@ -126,9 +133,27 @@ export function mapTicketsFromServerList(arr: unknown[]) {
export function mapTicketWithDetailsFromServer(input: unknown) {
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 = {
...s,
customFields: (s.customFields ?? {}) as Record<string, unknown>,
customFields,
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,

View file

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

View file

@ -78,6 +78,17 @@ export const ticketEventSchema = z.object({
})
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({
id: z.string(),
reference: z.number(),
@ -131,7 +142,7 @@ export type Ticket = z.infer<typeof ticketSchema>
export const ticketWithDetailsSchema = ticketSchema.extend({
description: z.string().optional(),
customFields: z.record(z.string(), z.any()).optional(),
customFields: z.record(z.string(), ticketCustomFieldValueSchema).optional(),
timeline: z.array(ticketEventSchema),
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",
globals: true,
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