feat: ajustar board de tickets

This commit is contained in:
Esdras Renan 2025-10-27 14:50:17 -03:00
parent e9a8bd6b9b
commit d23987eda8
7 changed files with 434 additions and 429 deletions

View file

@ -65,10 +65,10 @@ model Team {
} }
model TeamMember { model TeamMember {
teamId String teamId String
userId String userId String
isLead Boolean @default(false) isLead Boolean @default(false)
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -77,45 +77,45 @@ model TeamMember {
} }
model Company { model Company {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
slug String slug String
provisioningCode String @unique provisioningCode String @unique
isAvulso Boolean @default(false) isAvulso Boolean @default(false)
contractedHoursPerMonth Float? contractedHoursPerMonth Float?
cnpj String? cnpj String?
domain String? domain String?
phone String? phone String?
description String? description String?
address String? address String?
legalName String? legalName String?
tradeName String? tradeName String?
stateRegistration String? stateRegistration String?
stateRegistrationType CompanyStateRegistrationType? stateRegistrationType CompanyStateRegistrationType?
primaryCnae String? primaryCnae String?
timezone String? timezone String?
businessHours Json? businessHours Json?
supportEmail String? supportEmail String?
billingEmail String? billingEmail String?
contactPreferences Json? contactPreferences Json?
clientDomains Json? clientDomains Json?
communicationChannels Json? communicationChannels Json?
fiscalAddress Json? fiscalAddress Json?
hasBranches Boolean @default(false) hasBranches Boolean @default(false)
regulatedEnvironments Json? regulatedEnvironments Json?
privacyPolicyAccepted Boolean @default(false) privacyPolicyAccepted Boolean @default(false)
privacyPolicyReference String? privacyPolicyReference String?
privacyPolicyMetadata Json? privacyPolicyMetadata Json?
contacts Json? contacts Json?
locations Json? locations Json?
contracts Json? contracts Json?
sla Json? sla Json?
tags Json? tags Json?
customFields Json? customFields Json?
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
tickets Ticket[] tickets Ticket[]
@ -125,20 +125,20 @@ model Company {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
email String @unique email String @unique
role UserRole role UserRole
timezone String @default("America/Sao_Paulo") timezone String @default("America/Sao_Paulo")
avatarUrl String? avatarUrl String?
companyId String? companyId String?
teams TeamMember[] teams TeamMember[]
requestedTickets Ticket[] @relation("TicketRequester") requestedTickets Ticket[] @relation("TicketRequester")
assignedTickets Ticket[] @relation("TicketAssignee") assignedTickets Ticket[] @relation("TicketAssignee")
comments TicketComment[] comments TicketComment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id]) company Company? @relation(fields: [companyId], references: [id])
@ -162,31 +162,31 @@ model Queue {
} }
model Ticket { model Ticket {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
reference Int @default(0) reference Int @default(0)
subject String subject String
summary String? summary String?
status TicketStatus @default(PENDING) status TicketStatus @default(PENDING)
priority TicketPriority @default(MEDIUM) priority TicketPriority @default(MEDIUM)
channel TicketChannel @default(EMAIL) channel TicketChannel @default(EMAIL)
queueId String? queueId String?
requesterId String requesterId String
assigneeId String? assigneeId String?
slaPolicyId String? slaPolicyId String?
companyId String? companyId String?
dueAt DateTime? dueAt DateTime?
firstResponseAt DateTime? firstResponseAt DateTime?
resolvedAt DateTime? resolvedAt DateTime?
closedAt DateTime? closedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
requester User @relation("TicketRequester", fields: [requesterId], references: [id]) requester User @relation("TicketRequester", fields: [requesterId], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id]) assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id]) queue Queue? @relation(fields: [queueId], references: [id])
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id]) slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
company Company? @relation(fields: [companyId], references: [id]) company Company? @relation(fields: [companyId], references: [id])
events TicketEvent[] events TicketEvent[]
comments TicketComment[] comments TicketComment[]
@ -209,14 +209,14 @@ model TicketEvent {
} }
model TicketComment { model TicketComment {
id String @id @default(cuid()) id String @id @default(cuid())
ticketId String ticketId String
authorId String authorId String
visibility CommentVisibility @default(INTERNAL) visibility CommentVisibility @default(INTERNAL)
body String body String
attachments Json? attachments Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
@ -225,15 +225,15 @@ model TicketComment {
} }
model SlaPolicy { model SlaPolicy {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
description String? description String?
timeToFirstResponse Int? timeToFirstResponse Int?
timeToResolution Int? timeToResolution Int?
calendar Json? calendar Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tickets Ticket[] tickets Ticket[]
@ -257,14 +257,14 @@ model AuthUser {
} }
model AuthSession { model AuthSession {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
token String @unique token String @unique
expiresAt DateTime expiresAt DateTime
ipAddress String? ipAddress String?
userAgent String? userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -273,7 +273,7 @@ model AuthSession {
} }
model AuthAccount { model AuthAccount {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
accountId String accountId String
providerId String providerId String
@ -284,8 +284,8 @@ model AuthAccount {
scope String? scope String?
idToken String? idToken String?
password String? password String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade) user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -294,24 +294,24 @@ model AuthAccount {
} }
model AuthInvite { model AuthInvite {
id String @id @default(cuid()) id String @id @default(cuid())
email String email String
name String? name String?
role String @default("agent") role String @default("agent")
tenantId String tenantId String
token String @unique token String @unique
status String @default("pending") status String @default("pending")
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById String? createdById String?
acceptedAt DateTime? acceptedAt DateTime?
acceptedById String? acceptedById String?
revokedAt DateTime? revokedAt DateTime?
revokedById String? revokedById String?
revokedReason String? revokedReason String?
events AuthInviteEvent[] events AuthInviteEvent[]
@@index([tenantId, status]) @@index([tenantId, status])
@@index([tenantId, email]) @@index([tenantId, email])

View file

@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { requireStaffSession } from "@/lib/auth-server" import { requireStaffSession } from "@/lib/auth-server"
import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace" import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace"
import { normalizeCompany } from "@/server/company-service" import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@ -79,10 +79,7 @@ export default async function AdminUsersPage() {
} }
}) })
const companiesRaw = await prisma.company.findMany({ const companiesRaw = await fetchCompaniesByTenant(tenantId)
where: { tenantId },
orderBy: { name: "asc" },
})
const companies = companiesRaw.map(normalizeCompany) const companies = companiesRaw.map(normalizeCompany)
return ( return (

View file

@ -7,6 +7,7 @@ import { prisma } from "@/lib/prisma"
import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { ConvexConfigurationError } from "@/server/convex-client" import { ConvexConfigurationError } from "@/server/convex-client"
import { syncConvexCompany } from "@/server/companies-sync" import { syncConvexCompany } from "@/server/companies-sync"
import { safeCompanyFindMany } from "@/server/company-service"
export const runtime = "nodejs" export const runtime = "nodejs"
@ -75,7 +76,7 @@ export async function GET(request: Request) {
...(orFilters.length > 0 ? { OR: orFilters } : {}), ...(orFilters.length > 0 ? { OR: orFilters } : {}),
} }
const companies = await prisma.company.findMany({ const companies = await safeCompanyFindMany({
where, where,
orderBy: { name: "asc" }, orderBy: { name: "asc" },
take: 20, take: 20,

View file

@ -36,8 +36,8 @@ export function TicketsPageClient() {
<SiteHeader <SiteHeader
title="Tickets" title="Tickets"
lead="Visão consolidada de filas e SLAs" lead="Visão consolidada de filas e SLAs"
primaryAction={<NewTicketDialog />} primaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>} secondaryAction={<NewTicketDialog />}
/> />
} }
> >

View file

@ -40,10 +40,12 @@ export function PrioritySelect({
ticketId, ticketId,
value, value,
className, className,
badgeClassName,
}: { }: {
ticketId: string ticketId: string
value: TicketPriority value: TicketPriority
className?: string className?: string
badgeClassName?: string
}) { }) {
const updatePriority = useMutation(api.tickets.updatePriority) const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState<TicketPriority>(value) const [priority, setPriority] = useState<TicketPriority>(value)
@ -69,7 +71,9 @@ export function PrioritySelect({
> >
<SelectTrigger className={cn(headerTriggerClass, className)} aria-label="Atualizar prioridade"> <SelectTrigger className={cn(headerTriggerClass, className)} aria-label="Atualizar prioridade">
<SelectValue asChild> <SelectValue asChild>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}> <Badge
className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass, badgeClassName)}
>
<PriorityIcon value={priority} /> <PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority} {priorityStyles[priority]?.label ?? priority}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" /> <ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />

View file

@ -10,7 +10,7 @@ import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { PrioritySelect } from "@/components/tickets/priority-select"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style" import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
type TicketsBoardProps = { type TicketsBoardProps = {
@ -57,6 +57,17 @@ function formatUpdated(date: Date | number | string, now: number) {
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR }) return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
} }
function formatQueueLabel(queue?: string | null) {
if (!queue) {
return { label: "Sem fila", title: "Sem fila" }
}
const normalized = queue.trim().toLowerCase()
if (normalized.startsWith("laboratorio")) {
return { label: "Lab", title: queue }
}
return { label: queue, title: queue }
}
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) { export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
@ -103,73 +114,118 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
} }
return ( return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{tickets.map((ticket) => { {tickets.map((ticket) => {
const isEntering = enteringIds?.has(ticket.id) const isEntering = enteringIds?.has(ticket.id)
const queueDisplay = formatQueueLabel(ticket.queue)
const updatedTimestamp = getTimestamp(ticket.updatedAt)
const updatedAbsoluteLabel = updatedTimestamp
? new Date(updatedTimestamp).toLocaleString("pt-BR", {
dateStyle: "short",
timeStyle: "short",
})
: "—"
return ( return (
<Link <Link
key={ticket.id} key={ticket.id}
href={`/tickets/${ticket.id}`} href={`/tickets/${ticket.id}`}
className={cn( className={cn(
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300", "group flex h-full flex-col rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
isEntering ? "recent-ticket-enter" : "" isEntering ? "recent-ticket-enter" : "",
)} )}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex w-full items-center justify-between gap-4">
<Badge <Badge
variant="outline" variant="outline"
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700" className="rounded-full border-slate-200 bg-white px-4 py-2 text-sm font-semibold tracking-tight text-neutral-700"
> >
#{ticket.reference} #{ticket.reference}
</Badge> </Badge>
<span className="text-right text-sm font-semibold text-neutral-900" title={queueDisplay.title}>
{queueDisplay.label}
</span>
</div>
<div className="flex w-full items-center justify-between gap-4">
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition", "inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition",
getTicketStatusChipClass(ticket.status), getTicketStatusChipClass(ticket.status),
)} )}
> >
{getTicketStatusLabel(ticket.status)} {getTicketStatusLabel(ticket.status)}
</span> </span>
<div
className="relative z-[1] flex justify-end"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<PrioritySelect
className="min-w-[7rem]"
badgeClassName="h-9 px-4 shadow-sm"
ticketId={ticket.id}
value={ticket.priority}
/>
</div>
</div> </div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt, now)}
</span>
</div> </div>
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"} <div className="mt-4 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center shadow-sm">
</h3> <h3 className="line-clamp-2 text-lg font-semibold text-neutral-900">
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600"> {ticket.subject || "Sem assunto"}
<span className="font-medium text-neutral-500">Fila:</span> </h3>
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
{ticket.queue ?? "Sem fila"}
</span>
<span className="font-medium text-neutral-500">Prioridade:</span>
<TicketPriorityPill
priority={ticket.priority}
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
/>
</div> </div>
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
<div className="flex items-start justify-between gap-4"> <div className="mt-4 flex flex-1 flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<dt className="font-medium text-neutral-500">Empresa</dt> <dl className="grid grid-cols-1 gap-4 text-sm text-neutral-600 sm:grid-cols-2">
<dd className="truncate text-right text-neutral-700"> <div className="flex flex-col gap-1">
{ticket.company?.name ?? "Sem empresa"} <dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
</dd> Empresa
</dt>
<dd className="text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Responsável
</dt>
<dd className="text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Solicitante
</dt>
<dd className="text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Criado em
</dt>
<dd className="text-neutral-700">
{(() => {
const createdTimestamp = getTimestamp(ticket.createdAt)
return createdTimestamp
? new Date(createdTimestamp).toLocaleDateString("pt-BR")
: "—"
})()}
</dd>
</div>
</dl>
<div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center">
<span className="text-neutral-700">
Categoria:{" "}
<span className="font-semibold text-neutral-900">{ticket.category?.name ?? "Sem categoria"}</span>
</span>
</div> </div>
<div className="flex items-start justify-between gap-4"> </div>
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
</Link> </Link>
) )
})} })}

