chore: reorganize project structure and ensure default queues
This commit is contained in:
parent
854887f499
commit
1cccb852a5
201 changed files with 417 additions and 838 deletions
93
src/app/api/admin/invites/[id]/route.ts
Normal file
93
src/app/api/admin/invites/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
205
src/app/api/admin/invites/route.ts
Normal file
205
src/app/api/admin/invites/route.ts
Normal 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 })
|
||||
}
|
||||
122
src/app/api/admin/users/route.ts
Normal file
122
src/app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
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 generatePassword(length = 12) {
|
||||
const bytes = randomBytes(length)
|
||||
return Array.from(bytes)
|
||||
.map((byte) => (byte % 36).toString(36))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const users = await prisma.authUser.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null)
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
|
||||
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
|
||||
const roleInput = typeof payload.role === "string" ? payload.role : undefined
|
||||
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
|
||||
|
||||
if (!emailInput || !emailInput.includes("@")) {
|
||||
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const role = normalizeRole(roleInput)
|
||||
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
|
||||
|
||||
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
|
||||
}
|
||||
|
||||
const password = generatePassword()
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const user = await prisma.authUser.create({
|
||||
data: {
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
role,
|
||||
tenantId,
|
||||
accounts: {
|
||||
create: {
|
||||
providerId: "credential",
|
||||
accountId: emailInput,
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
await convex.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
avatarUrl: undefined,
|
||||
role: role.toUpperCase(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Falha ao sincronizar usuário no Convex", error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ user, temporaryPassword: password })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue