Some checks failed
- Cria 10 novos templates React Email (invite, password-reset, new-login, sla-warning, sla-breached, ticket-created, ticket-resolved, ticket-assigned, ticket-status, ticket-comment) - Adiciona envio de email ao criar convite de usuario - Adiciona security_invite em COLLABORATOR_VISIBLE_TYPES - Melhora tabela de equipe com badges de papel e colunas fixas - Atualiza TicketCard com nova interface de props - Remove botao de limpeza de dados antigos do admin 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
7.6 KiB
TypeScript
248 lines
7.6 KiB
TypeScript
import { NextResponse } from "next/server"
|
|
import { randomBytes } from "crypto"
|
|
|
|
import { Prisma } from "@/lib/prisma"
|
|
import { ConvexHttpClient } from "convex/browser"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import { assertStaffSession } from "@/lib/auth-server"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
|
import { env } from "@/lib/env"
|
|
import { prisma } from "@/lib/prisma"
|
|
import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils"
|
|
import { notifyUserInvite } from "@/server/notification/notification-service"
|
|
|
|
const DEFAULT_EXPIRATION_DAYS = 7
|
|
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
|
|
|
|
function toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
|
|
if (payload === null || payload === undefined) {
|
|
return JSON_NULL
|
|
}
|
|
return payload as Prisma.InputJsonValue
|
|
}
|
|
|
|
function normalizeRole(input: string | null | undefined): RoleOption {
|
|
const role = (input ?? "agent").toLowerCase() as RoleOption
|
|
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
|
}
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
admin: "Administrador",
|
|
manager: "Gestor",
|
|
agent: "Agente",
|
|
collaborator: "Colaborador",
|
|
}
|
|
|
|
function formatRoleName(role: string): string {
|
|
return ROLE_LABELS[role.toLowerCase()] ?? role
|
|
}
|
|
|
|
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: toJsonPayload(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 assertStaffSession()
|
|
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 assertStaffSession()
|
|
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 isSessionAdmin = isAdmin(session.user.role)
|
|
if (!isSessionAdmin && !["manager", "collaborator"].includes(role)) {
|
|
return NextResponse.json({ error: "Agentes só podem convidar gestores ou colaboradores" }, { status: 403 })
|
|
}
|
|
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)
|
|
|
|
// Envia email de convite
|
|
const inviteUrl = buildInviteUrl(token)
|
|
const inviterName = session.user.name ?? session.user.email
|
|
const roleName = formatRoleName(role)
|
|
|
|
try {
|
|
await notifyUserInvite(
|
|
email,
|
|
name ?? null,
|
|
inviterName,
|
|
roleName,
|
|
null, // companyName - não temos essa informação no convite
|
|
inviteUrl
|
|
)
|
|
} catch (error) {
|
|
// Log do erro mas não falha a criação do convite
|
|
console.error("[invites] Falha ao enviar email de convite:", error)
|
|
}
|
|
|
|
return NextResponse.json({ invite: normalized })
|
|
}
|