diff --git a/apps/web/src/app/_components/admin/dashboard-table.tsx b/apps/web/src/app/_components/admin/dashboard-table.tsx index 357bc7f8..cce7ed36 100644 --- a/apps/web/src/app/_components/admin/dashboard-table.tsx +++ b/apps/web/src/app/_components/admin/dashboard-table.tsx @@ -445,7 +445,7 @@ function CollapsibleSection({ ); } -/** Must match `useQuery` inputs so refetch/invalidation targets the same cache keys. */ +/** Baseline inputs; search is merged in `useMemo` for consistent query keys. */ const ADMIN_DASHBOARD_INPUT = { limitPerType: 50 } as const; const ADMIN_SECTION_INPUT = { limitPerType: 50 } as const; @@ -462,23 +462,31 @@ export function AdminDashboardTable() { [searchQuery], ); + const sectionQueryInput = useMemo( + () => ({ + ...ADMIN_SECTION_INPUT, + search: searchQuery.trim() || undefined, + }), + [searchQuery], + ); + const { data: mostRecentItemsData, isLoading: isLoadingMostRecent } = api.admin.dashboardItems.useQuery(dashboardQueryInput, { staleTime: 60_000, }); const { data: flaggedData, isLoading: isLoadingFlagged } = - api.admin.flaggedDashboardItems.useQuery(ADMIN_SECTION_INPUT, { + api.admin.flaggedDashboardItems.useQuery(sectionQueryInput, { staleTime: 60_000, }); const { data: hiddenData, isLoading: isLoadingHidden } = - api.admin.hiddenDashboardItems.useQuery(ADMIN_SECTION_INPUT, { + api.admin.hiddenDashboardItems.useQuery(sectionQueryInput, { staleTime: 60_000, }); const { data: reportedData, isLoading: isLoadingReported } = - api.admin.reportedDashboardItems.useQuery(ADMIN_SECTION_INPUT, { + api.admin.reportedDashboardItems.useQuery(sectionQueryInput, { staleTime: 60_000, }); type SectionKey = "recent" | "reported" | "flagged" | "hidden"; @@ -540,9 +548,10 @@ export function AdminDashboardTable() { }, onSuccess: async (_data, variables) => { await Promise.all([ - utils.admin.dashboardItems.refetch(ADMIN_DASHBOARD_INPUT), - utils.admin.flaggedDashboardItems.refetch(ADMIN_SECTION_INPUT), - utils.admin.reportedDashboardItems.refetch(ADMIN_SECTION_INPUT), + utils.admin.dashboardItems.invalidate(), + utils.admin.flaggedDashboardItems.invalidate(), + utils.admin.reportedDashboardItems.invalidate(), + utils.admin.hiddenDashboardItems.invalidate(), ]); clearItemOptimisticState({ category: variables.entityType, @@ -566,9 +575,10 @@ export function AdminDashboardTable() { }, onSuccess: async (_data, variables) => { await Promise.all([ - utils.admin.dashboardItems.refetch(ADMIN_DASHBOARD_INPUT), - utils.admin.hiddenDashboardItems.refetch(ADMIN_SECTION_INPUT), - utils.admin.reportedDashboardItems.refetch(ADMIN_SECTION_INPUT), + utils.admin.dashboardItems.invalidate(), + utils.admin.flaggedDashboardItems.invalidate(), + utils.admin.hiddenDashboardItems.invalidate(), + utils.admin.reportedDashboardItems.invalidate(), ]); clearItemOptimisticState({ category: variables.entityType, @@ -588,32 +598,7 @@ export function AdminDashboardTable() { } else if (activeTab === "role") { result = result.filter((i) => i.category === "role"); } else if (activeTab === "company") { - result = result.filter((i) => i.category === "company").slice(0, 50); - } - - const q = searchQuery.trim().toLowerCase(); - if (q) { - result = result - .filter((item) => { - const titleMatch = item.title.toLowerCase().includes(q); - const subtitleMatch = - item.subtitle?.toLowerCase().includes(q) ?? false; - const descriptionMatch = - item.description?.toLowerCase().includes(q) ?? false; - const companyMatch = - item.company?.toLowerCase().includes(q) ?? false; - const locationMatch = - item.location?.toLowerCase().includes(q) ?? false; - - return ( - titleMatch || - subtitleMatch || - descriptionMatch || - companyMatch || - locationMatch - ); - }) - .slice(0, 50); + result = result.filter((i) => i.category === "company"); } return result; diff --git a/packages/api/src/router/admin.ts b/packages/api/src/router/admin.ts index 30a49b29..4da75f57 100644 --- a/packages/api/src/router/admin.ts +++ b/packages/api/src/router/admin.ts @@ -1,7 +1,8 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; -import { and, desc, eq, ilike, sql } from "@cooper/db"; +import type { CooperDb } from "@cooper/db/client"; +import { and, desc, eq, ilike, inArray, or, sql } from "@cooper/db"; import { Company, Flagged, @@ -78,6 +79,158 @@ const mapCompanyItem = ( hidden: flags.hidden(ModerationEntityType.COMPANY, company.id), }); +/** Resolves company / role / review IDs tied together: direct text hits plus all roles & reviews for matched companies and linked reviews for matched roles. */ +async function getExpandedSearchEntityIds(db: CooperDb, search: string) { + const pattern = `%${search}%`; + + const [nameMatchedCompanies, titleMatchedRoles, textMatchedReviews] = + await Promise.all([ + db.query.Company.findMany({ + where: ilike(Company.name, pattern), + columns: { id: true }, + }), + db.query.Role.findMany({ + where: ilike(Role.title, pattern), + columns: { id: true, companyId: true }, + }), + db.query.Review.findMany({ + where: or( + ilike(Review.textReview, pattern), + ilike(Review.reviewHeadline, pattern), + ), + columns: { id: true, roleId: true, companyId: true }, + }), + ]); + + const companyIds = new Set(); + const roleIds = new Set(); + const reviewIds = new Set(); + + for (const c of nameMatchedCompanies) companyIds.add(c.id); + for (const r of titleMatchedRoles) { + roleIds.add(r.id); + companyIds.add(r.companyId); + } + for (const rv of textMatchedReviews) { + reviewIds.add(rv.id); + companyIds.add(rv.companyId); + roleIds.add(rv.roleId); + } + + if (companyIds.size === 0 && roleIds.size === 0 && reviewIds.size === 0) { + return { + companyIds: new Set(), + roleIds: new Set(), + reviewIds: new Set(), + }; + } + + for (let pass = 0; pass < 6; pass++) { + const before = companyIds.size + roleIds.size; + const cList = [...companyIds]; + if (cList.length > 0) { + const rolesAtCompanies = await db.query.Role.findMany({ + where: (role, { inArray: inArr }) => inArr(role.companyId, cList), + columns: { id: true, companyId: true }, + }); + for (const r of rolesAtCompanies) { + roleIds.add(r.id); + companyIds.add(r.companyId); + } + } + const rList = [...roleIds]; + if (rList.length > 0) { + const roleRows = await db.query.Role.findMany({ + where: (role, { inArray: inArr }) => inArr(role.id, rList), + columns: { companyId: true }, + }); + for (const r of roleRows) companyIds.add(r.companyId); + } + if (companyIds.size + roleIds.size === before) break; + } + + const companyIdList = [...companyIds]; + const roleIdList = [...roleIds]; + + const reviewWhereParts = []; + if (companyIdList.length > 0) { + reviewWhereParts.push(inArray(Review.companyId, companyIdList)); + } + if (roleIdList.length > 0) { + reviewWhereParts.push(inArray(Review.roleId, roleIdList)); + } + + if (reviewWhereParts.length > 0) { + const linkedReviews = await db.query.Review.findMany({ + where: + reviewWhereParts.length === 1 + ? reviewWhereParts[0] + : or(...reviewWhereParts), + columns: { id: true }, + }); + for (const rv of linkedReviews) reviewIds.add(rv.id); + } + + return { companyIds, roleIds, reviewIds }; +} + +async function fetchDashboardRowsForExpandedSearch(args: { + db: CooperDb; + limitPerType: number; + companyIds: Set; + roleIds: Set; + reviewIds: Set; +}) { + const { db, limitPerType, companyIds, roleIds, reviewIds } = args; + const companyIdArr = [...companyIds]; + const roleIdArr = [...roleIds]; + const reviewIdArr = [...reviewIds]; + + const [reviews, roles, companies] = await Promise.all([ + fetchWhenIdsPresent(reviewIdArr, () => + db.query.Review.findMany({ + orderBy: desc(Review.createdAt), + where: (review, { inArray: inArr }) => inArr(review.id, reviewIdArr), + limit: limitPerType, + }), + ), + fetchWhenIdsPresent(roleIdArr, () => + db.query.Role.findMany({ + orderBy: desc(Role.createdAt), + where: (role, { inArray: inArr }) => inArr(role.id, roleIdArr), + limit: limitPerType, + }), + ), + fetchWhenIdsPresent(companyIdArr, () => + db.query.Company.findMany({ + orderBy: desc(Company.createdAt), + where: (company, { inArray: inArr }) => inArr(company.id, companyIdArr), + limit: limitPerType, + }), + ), + ]); + + return { reviews, roles, companies }; +} + +function filterIdsBySearchExpansion( + ids: string[], + expanded: { + companyIds: Set; + roleIds: Set; + reviewIds: Set; + }, + kind: "review" | "role" | "company", +) { + const allow = + kind === "review" + ? expanded.reviewIds + : kind === "role" + ? expanded.roleIds + : expanded.companyIds; + return ids.filter((id) => allow.has(id)); +} + export const adminRouter = { dashboardItems: protectedProcedure .input( @@ -93,21 +246,46 @@ export const adminRouter = { const search = input?.search?.trim() ?? ""; const hasSearch = search.length > 0; - const [reviews, roles, companies] = await Promise.all([ - ctx.db.query.Review.findMany({ - orderBy: desc(Review.createdAt), - limit: limitPerType, - }), - ctx.db.query.Role.findMany({ - orderBy: desc(Role.createdAt), - limit: limitPerType, - }), - ctx.db.query.Company.findMany({ - orderBy: desc(Company.createdAt), - where: hasSearch ? ilike(Company.name, `%${search}%`) : undefined, - limit: limitPerType, - }), - ]); + let reviews: Awaited> = + []; + let roles: Awaited> = []; + let companies: Awaited> = + []; + + if (!hasSearch) { + [reviews, roles, companies] = await Promise.all([ + ctx.db.query.Review.findMany({ + orderBy: desc(Review.createdAt), + limit: limitPerType, + }), + ctx.db.query.Role.findMany({ + orderBy: desc(Role.createdAt), + limit: limitPerType, + }), + ctx.db.query.Company.findMany({ + orderBy: desc(Company.createdAt), + limit: limitPerType, + }), + ]); + } else { + const expanded = await getExpandedSearchEntityIds(ctx.db, search); + const emptyExpanded = + expanded.companyIds.size === 0 && + expanded.roleIds.size === 0 && + expanded.reviewIds.size === 0; + if (!emptyExpanded) { + const rows = await fetchDashboardRowsForExpandedSearch({ + db: ctx.db, + limitPerType, + companyIds: expanded.companyIds, + roleIds: expanded.roleIds, + reviewIds: expanded.reviewIds, + }); + reviews = rows.reviews; + roles = rows.roles; + companies = rows.companies; + } + } const [flaggedRecords, hiddenRecords] = await Promise.all([ ctx.db.query.Flagged.findMany({ @@ -162,27 +340,55 @@ export const adminRouter = { z .object({ limitPerType: z.number().min(1).max(100).default(20), + search: z.string().optional(), }) .optional(), ) .query(async ({ ctx, input }) => { const limitPerType = input?.limitPerType ?? 20; + const search = input?.search?.trim() ?? ""; + const hasSearch = search.length > 0; const flagged = await ctx.db.query.Flagged.findMany({ orderBy: desc(Flagged.createdAt), where: eq(Flagged.isActive, true), }); - const reviewIds = flagged + let reviewIds = flagged .filter((f) => f.entityType === ModerationEntityType.REVIEW) .map((f) => f.entityId); - const roleIds = flagged + let roleIds = flagged .filter((f) => f.entityType === ModerationEntityType.ROLE) .map((f) => f.entityId); - const companyIds = flagged + let companyIds = flagged .filter((f) => f.entityType === ModerationEntityType.COMPANY) .map((f) => f.entityId); + if (hasSearch) { + const expanded = await getExpandedSearchEntityIds(ctx.db, search); + if ( + expanded.companyIds.size === 0 && + expanded.roleIds.size === 0 && + expanded.reviewIds.size === 0 + ) { + return { + items: [], + counts: { + reviews: 0, + roles: 0, + companies: 0, + }, + }; + } + reviewIds = filterIdsBySearchExpansion(reviewIds, expanded, "review"); + roleIds = filterIdsBySearchExpansion(roleIds, expanded, "role"); + companyIds = filterIdsBySearchExpansion( + companyIds, + expanded, + "company", + ); + } + if ( reviewIds.length === 0 && roleIds.length === 0 && @@ -253,27 +459,55 @@ export const adminRouter = { z .object({ limitPerType: z.number().min(1).max(100).default(20), + search: z.string().optional(), }) .optional(), ) .query(async ({ ctx, input }) => { const limitPerType = input?.limitPerType ?? 20; + const search = input?.search?.trim() ?? ""; + const hasSearch = search.length > 0; const hidden = await ctx.db.query.Hidden.findMany({ orderBy: desc(Hidden.createdAt), where: eq(Hidden.isActive, true), }); - const reviewIds = hidden + let reviewIds = hidden .filter((h) => h.entityType === ModerationEntityType.REVIEW) .map((h) => h.entityId); - const roleIds = hidden + let roleIds = hidden .filter((h) => h.entityType === ModerationEntityType.ROLE) .map((h) => h.entityId); - const companyIds = hidden + let companyIds = hidden .filter((h) => h.entityType === ModerationEntityType.COMPANY) .map((h) => h.entityId); + if (hasSearch) { + const expanded = await getExpandedSearchEntityIds(ctx.db, search); + if ( + expanded.companyIds.size === 0 && + expanded.roleIds.size === 0 && + expanded.reviewIds.size === 0 + ) { + return { + items: [], + counts: { + reviews: 0, + roles: 0, + companies: 0, + }, + }; + } + reviewIds = filterIdsBySearchExpansion(reviewIds, expanded, "review"); + roleIds = filterIdsBySearchExpansion(roleIds, expanded, "role"); + companyIds = filterIdsBySearchExpansion( + companyIds, + expanded, + "company", + ); + } + if ( reviewIds.length === 0 && roleIds.length === 0 && @@ -344,31 +578,34 @@ export const adminRouter = { z .object({ limitPerType: z.number().min(1).max(100).default(20), + search: z.string().optional(), }) .optional(), ) .query(async ({ ctx, input }) => { const limitPerType = input?.limitPerType ?? 20; + const search = input?.search?.trim() ?? ""; + const hasSearch = search.length > 0; const reports = await ctx.db.query.Report.findMany({ orderBy: desc(Report.createdAt), }); - const reviewIds = [ + let reviewIds = [ ...new Set( reports .map((r) => r.reviewId) .filter((id): id is string => Boolean(id)), ), ]; - const roleIds = [ + let roleIds = [ ...new Set( reports .map((r) => r.roleId) .filter((id): id is string => Boolean(id)), ), ]; - const companyIds = [ + let companyIds = [ ...new Set( reports .map((r) => r.companyId) @@ -376,6 +613,31 @@ export const adminRouter = { ), ]; + if (hasSearch) { + const expanded = await getExpandedSearchEntityIds(ctx.db, search); + if ( + expanded.companyIds.size === 0 && + expanded.roleIds.size === 0 && + expanded.reviewIds.size === 0 + ) { + return { + items: [], + counts: { + reviews: 0, + roles: 0, + companies: 0, + }, + }; + } + reviewIds = filterIdsBySearchExpansion(reviewIds, expanded, "review"); + roleIds = filterIdsBySearchExpansion(roleIds, expanded, "role"); + companyIds = filterIdsBySearchExpansion( + companyIds, + expanded, + "company", + ); + } + if ( reviewIds.length === 0 && roleIds.length === 0 && diff --git a/packages/api/src/router/review.ts b/packages/api/src/router/review.ts index 2b11dc65..4883db9e 100644 --- a/packages/api/src/router/review.ts +++ b/packages/api/src/router/review.ts @@ -140,19 +140,21 @@ export const reviewRouter = { } // Check if a CompaniesToLocations object already exists with the given companyId and locationId - if (input.locationId) { + if (input.locationId && input.companyId) { + const companyId = input.companyId; + const locationId = input.locationId; const existingRelation = await ctx.db.query.CompaniesToLocations.findFirst({ where: and( - eq(CompaniesToLocations.companyId, input.companyId), - eq(CompaniesToLocations.locationId, input.locationId ?? ""), + eq(CompaniesToLocations.companyId, companyId), + eq(CompaniesToLocations.locationId, locationId), ), }); if (!existingRelation) { await ctx.db.insert(CompaniesToLocations).values({ - locationId: input.locationId, - companyId: input.companyId, + locationId, + companyId, }); } } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 671359ae..9bc5ebe1 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -4,3 +4,6 @@ import { drizzle } from "drizzle-orm/vercel-postgres"; import * as schema from "./schema"; export const db = drizzle(sql, { schema }); + +/** Stable alias for `typeof db` (for routers that accept a DB instance type without importing `db`). */ +export type CooperDb = typeof db;