sistema-de-chamados/src/server/company-service.ts
2025-11-19 11:07:47 -03:00

461 lines
15 KiB
TypeScript

import { Prisma, type Company } from "@prisma/client"
import { ZodError } from "zod"
import { prisma } from "@/lib/prisma"
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
}
type CompanyCreatePayload = Omit<Prisma.CompanyCreateInput, "provisioningCode">
// Local representation of the DB enum to avoid relying on Prisma enum exports
type DbCompanyStateRegistrationType = "STANDARD" | "EXEMPT" | "SIMPLES"
function asDbStateRegistrationType(value: unknown): DbCompanyStateRegistrationType | undefined {
return value === "STANDARD" || value === "EXEMPT" || value === "SIMPLES" ? value : undefined
}
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,
DbCompanyStateRegistrationType
> = {
standard: "STANDARD",
exempt: "EXEMPT",
simples: "SIMPLES",
}
const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
DbCompanyStateRegistrationType,
CompanyStateRegistrationTypeOption
> = {
STANDARD: "standard",
EXEMPT: "exempt",
SIMPLES: "simples",
}
const JSON_NULL_VALUE = Prisma.JsonNull as unknown as Prisma.InputJsonValue
const COMPANY_LEGACY_SELECT = {
id: true,
tenantId: true,
name: true,
slug: true,
isAvulso: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.CompanySelect
type LegacyCompanyRow = Prisma.CompanyGetPayload<{ select: typeof COMPANY_LEGACY_SELECT }>
function isMissingProvisioningCodeColumn(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2022") {
const meta = error.meta as Record<string, unknown> | undefined
const metaColumn =
typeof meta?.column === "string"
? meta.column
: typeof meta?.column_name === "string"
? meta.column_name
: typeof meta?.model === "string"
? meta.model
: null
if (metaColumn && metaColumn.toLowerCase().includes("provisioningcode")) return true
if (error.message.toLowerCase().includes("provisioningcode")) return true
}
return false
}
function isMissingCompanyTable(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2021") return true
const message = error.message.toLowerCase()
if (message.includes("table") && message.includes("company") && message.includes("does not exist")) {
return true
}
}
return false
}
export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> {
try {
return await prisma.company.findMany(args)
} catch (error) {
if (isMissingCompanyTable(error)) {
return []
}
if (!isMissingProvisioningCodeColumn(error)) {
throw error
}
if (args.select || args.include) {
throw error
}
const legacyRows = (await prisma.company.findMany({
...args,
select: COMPANY_LEGACY_SELECT,
})) as LegacyCompanyRow[]
return legacyRows.map(
(row) => ({
id: row.id,
tenantId: row.tenantId,
name: row.name,
slug: row.slug,
provisioningCode: "",
isAvulso: Boolean(row.isAvulso),
contractedHoursPerMonth: null,
cnpj: null,
domain: null,
phone: null,
description: null,
address: null,
legalName: null,
tradeName: null,
stateRegistration: null,
stateRegistrationType: null,
primaryCnae: null,
timezone: null,
businessHours: null,
supportEmail: null,
billingEmail: null,
contactPreferences: null,
clientDomains: null,
communicationChannels: null,
fiscalAddress: null,
hasBranches: false,
regulatedEnvironments: null,
privacyPolicyAccepted: false,
privacyPolicyReference: null,
privacyPolicyMetadata: null,
contacts: null,
locations: null,
contracts: null,
sla: null,
tags: null,
customFields: null,
notes: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}) as Company
)
}
}
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): CompanyCreatePayload {
const stateRegistrationType = payload.stateRegistrationType
? STATE_REGISTRATION_TYPE_TO_PRISMA[payload.stateRegistrationType as CompanyStateRegistrationTypeOption]
: null
const communicationChannels = mergeChannelsWithPrimary(payload)
const privacyPolicyMetadata = payload.privacyPolicy?.metadata ?? null
const data: CompanyCreatePayload = {
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 ?? JSON_NULL_VALUE,
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)
: JSON_NULL_VALUE,
clientDomains: payload.clientDomains,
communicationChannels,
fiscalAddress: payload.fiscalAddress ?? JSON_NULL_VALUE,
hasBranches: payload.hasBranches ?? false,
regulatedEnvironments: payload.regulatedEnvironments,
privacyPolicyAccepted: payload.privacyPolicy?.accepted ?? false,
privacyPolicyReference: payload.privacyPolicy?.reference ?? null,
privacyPolicyMetadata: privacyPolicyMetadata
? (privacyPolicyMetadata as Prisma.InputJsonValue)
: JSON_NULL_VALUE,
contacts: payload.contacts,
locations: payload.locations,
contracts: payload.contracts,
sla: payload.sla ?? JSON_NULL_VALUE,
tags: payload.tags,
customFields: payload.customFields,
notes: payload.notes ?? null,
}
return data
}
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: (() => {
const t = asDbStateRegistrationType(company.stateRegistrationType)
return t ? STATE_REGISTRATION_TYPE_FROM_PRISMA[t] : 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 && company.provisioningCode.trim().length > 0
? company.provisioningCode
: null,
createdAt: company.createdAt.toISOString(),
updatedAt: company.updatedAt.toISOString(),
}
}
export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
return safeCompanyFindMany({
where: { tenantId },
orderBy: { name: "asc" },
})
}
export async function fetchCompanyById(id: string): Promise<Company | null> {
const rows = await safeCompanyFindMany({
where: { id },
take: 1,
})
return rows[0] ?? null
}