View file

@ -72,6 +72,99 @@ const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
SIMPLES: "simples", SIMPLES: "simples",
} }
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
}
export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> {
try {
return await prisma.company.findMany(args)
} catch (error) {
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: Prisma.JsonNull,
supportEmail: null,
billingEmail: null,
contactPreferences: Prisma.JsonNull,
clientDomains: Prisma.JsonNull,
communicationChannels: Prisma.JsonNull,
fiscalAddress: Prisma.JsonNull,
hasBranches: false,
regulatedEnvironments: Prisma.JsonNull,
privacyPolicyAccepted: false,
privacyPolicyReference: null,
privacyPolicyMetadata: Prisma.JsonNull,
contacts: Prisma.JsonNull,
locations: Prisma.JsonNull,
contracts: Prisma.JsonNull,
sla: Prisma.JsonNull,
tags: Prisma.JsonNull,
customFields: Prisma.JsonNull,
notes: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}) as Company
)
}
}
export function formatZodError(error: ZodError) { export function formatZodError(error: ZodError) {
return error.issues.map((issue) => ({ return error.issues.map((issue) => ({
path: issue.path.join("."), path: issue.path.join("."),
@ -328,171 +421,25 @@ export function normalizeCompany(company: Company): NormalizedCompany {
return { return {
...payload, ...payload,
id: company.id, id: company.id,
provisioningCode: company.provisioningCode ?? null, provisioningCode: company.provisioningCode && company.provisioningCode.trim().length > 0
? company.provisioningCode
: null,
createdAt: company.createdAt.toISOString(), createdAt: company.createdAt.toISOString(),
updatedAt: company.updatedAt.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 DbCompanyStateRegistrationType)
: 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[]> { export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql` return safeCompanyFindMany({
${COMPANY_BASE_SELECT} where: { tenantId },
WHERE tenantId = ${tenantId} orderBy: { name: "asc" },
ORDER BY name ASC })
`)
return rows.map(mapRawRowToCompany)
} }
export async function fetchCompanyById(id: string): Promise<Company | null> { export async function fetchCompanyById(id: string): Promise<Company | null> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql` const rows = await safeCompanyFindMany({
${COMPANY_BASE_SELECT} where: { id },
WHERE id = ${id} take: 1,
LIMIT 1 })
`) return rows[0] ?? null
const row = rows[0]
return row ? mapRawRowToCompany(row) : null
} }