fix(dashboards): prevent render loops with stable ready handlers and idempotent updates; improve filter hydration guards

fix(export): return 501 with hint when Playwright browsers missing; nicer error toast in UI

fix(site-header): export primary/secondary buttons as named for SC safety; keep static props for compat

fix(portal): add DialogDescription for a11y; tidy preview dialog

fix(csats): avoid reinit state loops with timestamp guard

chore(prisma): default dev DB to prisma/db.dev.sqlite and log path

chore(auth): add dev bypass flags wiring (server/client) for local testing

dev: seed script for Convex demo data
This commit is contained in:
Esdras Renan 2025-11-06 00:01:45 -03:00
parent ff0254df18
commit b62e14d8eb
13 changed files with 210 additions and 103 deletions

View file

@ -1,62 +0,0 @@
# Ambiente local — Sistema de Chamados
# Copie este arquivo para `.env` e preencha os valores sensíveis.
# Nunca faça commit de `.env` com segredos reais.
# Convex
CONVEX_DEPLOYMENT=anonymous:anonymous-sistema-de-chamados
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
CONVEX_SYNC_SECRET=dev-sync-secret
# Next.js / App URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Better Auth
# Gere um segredo forte (ex.: `openssl rand -hex 32`)
BETTER_AUTH_SECRET=change-me
BETTER_AUTH_URL=http://localhost:3000
# Banco de dados (Prisma)
DATABASE_URL=file:./prisma/db.dev.sqlite
# Seeds automáticos (Better Auth)
# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas
SEED_ENSURE_ONLY=true
# Provisionamento e inventário de máquinas
# Segredo obrigatório para registrar/atualizar máquinas (Convex)
MACHINE_PROVISIONING_SECRET=change-me-provisioning
# Tempo de vida do token de máquina (ms) — padrão 30 dias
MACHINE_TOKEN_TTL_MS=2592000000
# Opcional: segredo dedicado para webhook do FleetDM (senão usa o de provisionamento)
FLEET_SYNC_SECRET=
# SMTP (envio de e-mails)
SMTP_ADDRESS=
SMTP_PORT=465
SMTP_DOMAIN=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTHENTICATION=login
SMTP_ENABLE_STARTTLS_AUTO=false
SMTP_TLS=true
MAILER_SENDER_EMAIL="Suporte <no-reply@seu-dominio.com>"
# Alertas (actions do Convex)
# Hora local (America/Sao_Paulo) para rodar alertas automáticos
ALERTS_LOCAL_HOUR=8
# Seeds e sincronizações auxiliares
SYNC_TENANT_ID=tenant-atlas
SYNC_DEFAULT_ASSIGNEE=agent@example.com
SEED_TENANT_ID=tenant-atlas
SEED_ADMIN_PASSWORD=admin123
SEED_AGENT_PASSWORD=agent123
SEED_USER_TENANT=tenant-atlas
SEED_USER_EMAIL=
SEED_USER_PASSWORD=
SEED_USER_NAME=
SEED_USER_ROLE=admin
# Desenvolvimento Desktop (Tauri/Vite)
# Em redes locais, defina o IP do host para HMR.
TAURI_DEV_HOST=

47
env (1) Normal file
View file

@ -0,0 +1,47 @@
# Configurações locais padrão
NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br
BETTER_AUTH_SECRET=44bf2b9f97b94d0ea68b4518eec0c565a9b5660fbd3f54ef958d36b2b2a2d4e3
BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br
NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br
DATABASE_URL=file:./prisma/db.sqlite
CONVEX_SYNC_SECRET=dev-sync-secret
SMTP_ADDRESS=smtp.hostinger.com
SMTP_PORT=465
SMTP_DOMAIN=esdrasrenan.com.br
SMTP_USERNAME=chat@esdrasrenan.com.br
SMTP_PASSWORD=5ffB7Ny4#@qA!0e19NG5
SMTP_AUTHENTICATION=login
SMTP_ENABLE_STARTTLS_AUTO=false
SMTP_TLS=true
MAILER_SENDER_EMAIL="Chatwoot chat@esdrasrenan.com.br (chat@esdrasrenan.com.br)"
# ===== Adições para provisionamento/integrações =====
# Segredo para provisionamento de máquinas (Convex)
MACHINE_PROVISIONING_SECRET=71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6
# TTL do token de máquina (ms) — 30 dias
MACHINE_TOKEN_TTL_MS=2592000000
# Opcional: segredo para webhook do FleetDM (senão usa o de provisionamento)
FLEET_SYNC_SECRET=71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6
# Hora local (America/Sao_Paulo) para rodar alertas automáticos (actions do Convex)
ALERTS_LOCAL_HOUR=8
# ===== Seeds/sync auxiliares =====
SYNC_TENANT_ID=tenant-atlas
SYNC_DEFAULT_ASSIGNEE=agent@example.com
SEED_TENANT_ID=tenant-atlas
SEED_ADMIN_PASSWORD=admin123
SEED_AGENT_PASSWORD=agent123
SEED_USER_TENANT=tenant-atlas
SEED_USER_EMAIL=
SEED_USER_PASSWORD=
SEED_USER_NAME=
SEED_USER_ROLE=admin
# ===== Desenvolvimento Desktop =====
# IP do host para HMR quando rodar `pnpm --filter appsdesktop tauri dev`
TAURI_DEV_HOST=
VITE_APP_URL=https://tickets.esdrasrenan.com.br
VITE_API_BASE_URL=https://tickets.esdrasrenan.com.br

