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:
parent
ff0254df18
commit
b62e14d8eb
13 changed files with 210 additions and 103 deletions
62
.env.example
62
.env.example
|
|
@ -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
47
env (1)
Normal 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
|
||||||
19
scripts/seed-convex-demo.mjs
Normal file
19
scripts/seed-convex-demo.mjs
Normal 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()
|
||||||
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue