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

@ -0,0 +1,489 @@
import { Prisma, type Company, type CompanyStateRegistrationType } from "@prisma/client"
import { prisma } from "@/lib/prisma"
import { ZodError } from "zod"
import {
companyFormSchema,
companyInputSchema,
type CompanyCommunicationChannels,
type CompanyFormValues,
type CompanyStateRegistrationTypeOption,
} from "@/lib/schemas/company"
export type NormalizedCompany = CompanyFormValues & {
id: string
provisioningCode: string | null
createdAt: string
updatedAt: string
}
function slugify(value?: string | null): string {
if (!value) return ""
const ascii = value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
const collapsed = ascii.trim().replace(/[_\s]+/g, "-")
const sanitized = collapsed.replace(/-+/g, "-").toLowerCase()
return sanitized.replace(/^-+|-+$/g, "")
}
function ensureSlugValue(
inputSlug: string | null | undefined,
fallbackName: string | null | undefined,
fallbackId?: string
): string {
const slugFromInput = slugify(inputSlug)
if (slugFromInput) return slugFromInput
const slugFromName = slugify(fallbackName)
if (slugFromName) return slugFromName
if (fallbackId) {
const slugFromId = slugify(fallbackId)
if (slugFromId) return slugFromId
return fallbackId.toLowerCase()
}
return ""
}
const STATE_REGISTRATION_TYPE_TO_PRISMA: Record<
CompanyStateRegistrationTypeOption,
CompanyStateRegistrationType
> = {
standard: "STANDARD",
exempt: "EXEMPT",
simples: "SIMPLES",
}
const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
CompanyStateRegistrationType,
CompanyStateRegistrationTypeOption
> = {
STANDARD: "standard",
EXEMPT: "exempt",
SIMPLES: "simples",
}
export function formatZodError(error: ZodError) {
return error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
}))
}
function sanitizeDomain(value?: string | null) {
if (!value) return null
const trimmed = value.trim().toLowerCase()
return trimmed.length === 0 ? null : trimmed
}
function sanitizePhone(value?: string | null) {
if (!value) return null
const trimmed = value.trim()
return trimmed.length === 0 ? null : trimmed
}
function normalizeChannels(
channels?: Partial<CompanyCommunicationChannels> | null
): CompanyCommunicationChannels {
const ensure = (values?: string[]) =>
Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)))
return {
supportEmails: ensure(channels?.supportEmails).map((email) => email.toLowerCase()),
billingEmails: ensure(channels?.billingEmails).map((email) => email.toLowerCase()),
whatsappNumbers: ensure(channels?.whatsappNumbers),
phones: ensure(channels?.phones),
portals: ensure(channels?.portals),
}
}
function mergeChannelsWithPrimary(
payload: CompanyFormValues,
base?: CompanyCommunicationChannels
): CompanyCommunicationChannels {
const channels = normalizeChannels(base ?? payload.communicationChannels)
const supportEmails = new Set(channels.supportEmails)
const billingEmails = new Set(channels.billingEmails)
const phones = new Set(channels.phones)
if (payload.supportEmail) supportEmails.add(payload.supportEmail.toLowerCase())
if (payload.billingEmail) billingEmails.add(payload.billingEmail.toLowerCase())
if (payload.phone) phones.add(payload.phone)
return {
supportEmails: Array.from(supportEmails),
billingEmails: Array.from(billingEmails),
whatsappNumbers: channels.whatsappNumbers,
phones: Array.from(phones),
portals: channels.portals,
}
}
export function sanitizeCompanyInput(input: unknown, tenantId: string): CompanyFormValues {
const parsed = companyInputSchema.safeParse(input)
if (!parsed.success) {
throw parsed.error
}
const raw = parsed.data
const normalizedName = raw.name?.trim() ?? ""
const normalizedSlug = ensureSlugValue(raw.slug, normalizedName, raw.slug ?? raw.name ?? undefined)
const cnpjDigits =
typeof raw.cnpj === "string" ? raw.cnpj.replace(/\D/g, "").slice(0, 14) : null
const normalizedContacts = (raw.contacts ?? []).map((contact) => ({
...contact,
email: contact.email?.trim().toLowerCase(),
phone: sanitizePhone(contact.phone),
whatsapp: sanitizePhone(contact.whatsapp),
preference: Array.from(new Set(contact.preference ?? [])),
}))
const normalizedLocations = (raw.locations ?? []).map((location) => ({
...location,
responsibleContactId: location.responsibleContactId ?? null,
serviceWindow: location.serviceWindow ?? { mode: "inherit", periods: [] },
}))
const normalizedContracts = (raw.contracts ?? []).map((contract) => ({
...contract,
scope: Array.from(new Set(contract.scope ?? [])),
}))
const normalizedTags = Array.from(
new Set((raw.tags ?? []).map((tag) => tag.trim()).filter(Boolean))
)
const normalizedCustomFields = (raw.customFields ?? []).map((field) => ({
...field,
label: field.label.trim(),
key: field.key.trim(),
}))
const normalized: CompanyFormValues = companyFormSchema.parse({
...raw,
tenantId,
name: normalizedName,
slug: normalizedSlug,
legalName: raw.legalName?.trim() ?? null,
tradeName: raw.tradeName?.trim() ?? null,
cnpj: cnpjDigits && cnpjDigits.length === 14 ? cnpjDigits : null,
stateRegistration: raw.stateRegistration?.trim() ?? null,
primaryCnae: raw.primaryCnae?.trim() ?? null,
description: raw.description?.trim() ?? null,
domain: sanitizeDomain(raw.domain),
phone: sanitizePhone(raw.phone),
address: raw.address?.trim() ?? null,
communicationChannels: normalizeChannels(raw.communicationChannels),
supportEmail: raw.supportEmail?.trim().toLowerCase() ?? null,
billingEmail: raw.billingEmail?.trim().toLowerCase() ?? null,
clientDomains: Array.from(
new Set((raw.clientDomains ?? []).map((domain) => domain.trim().toLowerCase()).filter(Boolean))
),
fiscalAddress: raw.fiscalAddress ?? null,
regulatedEnvironments: Array.from(new Set(raw.regulatedEnvironments ?? [])),
contacts: normalizedContacts,
locations: normalizedLocations,
contracts: normalizedContracts,
businessHours: raw.businessHours ?? null,
sla: raw.sla ?? null,
tags: normalizedTags,
customFields: normalizedCustomFields,
notes: raw.notes?.trim() ?? null,
privacyPolicy: raw.privacyPolicy
? {
accepted: raw.privacyPolicy.accepted ?? false,
reference: raw.privacyPolicy.reference ?? null,
metadata: raw.privacyPolicy.metadata,
}
: {
accepted: false,
reference: null,
},
})
return normalized
}
export function buildCompanyData(
payload: CompanyFormValues,
tenantId: string
): Omit<Prisma.CompanyCreateInput, "provisioningCode"> {
const stateRegistrationType = payload.stateRegistrationType
? STATE_REGISTRATION_TYPE_TO_PRISMA[payload.stateRegistrationType as CompanyStateRegistrationTypeOption]
: null
const communicationChannels = mergeChannelsWithPrimary(payload)
const privacyPolicyMetadata = payload.privacyPolicy?.metadata ?? null
return {
tenantId,
name: payload.name.trim(),
slug: payload.slug.trim(),
isAvulso: payload.isAvulso ?? false,
contractedHoursPerMonth: payload.contractedHoursPerMonth ?? null,
cnpj: payload.cnpj ?? null,
domain: payload.domain ?? null,
phone: payload.phone ?? null,
description: payload.description ?? null,
address: payload.address ?? null,
legalName: payload.legalName ?? null,
tradeName: payload.tradeName ?? null,
stateRegistration: payload.stateRegistration ?? null,
stateRegistrationType,
primaryCnae: payload.primaryCnae ?? null,
timezone: payload.businessHours?.timezone ?? null,
businessHours: payload.businessHours ?? Prisma.JsonNull,
supportEmail: payload.supportEmail ?? null,
billingEmail: payload.billingEmail ?? null,
contactPreferences:
payload.contactPreferences || payload.supportEmail || payload.billingEmail
? ({
...payload.contactPreferences,
supportEmail: payload.supportEmail ?? null,
billingEmail: payload.billingEmail ?? null,
} satisfies Prisma.InputJsonValue)
: Prisma.JsonNull,
clientDomains: payload.clientDomains,
communicationChannels,
fiscalAddress: payload.fiscalAddress ?? Prisma.JsonNull,
hasBranches: payload.hasBranches ?? false,
regulatedEnvironments: payload.regulatedEnvironments,
privacyPolicyAccepted: payload.privacyPolicy?.accepted ?? false,
privacyPolicyReference: payload.privacyPolicy?.reference ?? null,
privacyPolicyMetadata: privacyPolicyMetadata
? (privacyPolicyMetadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
contacts: payload.contacts,
locations: payload.locations,
contracts: payload.contracts,
sla: payload.sla ?? Prisma.JsonNull,
tags: payload.tags,
customFields: payload.customFields,
notes: payload.notes ?? null,
}
}
export function normalizeCompany(company: Company): NormalizedCompany {
const communicationChannels = normalizeChannels(
company.communicationChannels as CompanyCommunicationChannels | null | undefined
)
const normalizedName = (company.name ?? "").trim()
const normalizedSlug = ensureSlugValue(company.slug, normalizedName || company.name, company.id)
const base: CompanyFormValues = {
tenantId: company.tenantId,
name: normalizedName,
slug: normalizedSlug,
legalName: company.legalName,
tradeName: company.tradeName,
cnpj: company.cnpj,
stateRegistration: company.stateRegistration,
stateRegistrationType: company.stateRegistrationType
? STATE_REGISTRATION_TYPE_FROM_PRISMA[company.stateRegistrationType]
: undefined,
primaryCnae: company.primaryCnae,
description: company.description,
domain: company.domain,
phone: company.phone,
address: company.address,
contractedHoursPerMonth: company.contractedHoursPerMonth,
businessHours: (company.businessHours as CompanyFormValues["businessHours"]) ?? null,
communicationChannels,
supportEmail: company.supportEmail,
billingEmail: company.billingEmail,
contactPreferences: (company.contactPreferences as CompanyFormValues["contactPreferences"]) ?? undefined,
clientDomains: (company.clientDomains as string[] | null) ?? [],
fiscalAddress: (company.fiscalAddress as CompanyFormValues["fiscalAddress"]) ?? null,
hasBranches: Boolean(company.hasBranches),
regulatedEnvironments: (company.regulatedEnvironments as string[] | null) ?? [],
privacyPolicy: {
accepted: Boolean(company.privacyPolicyAccepted),
reference: company.privacyPolicyReference ?? null,
metadata: company.privacyPolicyMetadata
? (company.privacyPolicyMetadata as Record<string, unknown>)
: undefined,
},
contacts: (company.contacts as CompanyFormValues["contacts"]) ?? [],
locations: (company.locations as CompanyFormValues["locations"]) ?? [],
contracts: (company.contracts as CompanyFormValues["contracts"]) ?? [],
sla: (company.sla as CompanyFormValues["sla"]) ?? null,
tags: (company.tags as string[] | null) ?? [],
customFields: (company.customFields as CompanyFormValues["customFields"]) ?? [],
notes: company.notes ?? null,
isAvulso: Boolean(company.isAvulso),
}
const payload = companyFormSchema.parse({
...base,
communicationChannels: mergeChannelsWithPrimary(base, communicationChannels),
})
return {
...payload,
id: company.id,
provisioningCode: company.provisioningCode ?? null,
createdAt: company.createdAt.toISOString(),
updatedAt: company.updatedAt.toISOString(),
}
}
type RawCompanyRow = {
id: string
tenantId: string
name: string
slug: string
provisioningCode: string | null
isAvulso: number | boolean | null
contractedHoursPerMonth: number | null
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
legalName: string | null
tradeName: string | null
stateRegistration: string | null
stateRegistrationType: string | null
primaryCnae: string | null
timezone: string | null
businessHours: string | null
supportEmail: string | null
billingEmail: string | null
contactPreferences: string | null
clientDomains: string | null
communicationChannels: string | null
fiscalAddress: string | null
hasBranches: number | null
regulatedEnvironments: string | null
privacyPolicyAccepted: number | null
privacyPolicyReference: string | null
privacyPolicyMetadata: string | null
contacts: string | null
locations: string | null
contracts: string | null
sla: string | null
tags: string | null
customFields: string | null
notes: string | null
createdAt: string
updatedAt: string
}
function parseJsonValue(value: string | null): Prisma.JsonValue | null {
if (value === null || value === undefined) return null
const trimmed = value.trim()
if (!trimmed || trimmed.toLowerCase() === "null") return null
try {
return JSON.parse(trimmed) as Prisma.JsonValue
} catch (error) {
console.warn("[company-service] Invalid JSON detected; coercing to null.", { value, error })
return null
}
}
function mapRawRowToCompany(row: RawCompanyRow): Company {
return {
id: row.id,
tenantId: row.tenantId,
name: row.name,
slug: row.slug,
provisioningCode: row.provisioningCode ?? "",
isAvulso: Boolean(row.isAvulso),
contractedHoursPerMonth: row.contractedHoursPerMonth,
cnpj: row.cnpj,
domain: row.domain,
phone: row.phone,
description: row.description,
address: row.address,
legalName: row.legalName,
tradeName: row.tradeName,
stateRegistration: row.stateRegistration,
stateRegistrationType: row.stateRegistrationType
? (row.stateRegistrationType as CompanyStateRegistrationType)
: null,
primaryCnae: row.primaryCnae,
timezone: row.timezone,
businessHours: parseJsonValue(row.businessHours),
supportEmail: row.supportEmail,
billingEmail: row.billingEmail,
contactPreferences: parseJsonValue(row.contactPreferences),
clientDomains: parseJsonValue(row.clientDomains),
communicationChannels: parseJsonValue(row.communicationChannels),
fiscalAddress: parseJsonValue(row.fiscalAddress),
hasBranches: Boolean(row.hasBranches),
regulatedEnvironments: parseJsonValue(row.regulatedEnvironments),
privacyPolicyAccepted: Boolean(row.privacyPolicyAccepted),
privacyPolicyReference: row.privacyPolicyReference,
privacyPolicyMetadata: parseJsonValue(row.privacyPolicyMetadata),
contacts: parseJsonValue(row.contacts),
locations: parseJsonValue(row.locations),
contracts: parseJsonValue(row.contracts),
sla: parseJsonValue(row.sla),
tags: parseJsonValue(row.tags),
customFields: parseJsonValue(row.customFields),
notes: row.notes,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
}
const COMPANY_BASE_SELECT = Prisma.sql`
SELECT
id,
tenantId,
name,
slug,
provisioningCode,
isAvulso,
contractedHoursPerMonth,
cnpj,
domain,
phone,
description,
address,
legalName,
tradeName,
stateRegistration,
stateRegistrationType,
primaryCnae,
timezone,
CAST(businessHours AS TEXT) AS businessHours,
supportEmail,
billingEmail,
CAST(contactPreferences AS TEXT) AS contactPreferences,
CAST(clientDomains AS TEXT) AS clientDomains,
CAST(communicationChannels AS TEXT) AS communicationChannels,
CAST(fiscalAddress AS TEXT) AS fiscalAddress,
hasBranches,
CAST(regulatedEnvironments AS TEXT) AS regulatedEnvironments,
privacyPolicyAccepted,
privacyPolicyReference,
CAST(privacyPolicyMetadata AS TEXT) AS privacyPolicyMetadata,
CAST(contacts AS TEXT) AS contacts,
CAST(locations AS TEXT) AS locations,
CAST(contracts AS TEXT) AS contracts,
CAST(sla AS TEXT) AS sla,
CAST(tags AS TEXT) AS tags,
CAST(customFields AS TEXT) AS customFields,
notes,
createdAt,
updatedAt
FROM "Company"
`
export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
${COMPANY_BASE_SELECT}
WHERE tenantId = ${tenantId}
ORDER BY name ASC
`)
return rows.map(mapRawRowToCompany)
}
export async function fetchCompanyById(id: string): Promise<Company | null> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
${COMPANY_BASE_SELECT}
WHERE id = ${id}
LIMIT 1
`)
const row = rows[0]
return row ? mapRawRowToCompany(row) : null
}