feat(mentions): switch to Convex-backed search so @<ref> and text queries return visible tickets for the current user; keep permissions

This commit is contained in:
codex-bot 2025-10-23 10:34:32 -03:00
parent e6c841383e
commit 66fe34868c

View file

@ -1,13 +1,14 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server" import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma"
export const runtime = "nodejs" export const runtime = "nodejs"
const MAX_RESULTS = 10 const MAX_RESULTS = 10
const MAX_SCAN = 60
function normalizeRole(role?: string | null) { function normalizeRole(role?: string | null) {
return (role ?? "").toLowerCase() return (role ?? "").toLowerCase()
@ -19,10 +20,14 @@ export async function GET(request: Request) {
return NextResponse.json({ items: [] }, { status: 401 }) return NextResponse.json({ items: [] }, { status: 401 })
} }
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ items: [] }, { status: 500 })
}
const normalizedRole = normalizeRole(session.user.role) const normalizedRole = normalizeRole(session.user.role)
const isAgentOrAdmin = normalizedRole === "admin" || normalizedRole === "agent" const isAgentOrAdmin = normalizedRole === "admin" || normalizedRole === "agent"
const canLinkOwnTickets = normalizedRole === "collaborator" const canLinkOwnTickets = normalizedRole === "collaborator"
if (!isAgentOrAdmin && !canLinkOwnTickets) { if (!isAgentOrAdmin && !canLinkOwnTickets) {
return NextResponse.json({ items: [] }, { status: 403 }) return NextResponse.json({ items: [] }, { status: 403 })
} }
@ -32,75 +37,65 @@ export async function GET(request: Request) {
const rawQuery = url.searchParams.get("q") ?? "" const rawQuery = url.searchParams.get("q") ?? ""
const query = rawQuery.trim() const query = rawQuery.trim()
const whereBase: { const client = new ConvexHttpClient(convexUrl)
tenantId: string
requesterId?: string
} = { tenantId }
if (!isAgentOrAdmin) { // Garantir que o usuário exista no Convex para obter viewerId
whereBase.requesterId = session.user.id let viewerId: string | null = null
try {
const ensured = await client.mutation(api.users.ensureUser, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensured?._id ?? null
} catch (error) {
console.error("[mentions] ensureUser failed", error)
return NextResponse.json({ items: [] }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ items: [] }, { status: 403 })
} }
const numericQuery = /^\d+$/.test(query) // Pesquisar pelos tickets visíveis ao viewer (assunto, resumo ou #referência)
let tickets: Array<{
// Fast path for numeric query: exact reference match at DB level id: string
let filtered: any[] = [] reference: number
if (numericQuery) { subject: string
const ref = Number(query) status: string
const exact = await prisma.ticket.findMany({ priority: string
where: { ...whereBase, reference: ref }, requester?: { name?: string | null } | null
include: { assignee?: { name?: string | null } | null
assignee: { select: { name: true } }, company?: { name?: string | null } | null
requester: { select: { name: true } }, updatedAt?: number | null
company: { select: { name: true } }, }> = []
}, try {
take: MAX_RESULTS, const res = (await client.query(api.tickets.list, {
}) tenantId,
filtered = exact viewerId: viewerId as unknown as Id<"users">,
} else { search: query,
const tickets = await prisma.ticket.findMany({ limit: 40,
where: whereBase, })) as any[]
include: { tickets = Array.isArray(res) ? res : []
assignee: { select: { name: true } }, } catch (error) {
requester: { select: { name: true } }, console.error("[mentions] tickets.list failed", error)
company: { select: { name: true } }, return NextResponse.json({ items: [] }, { status: 500 })
},
orderBy: { updatedAt: "desc" },
take: MAX_SCAN,
})
const lowered = query.toLowerCase()
filtered = tickets
.filter((ticket) => {
if (!query) return true
const subject = ticket.subject ?? ""
if (subject.toLowerCase().includes(lowered)) return true
const requesterName = ticket.requester?.name ?? ""
if (requesterName.toLowerCase().includes(lowered)) return true
const companyName = ticket.company?.name ?? ""
if (companyName.toLowerCase().includes(lowered)) return true
return false
})
.slice(0, MAX_RESULTS)
} }
const basePath = isAgentOrAdmin ? "/tickets" : "/portal/tickets" const basePath = isAgentOrAdmin ? "/tickets" : "/portal/tickets"
const items = tickets.slice(0, MAX_RESULTS).map((t) => ({
const items = filtered.map((ticket) => { id: String(t.id),
const subject = ticket.subject ?? "" reference: Number(t.reference),
return { subject: String(t.subject ?? ""),
id: ticket.id, status: String(t.status ?? "PENDING"),
reference: ticket.reference, priority: String(t.priority ?? "MEDIUM"),
subject, requesterName: t.requester?.name ?? null,
status: ticket.status, assigneeName: t.assignee?.name ?? null,
priority: ticket.priority, companyName: t.company?.name ?? null,
requesterName: ticket.requester?.name ?? null, url: `${basePath}/${String(t.id)}`,
assigneeName: ticket.assignee?.name ?? null, updatedAt: t.updatedAt ? new Date(t.updatedAt).toISOString() : new Date().toISOString(),
companyName: ticket.company?.name ?? null, }))
url: `${basePath}/${ticket.id}`,
updatedAt: ticket.updatedAt.toISOString(),
}
})
return NextResponse.json({ items }) return NextResponse.json({ items })
} }