From 87e1a509ce87dfe9d7de1a66dfade7dddfb1e589 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 12:17:36 -0400 Subject: [PATCH] show sites and labels for non admins --- server/routers/external.ts | 12 + server/routers/launcher/index.ts | 2 + .../launcher/launcherResourceAccess.ts | 260 ++++++++++++++++++ server/routers/launcher/listLauncherLabels.ts | 67 +++++ server/routers/launcher/listLauncherSites.ts | 67 +++++ server/routers/launcher/types.ts | 34 +++ src/components/LabelsFilterSelector.tsx | 27 +- src/components/multi-site-selector.tsx | 27 +- .../LauncherFilterPopover.tsx | 35 ++- src/lib/queries.ts | 68 +++++ 10 files changed, 583 insertions(+), 16 deletions(-) create mode 100644 server/routers/launcher/listLauncherLabels.ts create mode 100644 server/routers/launcher/listLauncherSites.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index b4ca30cff..2ff5d6c4f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -468,6 +468,18 @@ authenticated.get( launcher.listLauncherResources ); +authenticated.get( + "/org/:orgId/launcher/sites", + verifyOrgAccess, + launcher.listLauncherSites +); + +authenticated.get( + "/org/:orgId/launcher/labels", + verifyOrgAccess, + launcher.listLauncherLabels +); + authenticated.get( "/org/:orgId/launcher/views", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index f23b266ea..939bf74bc 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -1,6 +1,8 @@ export * from "./types"; export { listLauncherGroups } from "./listLauncherGroups"; export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherSites } from "./listLauncherSites"; +export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 3083c0672..85ca24a23 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -39,10 +39,12 @@ import { import { LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherFilterListQuery, type LauncherGroup, type LauncherLabel, type LauncherListQuery, type LauncherResource, + type LauncherSiteInfo, parseIdListParam } from "./types"; @@ -1138,3 +1140,261 @@ export async function listLauncherResourcesForUser( total }; } + +function buildSiteNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return or( + like(sql`LOWER(${sites.name})`, pattern), + like(sql`LOWER(${sites.niceId})`, pattern) + ); +} + +function buildLabelNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return like(sql`LOWER(${labels.name})`, pattern); +} + +async function collectAccessibleSites( + orgId: string, + accessible: AccessibleIds, + siteNameSearch?: ReturnType +): Promise> { + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (siteNameSearch) { + publicConditions.push(siteNameSearch); + } + + const publicRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (siteNameSearch) { + siteConditions.push(siteNameSearch); + } + + const siteRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + return siteCountMap; +} + +async function collectAccessibleLabels( + orgId: string, + accessible: AccessibleIds, + labelNameSearch?: ReturnType +): Promise> { + const labelMap = new Map(); + + if (!(await labelsEnabled(orgId))) { + return labelMap; + } + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + publicConditions.push(labelNameSearch); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .where(and(...publicConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + siteConditions.push(labelNameSearch); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .where(and(...siteConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + return labelMap; +} + +export async function listAccessibleLauncherSitesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ sites: LauncherSiteInfo[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const siteNameSearch = buildSiteNameSearchCondition(query.query); + const siteCountMap = await collectAccessibleSites( + orgId, + accessible, + siteNameSearch + ); + + const sites: LauncherSiteInfo[] = Array.from(siteCountMap.values()) + .map((row) => ({ + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = sites.length; + const offset = (query.page - 1) * query.pageSize; + return { + sites: sites.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listAccessibleLauncherLabelsForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ labels: LauncherLabel[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const labelNameSearch = buildLabelNameSearchCondition(query.query); + const labelMap = await collectAccessibleLabels( + orgId, + accessible, + labelNameSearch + ); + + const labelsList = Array.from(labelMap.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = labelsList.length; + const offset = (query.page - 1) * query.pageSize; + return { + labels: labelsList.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherLabels.ts b/server/routers/launcher/listLauncherLabels.ts new file mode 100644 index 000000000..4bb25c965 --- /dev/null +++ b/server/routers/launcher/listLauncherLabels.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { labels, total } = await listAccessibleLauncherLabelsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + labels, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher labels:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherSites.ts b/server/routers/launcher/listLauncherSites.ts new file mode 100644 index 000000000..8755f6d5a --- /dev/null +++ b/server/routers/launcher/listLauncherSites.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherSites( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { sites, total } = await listAccessibleLauncherSitesForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + sites, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher sites retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher sites:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index eba5e8530..9d08271ae 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -93,6 +93,40 @@ export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; }; +export const launcherFilterListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(500) + .default(500), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default("") +}); + +export type LauncherFilterListQuery = z.infer< + typeof launcherFilterListQuerySchema +>; + +export type ListLauncherSitesResponse = { + sites: LauncherSiteInfo[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherLabelsResponse = { + labels: LauncherLabel[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + export const launcherListQuerySchema = z.strictObject({ pageSize: z.coerce .number() diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx index 610bbd224..3c9410e43 100644 --- a/src/components/LabelsFilterSelector.tsx +++ b/src/components/LabelsFilterSelector.tsx @@ -8,7 +8,7 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -27,6 +27,7 @@ type LabelsFilterSelectorProps = { onToggle: (label: LabelFilterOption) => void; onClear?: () => void; showClear?: boolean; + scope?: "org" | "launcher"; }; export function LabelsFilterSelector({ @@ -34,19 +35,33 @@ export function LabelsFilterSelector({ isSelected, onToggle, onClear, - showClear = false + showClear = false, + scope = "org" }: LabelsFilterSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( - orgQueries.labels({ + const orgLabelsQuery = useQuery({ + ...orgQueries.labels({ orgId, query: debouncedQuery, perPage: 500 - }) - ); + }), + enabled: scope === "org" + }); + const launcherLabelsQuery = useQuery({ + ...launcherQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const labels = + scope === "launcher" + ? (launcherLabelsQuery.data ?? []) + : (orgLabelsQuery.data ?? []); return ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index acb8b7dd9..fe81dc697 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -1,4 +1,4 @@ -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { @@ -19,6 +19,7 @@ export type MultiSitesSelectorProps = { selectedSites: Selectedsite[]; onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; + scope?: "org" | "launcher"; }; export function formatMultiSitesSelectorLabel( @@ -40,19 +41,33 @@ export function MultiSitesSelector({ orgId, selectedSites, onSelectionChange, - filterTypes + filterTypes, + scope = "org" }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(siteSearchQuery, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ + const orgSitesQuery = useQuery({ + ...orgQueries.sites({ orgId, query: debouncedQuery, perPage: 10 - }) - ); + }), + enabled: scope === "org" + }); + const launcherSitesQuery = useQuery({ + ...launcherQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const sites = + scope === "launcher" + ? (launcherSitesQuery.data ?? []) + : (orgSitesQuery.data ?? []); const sitesShown = useMemo(() => { const base = filterTypes diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index bcf072086..5a0e425f9 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -17,7 +17,7 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; @@ -44,12 +44,37 @@ export function LauncherFilterPopover({ const [labelsOpen, setLabelsOpen] = useState(false); const { data: labels = [] } = useQuery( - orgQueries.labels({ + launcherQueries.labels({ orgId, perPage: 500 }) ); + const { data: sites = [] } = useQuery( + launcherQueries.sites({ + orgId, + perPage: 500 + }) + ); + + const resolvedSelectedSites: Selectedsite[] = useMemo( + () => + selectedSites.map((selected) => { + const found = sites.find( + (site) => site.siteId === selected.siteId + ); + return found + ? { + siteId: found.siteId, + name: found.name, + type: found.type, + online: found.online + } + : selected; + }), + [sites, selectedSites] + ); + const selectedLabelIds = useMemo( () => new Set(selectedLabels.map((label) => label.labelId)), [selectedLabels] @@ -98,7 +123,7 @@ export function LauncherFilterPopover({ > {formatMultiSitesSelectorLabel( - selectedSites, + resolvedSelectedSites, t )} @@ -111,8 +136,9 @@ export function LauncherFilterPopover({ > @@ -145,6 +171,7 @@ export function LauncherFilterPopover({ > selectedLabelIds.has(label.labelId) } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c5d613942..14fa1d3da 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -48,7 +48,9 @@ import type { ListResourcePoliciesResponse } from "@server/routers/resource/type import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { ListLauncherGroupsResponse, + ListLauncherLabelsResponse, ListLauncherResourcesResponse, + ListLauncherSitesResponse, ListLauncherViewsResponse, LauncherListQuery, LauncherViewConfig @@ -1190,6 +1192,72 @@ export const launcherQueries = { return res.data.data.views; } }), + sites: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "SITES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal }); + return res.data.data.sites; + } + }), + labels: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "LABELS", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/labels?${sp.toString()}`, { + signal + }); + return res.data.data.labels; + } + }), groups: (orgId: string, filters: LauncherQueryFilters) => infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,