merge: bring fixes for dashboards/export/site-header/auth/prisma and env hygiene into main

This commit is contained in:
Esdras Renan 2025-11-06 00:03:35 -03:00
commit 1900f65e5e
13 changed files with 186 additions and 94 deletions

View file

@ -1,62 +1,28 @@
# Ambiente local — Sistema de Chamados NODE_ENV=development
# Copie este arquivo para `.env` e preencha os valores sensíveis.
# Nunca faça commit de `.env` com segredos reais.
# Convex # Public app URL
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 NEXT_PUBLIC_APP_URL=http://localhost:3000
# Better Auth # Better Auth
# Gere um segredo forte (ex.: `openssl rand -hex 32`)
BETTER_AUTH_SECRET=change-me
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=change-me-in-prod
# Banco de dados (Prisma) # Convex (dev server URL)
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
# SQLite database (local dev)
DATABASE_URL=file:./prisma/db.dev.sqlite DATABASE_URL=file:./prisma/db.dev.sqlite
# Seeds automáticos (Better Auth) # Optional SMTP (dev)
# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas # SMTP_ADDRESS=localhost
SEED_ENSURE_ONLY=true # SMTP_PORT=1025
# SMTP_TLS=false
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_AUTHENTICATION=login
# SMTP_ENABLE_STARTTLS_AUTO=false
# MAILER_SENDER_EMAIL=no-reply@example.com
# Provisionamento e inventário de máquinas # Dev-only bypass to simplify local testing (do NOT enable in prod)
# Segredo obrigatório para registrar/atualizar máquinas (Convex) # DEV_BYPASS_AUTH=0
MACHINE_PROVISIONING_SECRET=change-me-provisioning # NEXT_PUBLIC_DEV_BYPASS_AUTH=0
# 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=

4
.gitignore vendored
View file

