482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import type { Prisma as PrismaTypes } from "@/generated/prisma/client"
|
|
import { Prisma, asPrismaKnownError } from "@/lib/prisma"
|
|
import type { PrismaDelegateClient } from "@/lib/prisma"
|
|
import { prisma } from "@/lib/prisma"
|
|
import { ZodError } from "zod"
|
|
|
|
import {
|
|
companyFormSchema,
|
|
companyInputSchema,
|
|
type CompanyCommunicationChannels,
|
|
type CompanyFormValues,
|
|
type CompanyStateRegistrationTypeOption,
|
|
} from "@/lib/schemas/company"
|
|
|
|
type DbCompany = Awaited<ReturnType<PrismaDelegateClient["company"]["create"]>>
|
|
type CompanyFindManyArgs = Parameters<PrismaDelegateClient["company"]["findMany"]>[0]
|
|
type CompanyCreatePayload = Parameters<PrismaDelegateClient["company"]["create"]>[0]["data"]
|
|
|
|
export type NormalizedCompany = CompanyFormValues & {
|
|
id: string
|
|
provisioningCode: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
type PrismaJsonInput = PrismaTypes.InputJsonValue | PrismaTypes.NullableJsonNullValueInput
|
|
|
|
const JSON_NULL_VALUE: PrismaTypes.NullableJsonNullValueInput = Prisma.JsonNull
|
|
|
|
function toJsonInput(value: unknown): PrismaJsonInput {
|
|
if (value === null || value === undefined) {
|
|
return JSON_NULL_VALUE
|
|
}
|
|
if (value === Prisma.JsonNull || value === Prisma.DbNull) {
|
|
return value
|
|
}
|
|
return value as PrismaTypes.InputJsonValue
|
|
}
|
|
|
|
function asJsonRecord(value: PrismaTypes.JsonValue | null | undefined): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined
|
|
}
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
const COMPANY_LEGACY_SELECT = {
|
|
id: true,
|
|
tenantId: true,
|
|
name: true,
|
|
slug: true,
|
|
isAvulso: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const
|
|
|
|
function isMissingProvisioningCodeColumn(error: unknown): boolean {
|
|
const knownError = asPrismaKnownError(error)
|
|
if (knownError && knownError.code === "P2022") {
|
|
const meta = knownError.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 (knownError.message?.toLowerCase().includes("provisioningcode")) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isMissingCompanyTable(error: unknown): boolean {
|
|
const knownError = asPrismaKnownError(error)
|
|
if (knownError) {
|
|
if (knownError.code === "P2021") return true
|
|
const message = knownError.message?.toLowerCase() ?? ""
|
|
if (message.includes("table") && message.includes("company") && message.includes("does not exist")) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
export async function safeCompanyFindMany(args: CompanyFindManyArgs = {} as CompanyFindManyArgs): Promise<DbCompany[]> {
|
|
try {
|
|
return await prisma.company.findMany(args)
|
|
} catch (error) {
|
|
if (isMissingCompanyTable(error)) {
|
|
return []
|
|
}
|
|
if (!isMissingProvisioningCodeColumn(error)) {
|
|
throw error
|
|
}
|
|
|
|
const hasProjection = Boolean(args?.select || args?.include)
|
|
if (hasProjection) {
|
|
throw error
|
|
}
|
|
|
|
const legacyRows = await prisma.company.findMany({
|
|
...args,
|
|
select: COMPANY_LEGACY_SELECT,
|
|
})
|
|
|
|
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 DbCompany
|
|
)
|
|
}
|
|
}
|
|
|
|
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 contactPreferencesPayload =
|
|
payload.contactPreferences || payload.supportEmail || payload.billingEmail
|
|
? {
|
|
...(payload.contactPreferences ?? {}),
|
|
supportEmail: payload.supportEmail ?? null,
|
|
billingEmail: payload.billingEmail ?? null,
|
|
}
|
|
: null
|
|
|
|
const data: CompanyCreatePayload = {
|
|
tenantId,
|
|
name: payload.name.trim(),
|
|
slug: payload.slug.trim(),
|
|
provisioningCode: 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: toJsonInput(payload.businessHours),
|
|
supportEmail: payload.supportEmail ?? null,
|
|
billingEmail: payload.billingEmail ?? null,
|
|
contactPreferences: toJsonInput(contactPreferencesPayload),
|
|
clientDomains: toJsonInput(payload.clientDomains),
|
|
communicationChannels: toJsonInput(communicationChannels),
|
|
fiscalAddress: toJsonInput(payload.fiscalAddress),
|
|
hasBranches: payload.hasBranches ?? false,
|
|
regulatedEnvironments: toJsonInput(payload.regulatedEnvironments),
|
|
privacyPolicyAccepted: payload.privacyPolicy?.accepted ?? false,
|
|
privacyPolicyReference: payload.privacyPolicy?.reference ?? null,
|
|
privacyPolicyMetadata: toJsonInput(privacyPolicyMetadata),
|
|
contacts: toJsonInput(payload.contacts),
|
|
locations: toJsonInput(payload.locations),
|
|
contracts: toJsonInput(payload.contracts),
|
|
sla: toJsonInput(payload.sla),
|
|
tags: toJsonInput(payload.tags),
|
|
customFields: toJsonInput(payload.customFields),
|
|
notes: payload.notes ?? null,
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
export function normalizeCompany(company: DbCompany): 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: asJsonRecord(company.privacyPolicyMetadata),
|
|
},
|
|
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<DbCompany[]> {
|
|
return safeCompanyFindMany({
|
|
where: { tenantId },
|
|
orderBy: { name: "asc" },
|
|
})
|
|
}
|
|
|
|
export async function fetchCompanyById(id: string): Promise<DbCompany | null> {
|
|
const rows = await safeCompanyFindMany({
|
|
where: { id },
|
|
take: 1,
|
|
})
|
|
return rows[0] ?? null
|
|
}
|