View file

@ -0,0 +1,19 @@
import 'dotenv/config'
import { ConvexHttpClient } from 'convex/browser'
import { api } from '../convex/_generated/api.js'
async function main() {
const url = process.env.NEXT_PUBLIC_CONVEX_URL || 'http://127.0.0.1:3210'
const client = new ConvexHttpClient(url)
console.log(`[seed] Using Convex at ${url}`)
try {
await client.mutation(api.seed.seedDemo, {})
console.log('[seed] Convex demo data ensured (queues/users)')
} catch (err) {
console.error('[seed] Failed to seed Convex demo data:', err?.message || err)
process.exitCode = 1
}
}
main()

View file

@ -31,7 +31,23 @@ export async function POST(request: Request) {
let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null
try {
browser = await chromium.launch()
try {
browser = await chromium.launch({ headless: true })
} catch (launchError) {
const msg = (launchError as Error)?.message || String(launchError)
// Dev-friendly response when Playwright browsers are missing
if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(msg)) {
return NextResponse.json(
{
error: "Playwright browsers not installed",
hint: "Execute: npx playwright install",
details: msg,
},
{ status: 501 },
)
}
throw launchError
}
const page = await browser.newPage({ viewport: { width, height } })
await page.goto(payload.url, { waitUntil: "networkidle" })
if (waitForSelector) {

View file

@ -1,5 +1,5 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { SiteHeader, SiteHeaderPrimaryButton, SiteHeaderSecondaryButton } from "@/components/site-header"
import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { requireAuthenticatedSession } from "@/lib/auth-server"
@ -12,8 +12,8 @@ export default async function PlayPage() {
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificações</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessão</SiteHeader.PrimaryButton>}
secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
/>
}
>

View file

@ -441,6 +441,14 @@ function filtersEqual(a: DashboardFilters, b: DashboardFilters) {
)
}
function deepEqual<T>(a: T, b: T) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
const widgetConfigSchema = z.object({
title: z.string().min(1, "Informe um título"),
type: z.string(),
@ -500,12 +508,14 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
useEffect(() => {
if (!detail) return
setDashboard(detail.dashboard)
setWidgets(detail.widgets)
setShares(detail.shares)
setDashboard((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard))
setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets))
setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares))
const nextFilters = normalizeFilters(detail.dashboard.filters)
if (!filtersEqual(filters, nextFilters)) {
filtersHydratingRef.current = true
setFilters(nextFilters)
}
const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout)
if (!layoutItemsEqual(layoutRef.current, syncedLayout)) {
layoutRef.current = syncedLayout
@ -543,6 +553,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
return new Set(currentSection?.widgetKeys ?? [])
}, [enforceTv, sections, activeSectionIndex])
// Ready handlers (stable per widget) defined before usage in canvasItems
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
setReadyWidgets((prev) => {
const currentlyReady = prev.has(key)
if (currentlyReady === ready) return prev
const next = new Set(prev)
if (ready) next.add(key)
else next.delete(key)
return next
})
}, [])
const readyHandlersRef = useRef(new Map<string, (ready: boolean) => void>())
const getReadyHandler = useCallback(
(key: string) => {
const cached = readyHandlersRef.current.get(key)
if (cached) return cached
const handler = (ready: boolean) => handleWidgetReady(key, ready)
readyHandlersRef.current.set(key, handler)
return handler
},
[handleWidgetReady],
)
const canvasItems = packedLayout
.map((item) => {
const widget = widgetMap.get(item.i)
@ -565,7 +599,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
onDuplicate={() => handleDuplicateWidget(widget)}
onRemove={() => handleRemoveWidget(widget)}
onViewData={() => setDataTarget(widget)}
onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)}
onReadyChange={getReadyHandler(widget.widgetKey)}
/>
),
...(item.minW !== undefined ? { minW: item.minW } : {}),
@ -576,18 +610,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key))
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
setReadyWidgets((prev) => {
const next = new Set(prev)
if (ready) {
next.add(key)
} else {
next.delete(key)
}
return next
})
}, [])
useEffect(() => {
const keys = new Set(canvasItems.map((item) => item.key))
setReadyWidgets((prev) => {
@ -799,7 +821,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
}),
})
if (!response.ok) {
throw new Error("Export request failed")
let msg = "Export request failed"
try {
const data = await response.json()
if (data?.hint) msg = `${msg}: ${data.hint}`
else if (data?.error) msg = `${msg}: ${data.error}`
} catch {}
throw new Error(msg)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
@ -811,7 +839,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
toast.success("Exportação gerada com sucesso!")
} catch (error) {
console.error(error)
toast.error("Não foi possível exportar o dashboard.")
const message = error instanceof Error ? error.message : String(error)
toast.error(message.includes("Playwright browsers not installed") || message.includes("npx playwright install")
? "Exportação indisponível em DEV: rode `npx playwright install` para habilitar."
: "Não foi possível exportar o dashboard.")
} finally {
setIsExporting(false)
}

