diff --git a/server/index.ts b/server/index.ts index 9e7d0f54..79058ea0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -91,6 +91,7 @@ declare global { interface Request { user?: User; userOrgRoleId?: number; + orgId?: string; userOrgId?: string; userOrgIds?: string[]; } diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index d93316e8..7456096c 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -91,4 +91,4 @@ export async function listOrgs(req: Request, res: Response, next: NextFunction): logger.error(error); return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); } -} \ No newline at end of file +} diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 20c24fb5..f7ed186f 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,70 +1,51 @@ -import { Request, Response, NextFunction } from 'express'; -import { z } from 'zod'; -import { db } from '@server/db'; -import { resources, sites, userResources, roleResources } from '@server/db/schema'; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + resources, + sites, + userResources, + roleResources, +} from "@server/db/schema"; import response from "@server/utils/response"; -import HttpCode from '@server/types/HttpCode'; -import createHttpError from 'http-errors'; -import { sql, eq, and, or, inArray } from 'drizzle-orm'; -import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; -import logger from '@server/logger'; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import logger from "@server/logger"; -const listResourcesParamsSchema = z.object({ - siteId: z.coerce.number().int().positive().optional(), - orgId: z.coerce.number().int().positive().optional(), -}).refine(data => !!data.siteId !== !!data.orgId, { - message: "Either siteId or orgId must be provided, but not both", -}); +const listResourcesParamsSchema = z + .object({ + siteId: z.number().int().positive().optional(), + orgId: z.string().optional(), + }) + .refine((data) => !!data.siteId !== !!data.orgId, { + message: "Either siteId or orgId must be provided, but not both", + }); const listResourcesSchema = z.object({ - limit: z.coerce.number().int().positive().default(10), - offset: z.coerce.number().int().nonnegative().default(0), + limit: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()), + + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()), }); -interface RequestWithOrgAndRole extends Request { - userOrgRoleId?: number; - orgId?: number; -} - -export async function listResources(req: RequestWithOrgAndRole, res: Response, next: NextFunction): Promise { - try { - const parsedQuery = listResourcesSchema.safeParse(req.query); - if (!parsedQuery.success) { - return next(createHttpError(HttpCode.BAD_REQUEST, parsedQuery.error.errors.map(e => e.message).join(', '))); - } - const { limit, offset } = parsedQuery.data; - - const parsedParams = listResourcesParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next(createHttpError(HttpCode.BAD_REQUEST, parsedParams.error.errors.map(e => e.message).join(', '))); - } - const { siteId, orgId } = parsedParams.data; - - // Check if the user has permission to list sites - const hasPermission = await checkUserActionPermission(ActionsEnum.listResources, req); - if (!hasPermission) { - return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); - } - - if (orgId && orgId !== req.orgId) { - return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have access to this organization')); - } - - // Get the list of resources the user has access to - const accessibleResources = await db - .select({ resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` }) - .from(userResources) - .fullJoin(roleResources, eq(userResources.resourceId, roleResources.resourceId)) - .where( - or( - eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) - ) - ); - - const accessibleResourceIds = accessibleResources.map(resource => resource.resourceId); - - let baseQuery: any = db +function queryResources( + accessibleResourceIds: string[], + siteId?: number, + orgId?: string, +) { + if (siteId) { + return db .select({ resourceId: resources.resourceId, name: resources.name, @@ -73,27 +54,120 @@ export async function listResources(req: RequestWithOrgAndRole, res: Response, n }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) - .where(inArray(resources.resourceId, accessibleResourceIds)); + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.siteId, siteId), + ), + ); + } else if (orgId) { + return db + .select({ + resourceId: resources.resourceId, + name: resources.name, + subdomain: resources.subdomain, + siteName: sites.name, + }) + .from(resources) + .leftJoin(sites, eq(resources.siteId, sites.siteId)) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + ), + ); + } +} + +export type ListSitesResponse = { + resources: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listResources( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const parsedQuery = listResourcesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + parsedQuery.error.errors.map((e) => e.message).join(", "), + ), + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + parsedParams.error.errors.map((e) => e.message).join(", "), + ), + ); + } + const { siteId, orgId } = parsedParams.data; + + // Check if the user has permission to list sites + const hasPermission = await checkUserActionPermission( + ActionsEnum.listResources, + req, + ); + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to perform this action", + ), + ); + } + + if (orgId && orgId !== req.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization", + ), + ); + } + + // Get the list of resources the user has access to + const accessibleResources = await db + .select({ + resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`, + }) + .from(userResources) + .fullJoin( + roleResources, + eq(userResources.resourceId, roleResources.resourceId), + ) + .where( + or( + eq(userResources.userId, req.user!.userId), + eq(roleResources.roleId, req.userOrgRoleId!), + ), + ); + + const accessibleResourceIds = accessibleResources.map( + (resource) => resource.resourceId, + ); let countQuery: any = db - .select({ count: sql`cast(count(*) as integer)` }) + .select({ count: count() }) .from(resources) .where(inArray(resources.resourceId, accessibleResourceIds)); - if (siteId) { - baseQuery = baseQuery.where(eq(resources.siteId, siteId)); - countQuery = countQuery.where(eq(resources.siteId, siteId)); - } else { - // If orgId is provided, it's already checked to match req.orgId - baseQuery = baseQuery.where(eq(resources.orgId, req.orgId!)); - countQuery = countQuery.where(eq(resources.orgId, req.orgId!)); - } + const baseQuery = queryResources(accessibleResourceIds, siteId, orgId); - const resourcesList = await baseQuery.limit(limit).offset(offset); + const resourcesList = await baseQuery!.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; - return response(res, { + return response(res, { data: { resources: resourcesList, pagination: { @@ -109,6 +183,11 @@ export async function listResources(req: RequestWithOrgAndRole, res: Response, n }); } catch (error) { logger.error(error); - return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred...", + ), + ); } } diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 7fc05cf3..65dcd715 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -3,7 +3,7 @@ import { db } from "@server/db"; import { orgs, roleSites, sites, userSites } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/utils/response"; -import { and, eq, inArray, or, sql } from "drizzle-orm"; +import { and, count, eq, inArray, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -123,7 +123,7 @@ export async function listSites( const baseQuery = querySites(orgId, accessibleSiteIds); let countQuery = db - .select({ count: sql`cast(count(*) as integer)` }) + .select({ count: count() }) .from(sites) .where( and( diff --git a/src/app/[orgId]/sites/components/DataTable.tsx b/src/app/[orgId]/sites/components/DataTable.tsx index 1dee581f..59841b3b 100644 --- a/src/app/[orgId]/sites/components/DataTable.tsx +++ b/src/app/[orgId]/sites/components/DataTable.tsx @@ -69,7 +69,7 @@ export function DataTable({ .getColumn("name") ?.setFilterValue(event.target.value) } - className="max-w-sm" + className="max-w-sm mr-2" />