@ -36,6 +36,10 @@ yarn-error.log*
!.env.example !.env.example
!apps/desktop/.env.example !apps/desktop/.env.example
# Accidental Windows duplicate downloads (e.g., "env (1)")
env (*)
env (1)
# vercel # vercel
.vercel .vercel

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 let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null
try { 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 } }) const page = await browser.newPage({ viewport: { width, height } })
await page.goto(payload.url, { waitUntil: "networkidle" }) await page.goto(payload.url, { waitUntil: "networkidle" })
if (waitForSelector) { if (waitForSelector) {

View file

@ -1,5 +1,5 @@
import { AppShell } from "@/components/app-shell" 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 { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { requireAuthenticatedSession } from "@/lib/auth-server" import { requireAuthenticatedSession } from "@/lib/auth-server"
@ -12,8 +12,8 @@ export default async function PlayPage() {
<SiteHeader <SiteHeader
title="Modo play" title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade" lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificações</SiteHeader.SecondaryButton>} secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessão</SiteHeader.PrimaryButton>} 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({ const widgetConfigSchema = z.object({
title: z.string().min(1, "Informe um título"), title: z.string().min(1, "Informe um título"),
type: z.string(), type: z.string(),
@ -500,12 +508,14 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
useEffect(() => { useEffect(() => {
if (!detail) return if (!detail) return
setDashboard(detail.dashboard) setDashboard((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard))
setWidgets(detail.widgets) setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets))
setShares(detail.shares) setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares))
const nextFilters = normalizeFilters(detail.dashboard.filters) const nextFilters = normalizeFilters(detail.dashboard.filters)
filtersHydratingRef.current = true if (!filtersEqual(filters, nextFilters)) {
setFilters(nextFilters) filtersHydratingRef.current = true
setFilters(nextFilters)
}
const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout) const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout)
if (!layoutItemsEqual(layoutRef.current, syncedLayout)) { if (!layoutItemsEqual(layoutRef.current, syncedLayout)) {
layoutRef.current = syncedLayout layoutRef.current = syncedLayout
@ -543,6 +553,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
return new Set(currentSection?.widgetKeys ?? []) return new Set(currentSection?.widgetKeys ?? [])
}, [enforceTv, sections, activeSectionIndex]) }, [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 const canvasItems = packedLayout
.map((item) => { .map((item) => {
const widget = widgetMap.get(item.i) const widget = widgetMap.get(item.i)
@ -565,7 +599,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
onDuplicate={() => handleDuplicateWidget(widget)} onDuplicate={() => handleDuplicateWidget(widget)}
onRemove={() => handleRemoveWidget(widget)} onRemove={() => handleRemoveWidget(widget)}
onViewData={() => setDataTarget(widget)} onViewData={() => setDataTarget(widget)}
onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)} onReadyChange={getReadyHandler(widget.widgetKey)}
/> />
), ),
...(item.minW !== undefined ? { minW: item.minW } : {}), ...(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 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(() => { useEffect(() => {
const keys = new Set(canvasItems.map((item) => item.key)) const keys = new Set(canvasItems.map((item) => item.key))
setReadyWidgets((prev) => { setReadyWidgets((prev) => {
@ -799,7 +821,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
}), }),
}) })
if (!response.ok) { 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 blob = await response.blob()
const url = URL.createObjectURL(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!") toast.success("Exportação gerada com sucesso!")
} catch (error) { } catch (error) {
console.error(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 { } finally {
setIsExporting(false) setIsExporting(false)
} }

View file

@ -15,7 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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 { Dropzone } from "@/components/ui/dropzone"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@ -513,6 +513,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<X className="size-4" /> <X className="size-4" />
</DialogClose> </DialogClose>
</DialogHeader> </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 ? ( {previewAttachment ? (
isPreviewImage ? ( isPreviewImage ? (
<div className="rounded-b-2xl bg-neutral-900/5"> <div className="rounded-b-2xl bg-neutral-900/5">
@ -696,5 +701,3 @@ function PortalCommentAttachmentCard({

View file

@ -42,8 +42,8 @@ export function SiteHeader({
</header> </header>
) )
} }
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({ export function SiteHeaderPrimaryButton({
children, children,
className, className,
...props ...props
@ -55,7 +55,7 @@ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
) )
} }
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({ export function SiteHeaderSecondaryButton({
children, children,
className, className,
...props ...props
@ -66,3 +66,7 @@ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
</Button> </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 [hoverScore, setHoverScore] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const ratedAtTimestamp = ticket.csatRatedAt ? ticket.csatRatedAt.getTime() : null
useEffect(() => { useEffect(() => {
setScore(initialScore) setScore((prev) => (prev === initialScore ? prev : initialScore))
setComment(initialComment) setComment((prev) => (prev === initialComment ? prev : initialComment))
setRatedAt(ticket.csatRatedAt ?? null) const nextRatedAt = ratedAtTimestamp == null ? null : new Date(ratedAtTimestamp)
setHasSubmitted(initialScore > 0) setRatedAt((prev) => {
}, [initialScore, initialComment, ticket.csatRatedAt]) 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 effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER" const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"

View file

@ -90,7 +90,22 @@ export function useAuth() {
export const { signIn, signOut, useSession } = authClient export const { signIn, signOut, useSession } = authClient
export function AuthProvider({ children }: { children: React.ReactNode }) { 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 ensureUser = useMutation(api.users.ensureUser)
const [convexUserId, setConvexUserId] = useState<string | null>(null) const [convexUserId, setConvexUserId] = useState<string | null>(null)
const [machineContext, setMachineContext] = useState<MachineContext | 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> { export async function getServerSession(): Promise<ServerSession | null> {
try { 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 request = await buildRequest()
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: request.headers, headers: request.headers,

View file

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

View file

@ -42,7 +42,8 @@ const resolvedDatabaseUrl = (() => {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite" 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 = export const prisma =
@ -51,3 +52,9 @@ export const prisma =
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
global.prisma = prisma 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)
}