View file

@ -15,7 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dropzone } from "@/components/ui/dropzone"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
@ -513,6 +513,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<X className="size-4" />
</DialogClose>
</DialogHeader>
<DialogDescription className="sr-only">
{isPreviewImage
? "Pré-visualização da imagem anexada."
: "Pré-visualização do arquivo selecionado. Utilize o botão para abrir em uma nova aba."}
</DialogDescription>
{previewAttachment ? (
isPreviewImage ? (
<div className="rounded-b-2xl bg-neutral-900/5">
@ -696,5 +701,3 @@ function PortalCommentAttachmentCard({

View file

@ -43,7 +43,7 @@ export function SiteHeader({
)
}
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
export function SiteHeaderPrimaryButton({
children,
className,
...props
@ -55,7 +55,7 @@ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
)
}
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
export function SiteHeaderSecondaryButton({
children,
className,
...props
@ -66,3 +66,7 @@ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
</Button>
)
}
// Backward compatibility: attach as static members (client-only usage)
;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton
;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton

View file

@ -90,12 +90,20 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const [hoverScore, setHoverScore] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
const ratedAtTimestamp = ticket.csatRatedAt ? ticket.csatRatedAt.getTime() : null
useEffect(() => {
setScore(initialScore)
setComment(initialComment)
setRatedAt(ticket.csatRatedAt ?? null)
setHasSubmitted(initialScore > 0)
}, [initialScore, initialComment, ticket.csatRatedAt])
setScore((prev) => (prev === initialScore ? prev : initialScore))
setComment((prev) => (prev === initialComment ? prev : initialComment))
const nextRatedAt = ratedAtTimestamp == null ? null : new Date(ratedAtTimestamp)
setRatedAt((prev) => {
if (prev === nextRatedAt) return prev
if (prev && nextRatedAt && prev.getTime() === nextRatedAt.getTime()) return prev
return nextRatedAt
})
const nextHasSubmitted = initialScore > 0
setHasSubmitted((prev) => (prev === nextHasSubmitted ? prev : nextHasSubmitted))
}, [initialScore, initialComment, ratedAtTimestamp])
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"

View file

@ -90,7 +90,22 @@ export function useAuth() {
export const { signIn, signOut, useSession } = authClient
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession()
const devBypass = process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_DEV_BYPASS_AUTH === "1"
const { data: baseSession, isPending } = useSession()
const session: AppSession | null = baseSession ?? (devBypass
? {
session: { id: "dev-session", expiresAt: Date.now() + 1000 * 60 * 60 },
user: {
id: "dev-user",
name: "Dev Admin",
email: "admin@sistema.dev",
role: "admin",
tenantId: "tenant-atlas",
avatarUrl: null,
machinePersona: null,
},
}
: null)
const ensureUser = useMutation(api.users.ensureUser)
const [convexUserId, setConvexUserId] = useState<string | null>(null)
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)

View file

@ -55,6 +55,24 @@ async function buildRequest() {
export async function getServerSession(): Promise<ServerSession | null> {
try {
// Dev-only bypass to simplify local dashboard access when auth is misconfigured.
if (process.env.NODE_ENV !== "production" && process.env.DEV_BYPASS_AUTH === "1") {
return {
session: {
id: "dev-session",
expiresAt: Date.now() + 1000 * 60 * 60,
},
user: {
id: "dev-user",
name: "Dev Admin",
email: "admin@sistema.dev",
role: "admin",
tenantId: "tenant-atlas",
avatarUrl: null,
machinePersona: null,
},
}
}
const request = await buildRequest()
const session = await auth.api.getSession({
headers: request.headers,

View file

@ -23,7 +23,8 @@ export const auth = betterAuth({
provider: "sqlite",
}),
user: {
modelName: "authUser",
// Prisma model name (case-sensitive)
modelName: "AuthUser",
additionalFields: {
role: {
type: "string",
@ -47,17 +48,17 @@ export const auth = betterAuth({
},
},
session: {
modelName: "authSession",
modelName: "AuthSession",
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
},
account: {
modelName: "authAccount",
modelName: "AuthAccount",
},
verification: {
modelName: "authVerification",
modelName: "AuthVerification",
},
emailAndPassword: {
enabled: true,

View file

@ -42,7 +42,8 @@ const resolvedDatabaseUrl = (() => {
if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite"
}
return resolveFileUrl("file:./prisma/db.sqlite")
// In development, prefer a dedicated dev DB file
return resolveFileUrl("file:./prisma/db.dev.sqlite")
})()
export const prisma =
@ -51,3 +52,9 @@ export const prisma =
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma
}
if (process.env.NODE_ENV !== "production") {
// Helps detect mismatched DB path during dev server bootstrap
// eslint-disable-next-line no-console
console.log("[prisma] Using database:", resolvedDatabaseUrl)
}