Merge branch 'resource-launcher-compact' into dev

This commit is contained in:
miloschwartz
2026-07-06 14:19:58 -04:00
37 changed files with 2520 additions and 178 deletions
+1
View File
@@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", {
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
+3
View File
@@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", {
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
+32
View File
@@ -327,6 +327,14 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg
);
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
@@ -470,6 +478,12 @@ authenticated.get(
launcher.listLauncherGroups
);
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
@@ -494,6 +508,12 @@ authenticated.get(
launcher.listLauncherViews
);
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
@@ -506,6 +526,18 @@ authenticated.put(
launcher.updateLauncherView
);
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
@@ -79,7 +79,8 @@ export async function createLauncherView(
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
isOrgWide: created.userId == null
isOrgWide: created.userId == null,
isDefault: created.isDefault
},
success: true,
error: false,
@@ -0,0 +1,104 @@
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 { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -46,6 +46,15 @@ export async function deleteLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
+4
View File
@@ -1,5 +1,6 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";
@@ -0,0 +1,65 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,137 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}
+262 -40
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { db } from "@server/db";
import {
exitNodes,
@@ -31,13 +32,15 @@ import {
isNull,
like,
or,
sql
sql,
type SQL
} from "drizzle-orm";
import {
formatPublicResourceAccess,
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -59,6 +62,68 @@ export type AccessibleIds = {
};
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
type LauncherResourcesCacheEntry = {
items: LauncherResource[];
total: number;
};
type LauncherGroupsCacheEntry = {
groups: LauncherGroup[];
total: number;
};
function launcherListQueryHash(
userRoleIds: number[],
query: LauncherListQuery,
extra?: Record<string, string>
) {
const payload = JSON.stringify({
roles: [...userRoleIds].sort((a, b) => a - b),
query: query.query,
groupBy: query.groupBy,
siteIds: query.siteIds ?? "",
labelIds: query.labelIds ?? "",
sort_by: query.sort_by,
order: query.order,
...extra
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function launcherResourcesQueryHash(
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
) {
return launcherListQueryHash(userRoleIds, query, {
groupKey: query.groupKey
});
}
function launcherGroupsQueryHash(
userRoleIds: number[],
query: LauncherListQuery
) {
return launcherListQueryHash(userRoleIds, query);
}
function launcherResourcesCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:results:${orgId}:${userId}:${queryHash}`;
}
function launcherGroupsCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
}
function launcherAccessibleIdsCacheKey(
orgId: string,
@@ -194,6 +259,21 @@ function searchPattern(query: string) {
return `%${query.trim()}%`;
}
function combineOrConditions(
...conditions: (SQL | undefined)[]
): SQL | undefined {
const parts = conditions.filter(
(condition): condition is SQL => !!condition
);
if (parts.length === 0) {
return undefined;
}
if (parts.length === 1) {
return parts[0];
}
return or(...parts);
}
function buildSearchConditionForPublic(
query: string,
labelsFeatureEnabled: boolean
@@ -205,7 +285,17 @@ function buildSearchConditionForPublic(
const queryList = [
like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
inArray(
resources.resourceId,
db
.select({ id: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
)
];
if (labelsFeatureEnabled) {
@@ -238,6 +328,11 @@ function buildSearchConditionForSiteResource(
const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
pattern
),
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
@@ -262,6 +357,79 @@ function buildSearchConditionForSiteResource(
return or(...queryList);
}
async function filterPublicResourceIdsByTextSearch(
orgId: string,
resourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return resourceIds;
}
const rows = await db
.selectDistinct({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
textMatch
)
);
return rows.map((row) => row.resourceId);
}
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return siteResourceIds;
}
const rows = await db
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
textMatch
)
);
return rows.map((row) => row.siteResourceId);
}
async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
@@ -588,9 +756,8 @@ async function listSiteGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups: groups.slice(offset, offset + query.pageSize),
groups,
total
};
}
@@ -788,26 +955,53 @@ async function listLabelGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups: groups.slice(offset, offset + query.pageSize),
groups,
total
};
}
async function listLauncherGroupsForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<LauncherGroupsCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
return listSiteGroups(orgId, accessible, query);
}
export async function listLauncherGroupsForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
let result = cached;
if (!result) {
result = await listLauncherGroupsForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
}
return listSiteGroups(orgId, accessible, query);
const offset = (query.page - 1) * query.pageSize;
return {
groups: result.groups.slice(offset, offset + query.pageSize),
total: result.total
};
}
async function mapPublicResources(
@@ -1018,26 +1212,6 @@ function filterResourcesByLabel(
);
}
function filterResourcesBySearch(
items: LauncherResource[],
query: string
): LauncherResource[] {
if (!query.trim()) {
return items;
}
const pattern = query.trim().toLowerCase();
return items.filter(
(item) =>
item.name.toLowerCase().includes(pattern) ||
item.accessDisplay.toLowerCase().includes(pattern) ||
item.accessCopyValue.toLowerCase().includes(pattern) ||
item.labels.some((label) =>
label.name.toLowerCase().includes(pattern)
) ||
item.site?.name.toLowerCase().includes(pattern)
);
}
function sortLauncherResources(
items: LauncherResource[],
order: "asc" | "desc"
@@ -1050,12 +1224,12 @@ function sortLauncherResources(
});
}
export async function listLauncherResourcesForUser(
async function listLauncherResourcesForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
): Promise<LauncherResourcesCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1128,6 +1302,27 @@ export async function listLauncherResourcesForUser(
}
}
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query,
labelsFeatureEnabled
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query,
labelsFeatureEnabled
);
}
}
const labelMaps = await fetchLabelsForResources(
orgId,
filteredResourceIds,
@@ -1159,21 +1354,48 @@ export async function listLauncherResourcesForUser(
]);
let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
}
}
items = sortLauncherResources(items, query.order);
const total = items.length;
return {
items,
total: items.length
};
}
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
let result = cached;
if (!result) {
result = await listLauncherResourcesForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
}
const offset = (query.page - 1) * query.pageSize;
return {
resources: items.slice(offset, offset + query.pageSize),
total
resources: result.items.slice(offset, offset + query.pageSize),
total: result.total
};
}
+133
View File
@@ -0,0 +1,133 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}
@@ -0,0 +1,60 @@
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 { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+6 -17
View File
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
import {
extractDefaultViewOverrides,
listVisibleLauncherViews
} from "./launcherDefaultView";
export async function listLauncherViews(
req: Request,
@@ -51,7 +39,8 @@ export async function listLauncherViews(
return response(res, {
data: {
views: rows.map(mapViewRow)
views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
},
success: true,
error: false,
+55 -4
View File
@@ -2,9 +2,10 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label"]).default("site"),
groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
isDefault: boolean;
};
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
};
export const launcherFilterListQuerySchema = z.strictObject({
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int()
.positive()
.optional()
.catch(500)
.default(500),
.catch(20)
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label"]).optional().default("site"),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
+11 -1
View File
@@ -66,6 +66,15 @@ export async function updateLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
@@ -135,7 +144,8 @@ export async function updateLauncherView(
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null
isOrgWide: updated.userId == null,
isDefault: updated.isDefault
},
success: true,
error: false,
@@ -0,0 +1,82 @@
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 { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}