feat: expand admin companies and users modules

This commit is contained in:
Esdras Renan 2025-10-22 01:27:43 -03:00
parent a043b1203c
commit 2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions

View file

@ -1,48 +1,99 @@
import { NextResponse } from "next/server"
import { ZodError } from "zod"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { removeConvexCompany, syncConvexCompany } from "@/server/companies-sync"
import {
buildCompanyData,
fetchCompanyById,
formatZodError,
normalizeCompany,
sanitizeCompanyInput,
} from "@/server/company-service"
import type { CompanyFormValues } from "@/lib/schemas/company"
export const runtime = "nodejs"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
function mergePayload(base: CompanyFormValues, updates: Record<string, unknown>): Record<string, unknown> {
const merged: Record<string, unknown> = { ...base, ...updates }
if (!("businessHours" in updates)) merged.businessHours = base.businessHours
if (!("communicationChannels" in updates)) merged.communicationChannels = base.communicationChannels
if (!("clientDomains" in updates)) merged.clientDomains = base.clientDomains
if (!("fiscalAddress" in updates)) merged.fiscalAddress = base.fiscalAddress
if (!("regulatedEnvironments" in updates)) merged.regulatedEnvironments = base.regulatedEnvironments
if (!("contacts" in updates)) merged.contacts = base.contacts
if (!("locations" in updates)) merged.locations = base.locations
if (!("contracts" in updates)) merged.contracts = base.contracts
if (!("sla" in updates)) merged.sla = base.sla
if (!("tags" in updates)) merged.tags = base.tags
if (!("customFields" in updates)) merged.customFields = base.customFields
if ("privacyPolicy" in updates) {
const incoming = updates.privacyPolicy
if (incoming && typeof incoming === "object") {
merged.privacyPolicy = {
...base.privacyPolicy,
...incoming,
}
}
} else {
merged.privacyPolicy = base.privacyPolicy
}
merged.tenantId = base.tenantId
return merged
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Apenas administradores podem editar empresas" }, { status: 403 })
}
const { id } = await params
const raw = (await request.json()) as Partial<{
name: string
slug: string
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
isAvulso: boolean
contractedHoursPerMonth: number | string | null
}>
const updates: Record<string, unknown> = {}
if (typeof raw.name === "string" && raw.name.trim()) updates.name = raw.name.trim()
if (typeof raw.slug === "string" && raw.slug.trim()) updates.slug = raw.slug.trim()
if ("cnpj" in raw) updates.cnpj = raw.cnpj ?? null
if ("domain" in raw) updates.domain = raw.domain ?? null
if ("phone" in raw) updates.phone = raw.phone ?? null
if ("description" in raw) updates.description = raw.description ?? null
if ("address" in raw) updates.address = raw.address ?? null
if ("isAvulso" in raw) updates.isAvulso = Boolean(raw.isAvulso)
if ("contractedHoursPerMonth" in raw) {
const v = raw.contractedHoursPerMonth
updates.contractedHoursPerMonth = typeof v === "number" ? v : v ? Number(v) : null
}
const { id } = await params
try {
const company = await prisma.company.update({ where: { id }, data: updates })
const existing = await fetchCompanyById(id)
if (!existing) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })
}
if (existing.tenantId !== (session.user.tenantId ?? existing.tenantId)) {
return NextResponse.json({ error: "Acesso negado" }, { status: 403 })
}
const rawBody = (await request.json()) as Record<string, unknown>
const normalized = normalizeCompany(existing)
const {
id: _ignoreId,
provisioningCode: _ignoreCode,
createdAt: _createdAt,
updatedAt: _updatedAt,
...baseForm
} = normalized
void _ignoreId
void _ignoreCode
void _createdAt
void _updatedAt
const mergedInput = mergePayload(baseForm, rawBody)
const form = sanitizeCompanyInput(mergedInput, existing.tenantId)
const createData = buildCompanyData(form, existing.tenantId)
const { tenantId: _omitTenant, ...updateData } = createData
void _omitTenant
const company = await prisma.company.update({
where: { id },
data: updateData,
})
if (company.provisioningCode) {
const synced = await syncConvexCompany({
@ -56,17 +107,23 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
}
return NextResponse.json({ company })
return NextResponse.json({ company: normalizeCompany(company) })
} catch (error) {
console.error("Failed to update company", error)
if (error instanceof ZodError) {
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json({ error: "Já existe uma empresa com este slug." }, { status: 409 })
}
console.error("Failed to update company", error)
return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 })
}
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
if (!isAdmin(session.user.role)) {
@ -74,10 +131,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
}
const { id } = await params
const company = await prisma.company.findUnique({
where: { id },
select: { id: true, tenantId: true, name: true, slug: true },
})
const company = await fetchCompanyById(id)
if (!company) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })

View file

@ -1,22 +1,31 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { ZodError } from "zod"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { syncConvexCompany } from "@/server/companies-sync"
import {
buildCompanyData,
fetchCompaniesByTenant,
formatZodError,
normalizeCompany,
sanitizeCompanyInput,
} from "@/server/company-service"
export const runtime = "nodejs"
const DEFAULT_TENANT_ID = "tenant-atlas"
export async function GET() {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const companies = await prisma.company.findMany({
orderBy: { name: "asc" },
})
return NextResponse.json({ companies })
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const companies = await fetchCompaniesByTenant(tenantId)
return NextResponse.json({ companies: companies.map(normalizeCompany) })
}
export async function POST(request: Request) {
@ -26,43 +35,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Apenas administradores podem criar empresas" }, { status: 403 })
}
const body = (await request.json()) as Partial<{
name: string
slug: string
isAvulso: boolean
contractedHoursPerMonth: number | string | null
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
}>
const { name, slug, isAvulso, contractedHoursPerMonth, cnpj, domain, phone, description, address } = body ?? {}
if (!name || !slug) {
return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 })
}
try {
const rawBody = await request.json()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const form = sanitizeCompanyInput(rawBody, tenantId)
const provisioningCode = randomBytes(32).toString("hex")
const company = await prisma.company.create({
data: {
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
...buildCompanyData(form, tenantId),
provisioningCode,
// Campos opcionais
isAvulso: Boolean(isAvulso ?? false),
contractedHoursPerMonth:
typeof contractedHoursPerMonth === "number"
? contractedHoursPerMonth
: contractedHoursPerMonth
? Number(contractedHoursPerMonth)
: null,
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,
phone: phone ? String(phone) : null,
description: description ? String(description) : null,
address: address ? String(address) : null,
},
})
@ -78,16 +60,18 @@ export async function POST(request: Request) {
}
}
return NextResponse.json({ company })
return NextResponse.json({ company: normalizeCompany(company) })
} catch (error) {
console.error("Failed to create company", error)
if (error instanceof ZodError) {
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
// Duplicidade de slug por tenant ou provisioningCode único
return NextResponse.json(
{ error: "Já existe uma empresa com este slug ou código de provisionamento." },
{ status: 409 }
)
}
console.error("Failed to create company", error)
return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 })
}
}