feat: expand admin companies and users modules
This commit is contained in:
parent
a043b1203c
commit
2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions
489
src/server/company-service.ts
Normal file
489
src/server/company-service.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue