diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index 2a0dd021c..192e7aa57 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -20,7 +20,12 @@ const ROUTE_ME = "/me"; const ROUTE_USERS = "/users"; const ROUTE_USERS_DISABLE = "/users/:id/disable"; const ROUTE_USERS_ENABLE = "/users/:id/enable"; + +// Tables +const TABLE_ROLES = "roles"; +const TABLE_ROLE_PERMISSIONS = "role_permissions"; const TABLE_USERS = "users"; +const TABLE_USER_ROLES = "user_roles"; // Roles const ROUTE_ROLES = "/roles"; @@ -75,4 +80,7 @@ export { ROUTE_USERS_ENABLE, TABLE_INVITATIONS, TABLE_USERS, + TABLE_ROLES, + TABLE_ROLE_PERMISSIONS, + TABLE_USER_ROLES, }; diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/createRole.ts index 78ccfe45b..5500797d4 100644 --- a/packages/user/src/model/roles/handlers/createRole.ts +++ b/packages/user/src/model/roles/handlers/createRole.ts @@ -5,7 +5,7 @@ import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; const createRole = async (request: SessionRequest, reply: FastifyReply) => { - const { body, log } = request; + const { body, log, dbSchema, config, slonik } = request; const { role, permissions } = body as { role: string; @@ -13,9 +13,9 @@ const createRole = async (request: SessionRequest, reply: FastifyReply) => { }; try { - const service = new RoleService(); + const service = new RoleService(config, slonik, dbSchema); - const createResponse = await service.createRole(role, permissions); + const createResponse = await service.create({ role, permissions }); return reply.send(createResponse); } catch (error) { diff --git a/packages/user/src/model/roles/handlers/deleteRole.ts b/packages/user/src/model/roles/handlers/deleteRole.ts index 79a137523..b7f1b13c5 100644 --- a/packages/user/src/model/roles/handlers/deleteRole.ts +++ b/packages/user/src/model/roles/handlers/deleteRole.ts @@ -5,7 +5,7 @@ import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { - const { log, query } = request; + const { config, slonik, log, query, dbSchema } = request; try { let { role } = query as { role?: string }; @@ -25,9 +25,9 @@ const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { }); } - const service = new RoleService(); + const service = new RoleService(config, slonik, dbSchema); - const deleteResponse = await service.deleteRole(role); + const deleteResponse = await service.delete(role); return reply.send(deleteResponse); } diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts index dcda6ae2f..456fc05e7 100644 --- a/packages/user/src/model/roles/handlers/getPermissions.ts +++ b/packages/user/src/model/roles/handlers/getPermissions.ts @@ -4,29 +4,27 @@ import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { - const { log, query } = request; - let permissions: string[] = []; - + const { config, dbSchema, slonik, log, query } = request; try { - let { role } = query as { role?: string }; + let { role } = query as { role?: number }; if (role) { try { - role = JSON.parse(role) as string; + role = JSON.parse(role as unknown as string) as number; } catch { /* empty */ } - if (typeof role != "string") { - return reply.send({ permissions }); + if (typeof role != "number") { + throw new TypeError("Invalid input"); } - const service = new RoleService(); + const service = new RoleService(config, slonik, dbSchema); - permissions = await service.getPermissionsForRole(role); - } + const permissions = await service.getPermissionsForRole(role); - return reply.send({ permissions }); + return permissions; + } } catch (error) { log.error(error); reply.status(500); diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts index 7429c097d..612e8cd1b 100644 --- a/packages/user/src/model/roles/handlers/getRoles.ts +++ b/packages/user/src/model/roles/handlers/getRoles.ts @@ -4,10 +4,11 @@ import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; const getRoles = async (request: SessionRequest, reply: FastifyReply) => { - const { log } = request; + const { config, dbSchema, log, slonik } = request; try { - const service = new RoleService(); + const service = new RoleService(config, slonik, dbSchema); + const roles = await service.getRoles(); return reply.send({ roles }); diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/updatePermissions.ts index 2bb89c5c9..0ae8d5172 100644 --- a/packages/user/src/model/roles/handlers/updatePermissions.ts +++ b/packages/user/src/model/roles/handlers/updatePermissions.ts @@ -8,17 +8,18 @@ const updatePermissions = async ( request: SessionRequest, reply: FastifyReply ) => { - const { log, body } = request; + const { body, config, dbSchema, log, slonik } = request; try { - const { role, permissions } = body as { - role: string; + const { roleId, permissions } = body as { + roleId: number; permissions: string[]; }; - const service = new RoleService(); + const service = new RoleService(config, slonik, dbSchema); + const updatedPermissionsResponse = await service.updateRolePermissions( - role, + roleId, permissions ); diff --git a/packages/user/src/model/roles/resolver.ts b/packages/user/src/model/roles/resolver.ts index 491136be7..ef2640168 100644 --- a/packages/user/src/model/roles/resolver.ts +++ b/packages/user/src/model/roles/resolver.ts @@ -14,15 +14,15 @@ const Mutation = { }, context: MercuriusContext ) => { - const { app } = context; + const { app, config, dbSchema, database } = context; try { - const service = new RoleService(); + const service = new RoleService(config, database, dbSchema); - const createResponse = await service.createRole( - arguments_.role, - arguments_.permissions - ); + const createResponse = await service.create({ + role: arguments_.role, + permissions: arguments_.permissions, + }); return createResponse; } catch (error) { @@ -53,14 +53,14 @@ const Mutation = { }, context: MercuriusContext ) => { - const { app } = context; + const { app, config, dbSchema, database } = context; try { - const service = new RoleService(); + const service = new RoleService(config, database, dbSchema); const { role } = arguments_; - const deleteResponse = await service.deleteRole(role); + const deleteResponse = await service.delete(role); return deleteResponse; } catch (error) { @@ -87,18 +87,19 @@ const Mutation = { updateRolePermissions: async ( parent: unknown, arguments_: { - role: string; + roleId: number; permissions: string[]; }, context: MercuriusContext ) => { - const { app } = context; - const { permissions, role } = arguments_; + const { app, config, database, dbSchema } = context; + const { permissions, roleId } = arguments_; try { - const service = new RoleService(); + const service = new RoleService(config, database, dbSchema); + const updatedPermissionsResponse = await service.updateRolePermissions( - role, + roleId, permissions ); @@ -131,10 +132,11 @@ const Query = { arguments_: Record, context: MercuriusContext ) => { - const { app } = context; + const { app, config, database, dbSchema } = context; try { - const service = new RoleService(); + const service = new RoleService(config, database, dbSchema); + const roles = await service.getRoles(); return roles; @@ -153,22 +155,21 @@ const Query = { rolePermissions: async ( parent: unknown, arguments_: { - role: string; + role: number; }, context: MercuriusContext ) => { - const { app } = context; + const { app, config, database, dbSchema } = context; const { role } = arguments_; - let permissions: string[] = []; try { if (role) { - const service = new RoleService(); + const service = new RoleService(config, database, dbSchema); - permissions = await service.getPermissionsForRole(role); - } + const permissions = await service.getPermissionsForRole(role); - return permissions; + return permissions; + } } catch (error) { app.log.error(error); diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts index 3b9de3ba1..c0cd5663a 100644 --- a/packages/user/src/model/roles/service.ts +++ b/packages/user/src/model/roles/service.ts @@ -1,96 +1,94 @@ -import UserRoles from "supertokens-node/recipe/userroles"; +import { BaseService } from "@dzangolab/fastify-slonik"; +import RoleSqlFactory from "./sqlFactory"; +import { TABLE_ROLES } from "../../constants"; import CustomApiError from "../../customApiError"; -class RoleService { - createRole = async ( - role: string, - permissions?: string[] - ): Promise<{ status: "OK" }> => { - const { roles } = await UserRoles.getAllRoles(role); - - if (roles.includes(role)) { - throw new CustomApiError({ - name: "ROLE_ALREADY_EXISTS", - message: "Unable to create role as it already exists", - statusCode: 422, +import type { RolePermission, RoleWithPermissions } from "../../types/roles"; +import type { Service } from "../../types/roles/service"; +import type { QueryResultRow } from "slonik"; + +class RoleService< + Role extends QueryResultRow, + RoleCreateInput extends QueryResultRow, + RoleUpdateInput extends QueryResultRow + > + extends BaseService + // eslint-disable-next-line prettier/prettier + implements Service { + static readonly TABLE = TABLE_ROLES; + + create = async (data: RoleCreateInput) => { + const query = this.factory.getCreateSql({ + role: data.role, + default: data.default, + } as unknown as RoleCreateInput); + + const result = (await this.database.connect(async (connection) => { + return connection.query(query).then((data) => { + return data.rows[0]; }); - } + })) as Role; - const createRoleResponse = await UserRoles.createNewRoleOrAddPermissions( - role, - permissions || [] + const permissions = await this.addRolePermissions( + result.id as number, + data.permissions as string[] ); - return { status: createRoleResponse.status }; + return { + ...result, + permissions: permissions.map( + (permission: RolePermission) => permission.permission + ), + }; }; - deleteRole = async (role: string): Promise<{ status: "OK" }> => { - const response = await UserRoles.getUsersThatHaveRole(role); - - if (response.status === "UNKNOWN_ROLE_ERROR") { - throw new CustomApiError({ - name: response.status, - message: `Invalid role`, - statusCode: 422, - }); - } + addRolePermissions = async (roleId: number, permissions: string[]) => { + const query = this.factory.getAddRolePermissionSql(roleId, permissions); - if (response.users.length > 0) { - throw new CustomApiError({ - name: "ROLE_IN_USE", - message: - "The role is currently assigned to one or more users and cannot be deleted", - statusCode: 422, + const result = (await this.database.connect(async (connection) => { + return connection.query(query).then((data) => { + return data.rows; }); - } - - const deleteRoleResponse = await UserRoles.deleteRole(role); + })) as RolePermission[]; - return { status: deleteRoleResponse.status }; + return result; }; - getPermissionsForRole = async (role: string): Promise => { - let permissions: string[] = []; + getPermissionsForRole = async (id: number): Promise => { + const query = this.factory.getPermissionsForRoleSql(id); - const response = await UserRoles.getPermissionsForRole(role); - - if (response.status === "OK") { - permissions = response.permissions; - } + const result = await this.database.connect((connection) => { + return connection.any(query); + }); - return permissions; + return result as RolePermission[]; }; - getRoles = async (): Promise<{ role: string; permissions: string[] }[]> => { - let roles: { role: string; permissions: string[] }[] = []; + getRoles = async (): Promise => { + const query = this.factory.getRolesSql(); - const response = await UserRoles.getAllRoles(); + const result = await this.database.connect((connection) => { + return connection.any(query); + }); - if (response.status === "OK") { - // [DU 2024-MAR-20] This is N+1 problem - roles = await Promise.all( - response.roles.map(async (role) => { - const response = await UserRoles.getPermissionsForRole(role); + return result as RoleWithPermissions[]; + }; - return { - role, - permissions: response.status === "OK" ? response.permissions : [], - }; - }) - ); - } + removePermissionsFromRole = async (roleId: number, permissions: string[]) => { + const query = this.factory.getRemovePermissionsSql(roleId, permissions); + + const result = await this.database.connect((connection) => { + return connection.any(query); + }); - return roles; + return result; }; - updateRolePermissions = async ( - role: string, - permissions: string[] - ): Promise<{ status: "OK"; permissions: string[] }> => { - const response = await UserRoles.getPermissionsForRole(role); + updateRolePermissions = async (roleId: number, permissions: string[]) => { + const response = await this.findById(roleId); - if (response.status === "UNKNOWN_ROLE_ERROR") { + if (!response) { throw new CustomApiError({ name: "UNKNOWN_ROLE_ERROR", message: `Invalid role`, @@ -98,7 +96,7 @@ class RoleService { }); } - const rolePermissions = response.permissions; + const rolePermissions = response.permissions as string[]; const newPermissions = permissions.filter( (permission) => !rolePermissions.includes(permission) @@ -108,16 +106,33 @@ class RoleService { (permission) => !permissions.includes(permission) ); - await UserRoles.removePermissionsFromRole(role, removedPermissions); - await UserRoles.createNewRoleOrAddPermissions(role, newPermissions); + await this.removePermissionsFromRole(roleId, removedPermissions); + await this.addRolePermissions(roleId, newPermissions); - const permissionsResponse = await this.getPermissionsForRole(role); + const permissionsResponse = await this.getPermissionsForRole(roleId); - return { - status: "OK", - permissions: permissionsResponse, - }; + return permissionsResponse; }; + + get factory() { + if (!this.table) { + throw new Error(`Service table is not defined`); + } + + if (!this._factory) { + this._factory = new RoleSqlFactory< + Role, + RoleCreateInput, + RoleUpdateInput + >(this); + } + + return this._factory as RoleSqlFactory< + Role, + RoleCreateInput, + RoleUpdateInput + >; + } } export default RoleService; diff --git a/packages/user/src/model/roles/sql.ts b/packages/user/src/model/roles/sql.ts new file mode 100644 index 000000000..a6267abe5 --- /dev/null +++ b/packages/user/src/model/roles/sql.ts @@ -0,0 +1,63 @@ +import humps from "humps"; +import { sql } from "slonik"; + +import type { SortInput } from "@dzangolab/fastify-slonik"; +import type { IdentifierSqlToken } from "slonik"; + +const createSortFragment = ( + tableIdentifier: IdentifierSqlToken, + sort?: SortInput[] +) => { + if (sort && sort.length > 0) { + const arraySort = []; + + for (const data of sort) { + const direction = + data.direction === "ASC" ? sql.fragment`ASC` : sql.fragment`DESC`; + + let roleFragment; + + if (data.key === "roles") { + roleFragment = sql.fragment`user_role.role ->> 0`; + } + + const sortIdentifier = sql.identifier([ + ...tableIdentifier.names, + humps.decamelize(data.key), + ]); + + arraySort.push( + sql.fragment`${roleFragment ?? sortIdentifier} ${direction}` + ); + } + + return sql.fragment`ORDER BY ${sql.join(arraySort, sql.fragment`,`)}`; + } + + return sql.fragment``; +}; + +const createSortRoleFragment = ( + identifier: IdentifierSqlToken, + sort?: SortInput[] +) => { + let direction = sql.fragment`ASC`; + + if (!Array.isArray(sort)) { + sort = []; + } + + sort.some((sortItem) => { + if (sortItem.key === "roles" && sortItem.direction != "ASC") { + direction = sql.fragment`DESC`; + + return true; + } + + return false; + }); + + return sql.fragment`ORDER BY ${identifier} ${direction}`; +}; + +export { createSortFragment, createSortRoleFragment }; diff --git a/packages/user/src/model/roles/sqlFactory.ts b/packages/user/src/model/roles/sqlFactory.ts new file mode 100644 index 000000000..4dbcc4b3a --- /dev/null +++ b/packages/user/src/model/roles/sqlFactory.ts @@ -0,0 +1,141 @@ +import { + DefaultSqlFactory, + createLimitFragment, + createFilterFragment, + createTableIdentifier, +} from "@dzangolab/fastify-slonik"; +import humps from "humps"; +import { QueryResultRow, QuerySqlToken, sql } from "slonik"; +import * as zod from "zod"; + +import { createSortFragment, createSortRoleFragment } from "./sql"; +import { TABLE_ROLE_PERMISSIONS } from "../../constants"; + +import type { Service } from "../../types/roles/service"; +import type { SqlFactory } from "../../types/roles/sqlFactory"; +import type { FilterInput, SortInput } from "@dzangolab/fastify-slonik"; + +/* eslint-disable brace-style */ +class RoleSqlFactory< + Role extends QueryResultRow, + RoleCreateInput extends QueryResultRow, + RoleUpdateInput extends QueryResultRow + > + extends DefaultSqlFactory + implements SqlFactory +{ + /* eslint-enabled */ + protected declare _service: Service; + + getAddRolePermissionSql = ( + roleId: number, + permissions: string[] + ): QuerySqlToken => { + const permissionsTable = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + return sql.type(this.validationSchema)` + INSERT INTO ${permissionsTable} ("role_id", "permission") + SELECT * + FROM ${sql.unnest( + [permissions.map((permission) => [roleId, permission])], + ["integer", "varchar"] + )} + RETURNING *; + `; + }; + + getPermissionsForRoleSql = (id: number): QuerySqlToken => { + const permissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + const filters: FilterInput = { + key: "rowId", + operator: "eq", + value: `${id}`, + }; + + return sql.unsafe` + SELECT * + FROM ${permissionsIdentifier} + ${createFilterFragment(filters, permissionsIdentifier)}; + + `; + }; + + getRolesSql = (): QuerySqlToken => { + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + return sql.unsafe` + SELECT + ${this.getTableFragment()}.*, + COALESCE(user_permissions.permission, '[]') AS permissions + FROM ${this.getTableFragment()} + LEFT JOIN LATERAL ( + SELECT jsonb_agg(rp.permission) AS permissions + FROM ${rolePermissionsIdentifier} as rp + WHERE rp.role_id = ${this.getTableFragment()}.id + ) AS role_permissions ON TRUE + `; + }; + + getFindByIdSql = (id: number | string): QuerySqlToken => { + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + return sql.unsafe` + SELECT + ${this.getTableFragment()}.*, + COALESCE(user_permissions.permission, '[]') AS permissions + FROM ${this.getTableFragment()} + LEFT JOIN LATERAL ( + SELECT jsonb_agg(rp.permission) AS permissions + FROM ${rolePermissionsIdentifier} as rp + WHERE rp.role_id = ${this.getTableFragment()}.id + ) AS role_permissions ON TRUE + WHERE id = ${id} + `; + }; + + getRemovePermissionsSql = ( + roleId: number, + permissions: string[] + ): QuerySqlToken => { + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + const filters: FilterInput = { + OR: permissions.map((permission) => { + return { + AND: [ + { key: "roleId", operator: "eq", value: roleId }, + { key: "permission", operator: "eq", value: permission }, + ], + }; + }), + } as FilterInput; + + return sql.unsafe` + DELETE FROM ${rolePermissionsIdentifier} + ${createFilterFragment(filters, rolePermissionsIdentifier)} + RETURNING *; + `; + }; + + get service() { + return this._service; + } +} + +export default RoleSqlFactory; diff --git a/packages/user/src/types/roles/index.ts b/packages/user/src/types/roles/index.ts new file mode 100644 index 000000000..90fa78117 --- /dev/null +++ b/packages/user/src/types/roles/index.ts @@ -0,0 +1,22 @@ +interface Role { + id: number; + role: string; + default: string; +} + +interface RoleWithPermissions extends Role { + permissions: string[]; +} + +interface RoleCreateInput { + role: string; + permissions: string[]; +} + +interface RolePermission { + id: string; + roleId: number; + permission: string; +} + +export type { Role, RoleCreateInput, RolePermission, RoleWithPermissions }; diff --git a/packages/user/src/types/roles/service.ts b/packages/user/src/types/roles/service.ts new file mode 100644 index 000000000..401204ae8 --- /dev/null +++ b/packages/user/src/types/roles/service.ts @@ -0,0 +1,56 @@ +import type { Role, RolePermission, RoleWithPermissions } from "."; +import type { ApiConfig } from "@dzangolab/fastify-config"; +import type { + Database, + FilterInput, + SortDirection, + SortInput, +} from "@dzangolab/fastify-slonik"; +import type { z } from "zod"; + +interface Service { + config: ApiConfig; + database: Database; + sortDirection: SortDirection; + sortKey: string; + schema: "public" | string; + table: string; + validationSchema: z.ZodTypeAny; + + all(fields: string[]): Promise>; + create(data: C): Promise; + delete(id: number | string): Promise; + findById(id: number | string): Promise; + getLimitDefault(): number; + getLimitMax(): number; + list( + limit?: number, + offset?: number, + filters?: FilterInput, + sort?: SortInput[] + ): Promise>; + count(filters?: FilterInput): Promise; + update(id: number | string, data: U): Promise; + addRolePermissions( + id: number, + permission: string[] + ): Promise; + getPermissionsForRole(id: number): Promise; + getRoles(): Promise; + updateRolePermissions( + roleId: number, + permission: string[] + ): Promise; + removePermissionsFromRole( + roleId: number, + permission: string[] + ): Promise; +} + +type PaginatedList = { + totalCount: number; + filteredCount: number; + data: readonly T[]; +}; + +export type { PaginatedList, Service }; diff --git a/packages/user/src/types/roles/sqlFactory.ts b/packages/user/src/types/roles/sqlFactory.ts new file mode 100644 index 000000000..2b1af6ff4 --- /dev/null +++ b/packages/user/src/types/roles/sqlFactory.ts @@ -0,0 +1,34 @@ +import type { Service } from "./service"; +import type { ApiConfig } from "@dzangolab/fastify-config"; +import type { FilterInput, SortInput } from "@dzangolab/fastify-slonik"; +import type { FragmentSqlToken, QueryResultRow, QuerySqlToken } from "slonik"; + +interface SqlFactory< + T extends QueryResultRow, + C extends QueryResultRow, + U extends QueryResultRow +> { + config: ApiConfig; + service: Service; + + getAllSql(fields: string[], sort?: SortInput[]): QuerySqlToken; + getCreateSql(data: C): QuerySqlToken; + getDeleteSql(id: number | string): QuerySqlToken; + getFindByIdSql(id: number | string): QuerySqlToken; + getListSql( + limit: number, + offset?: number, + filters?: FilterInput, + sort?: SortInput[] + ): QuerySqlToken; + getSortInput(sort?: SortInput[]): SortInput[]; + getTableFragment(): FragmentSqlToken; + getUpdateSql(id: number | string, data: U): QuerySqlToken; + getCountSql(filters?: FilterInput): QuerySqlToken; + getAddRolePermissionSql(roleId: number, permission: string[]): QuerySqlToken; + getPermissionsForRoleSql(id: number): QuerySqlToken; + getRolesSql(): QuerySqlToken; + getRemovePermissionsSql(roleId: number, permissions: string[]): QuerySqlToken; +} + +export type { SqlFactory };