diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index 2a0dd021c..d690cb2d6 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -20,7 +20,6 @@ const ROUTE_ME = "/me"; const ROUTE_USERS = "/users"; const ROUTE_USERS_DISABLE = "/users/:id/disable"; const ROUTE_USERS_ENABLE = "/users/:id/enable"; -const TABLE_USERS = "users"; // Roles const ROUTE_ROLES = "/roles"; @@ -29,6 +28,12 @@ const ROUTE_ROLES_PERMISSIONS = "/roles/permissions"; // Permissions const ROUTE_PERMISSIONS = "/permissions"; +// Tables +const TABLE_ROLES = "roles"; +const TABLE_USERS = "users"; +const TABLE_ROLE_PERMISSIONS = "role_permissions"; +const TABLE_USER_ROLES = "user_roles"; + // Email verification const EMAIL_VERIFICATION_MODE = "REQUIRED"; const EMAIL_VERIFICATION_PATH = "/verify-email"; @@ -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/index.ts b/packages/user/src/index.ts index eda895c2d..428be7e8c 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -103,7 +103,6 @@ export { default as invitationRoutes } from "./model/invitations/controller"; export { default as permissionResolver } from "./model/permissions/resolver"; export { default as permissionRoutes } from "./model/permissions/controller"; export { default as RoleService } from "./model/roles/service"; -export { default as roleResolver } from "./model/roles/resolver"; export { default as roleRoutes } from "./model/roles/controller"; // [DU 2023-AUG-07] use formatDate from "@dzangolab/fastify-slonik" package export { formatDate } from "@dzangolab/fastify-slonik"; @@ -118,6 +117,8 @@ export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; export { default as validateEmail } from "./validator/email"; export { default as validatePassword } from "./validator/password"; export { default as hasUserPermission } from "./lib/hasUserPermission"; +export { default as CustomApiError } from "./customApiError"; +export { emailSchema, passwordSchema, roleSchema } from "./schemas"; export * from "./constants"; @@ -127,6 +128,9 @@ export type { ThirdPartyEmailPasswordRecipe } from "./supertokens/types/thirdPar export type { AuthUser, ChangePasswordInput, + Role, + RoleCreateInput, + RoleUpdateInput, UserCreateInput, UserUpdateInput, User, diff --git a/packages/user/src/model/roles/controller.ts b/packages/user/src/model/roles/controller.ts index 5140ae553..1ffd1a413 100644 --- a/packages/user/src/model/roles/controller.ts +++ b/packages/user/src/model/roles/controller.ts @@ -1,5 +1,4 @@ import handlers from "./handlers"; -import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; import type { FastifyInstance } from "fastify"; @@ -8,44 +7,44 @@ const plugin = async ( options: unknown, done: () => void ) => { - fastify.delete( - ROUTE_ROLES, + fastify.get( + "/roles", { - preHandler: [fastify.verifySession()], + preHandler: fastify.verifySession(), }, - handlers.deleteRole + handlers.roles ); fastify.get( - ROUTE_ROLES, + "/roles/:id(^\\d+)", { - preHandler: [fastify.verifySession()], + preHandler: fastify.verifySession(), }, - handlers.getRoles + handlers.role ); - fastify.get( - ROUTE_ROLES_PERMISSIONS, + fastify.delete( + "/roles/:id(^\\d+)", { - preHandler: [fastify.verifySession()], + preHandler: fastify.verifySession(), }, - handlers.getPermissions + handlers.deleteRole ); fastify.post( - ROUTE_ROLES, + "/roles", { - preHandler: [fastify.verifySession()], + preHandler: fastify.verifySession(), }, - handlers.createRole + handlers.create ); fastify.put( - ROUTE_ROLES_PERMISSIONS, + "/roles/:id(^\\d+)", { - preHandler: [fastify.verifySession()], + preHandler: fastify.verifySession(), }, - handlers.updatePermissions + handlers.update ); done(); diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/create.ts similarity index 51% rename from packages/user/src/model/roles/handlers/updatePermissions.ts rename to packages/user/src/model/roles/handlers/create.ts index 2bb89c5c9..c6883f068 100644 --- a/packages/user/src/model/roles/handlers/updatePermissions.ts +++ b/packages/user/src/model/roles/handlers/create.ts @@ -1,28 +1,22 @@ import CustomApiError from "../../../customApiError"; -import RoleService from "../service"; +import Service from "../service"; +import type { Role, RoleCreateInput, RoleUpdateInput } from "../../../types"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; -const updatePermissions = async ( - request: SessionRequest, - reply: FastifyReply -) => { - const { log, body } = request; +const create = async (request: SessionRequest, reply: FastifyReply) => { + const service = new Service( + request.config, + request.slonik + ); + + const input = request.body as RoleCreateInput; try { - const { role, permissions } = body as { - role: string; - permissions: string[]; - }; - - const service = new RoleService(); - const updatedPermissionsResponse = await service.updateRolePermissions( - role, - permissions - ); - - return reply.send(updatedPermissionsResponse); + const data = await service.create(input); + + return reply.send(data); } catch (error) { if (error instanceof CustomApiError) { reply.status(error.statusCode); @@ -34,7 +28,7 @@ const updatePermissions = async ( }); } - log.error(error); + request.log.error(error); reply.status(500); return reply.send({ @@ -44,4 +38,4 @@ const updatePermissions = async ( } }; -export default updatePermissions; +export default create; diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/delete.ts similarity index 55% rename from packages/user/src/model/roles/handlers/createRole.ts rename to packages/user/src/model/roles/handlers/delete.ts index 78ccfe45b..a7bc19abc 100644 --- a/packages/user/src/model/roles/handlers/createRole.ts +++ b/packages/user/src/model/roles/handlers/delete.ts @@ -1,23 +1,22 @@ import CustomApiError from "../../../customApiError"; -import RoleService from "../service"; +import Service from "../service"; +import type { Role, RoleCreateInput, RoleUpdateInput } from "../../../types"; 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 deleteRole = async (request: SessionRequest, reply: FastifyReply) => { + const service = new Service( + request.config, + request.slonik + ); - const { role, permissions } = body as { - role: string; - permissions: string[]; - }; + const { id } = request.params as { id: number }; try { - const service = new RoleService(); + const data = await service.delete(id); - const createResponse = await service.createRole(role, permissions); - - return reply.send(createResponse); + reply.send(data); } catch (error) { if (error instanceof CustomApiError) { reply.status(error.statusCode); @@ -29,7 +28,7 @@ const createRole = async (request: SessionRequest, reply: FastifyReply) => { }); } - log.error(error); + request.log.error(error); reply.status(500); return reply.send({ @@ -39,4 +38,4 @@ const createRole = async (request: SessionRequest, reply: FastifyReply) => { } }; -export default createRole; +export default deleteRole; diff --git a/packages/user/src/model/roles/handlers/deleteRole.ts b/packages/user/src/model/roles/handlers/deleteRole.ts deleted file mode 100644 index 79a137523..000000000 --- a/packages/user/src/model/roles/handlers/deleteRole.ts +++ /dev/null @@ -1,61 +0,0 @@ -import CustomApiError from "../../../customApiError"; -import RoleService from "../service"; - -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { - const { log, query } = request; - - try { - let { role } = query as { role?: string }; - - if (role) { - try { - role = JSON.parse(role) as string; - } catch { - /* empty */ - } - - if (typeof role != "string") { - throw new CustomApiError({ - name: "UNKNOWN_ROLE_ERROR", - message: `Invalid role`, - statusCode: 422, - }); - } - - const service = new RoleService(); - - const deleteResponse = await service.deleteRole(role); - - return reply.send(deleteResponse); - } - - throw new CustomApiError({ - name: "UNKNOWN_ROLE_ERROR", - message: `Invalid role`, - statusCode: 422, - }); - } catch (error) { - if (error instanceof CustomApiError) { - reply.status(error.statusCode); - - return reply.send({ - message: error.message, - name: error.name, - statusCode: error.statusCode, - }); - } - - log.error(error); - reply.status(500); - - return reply.send({ - status: "ERROR", - message: "Oops! Something went wrong", - }); - } -}; - -export default deleteRole; diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts deleted file mode 100644 index dcda6ae2f..000000000 --- a/packages/user/src/model/roles/handlers/getPermissions.ts +++ /dev/null @@ -1,41 +0,0 @@ -import RoleService from "../service"; - -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[] = []; - - try { - let { role } = query as { role?: string }; - - if (role) { - try { - role = JSON.parse(role) as string; - } catch { - /* empty */ - } - - if (typeof role != "string") { - return reply.send({ permissions }); - } - - const service = new RoleService(); - - permissions = await service.getPermissionsForRole(role); - } - - return reply.send({ permissions }); - } catch (error) { - log.error(error); - reply.status(500); - - return reply.send({ - status: "ERROR", - message: "Oops! Something went wrong", - }); - } -}; - -export default getPermissions; diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts deleted file mode 100644 index 7429c097d..000000000 --- a/packages/user/src/model/roles/handlers/getRoles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import RoleService from "../service"; - -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -const getRoles = async (request: SessionRequest, reply: FastifyReply) => { - const { log } = request; - - try { - const service = new RoleService(); - const roles = await service.getRoles(); - - return reply.send({ roles }); - } catch (error) { - log.error(error); - reply.status(500); - - return reply.send({ - status: "ERROR", - message: "Oops! Something went wrong", - }); - } -}; - -export default getRoles; diff --git a/packages/user/src/model/roles/handlers/index.ts b/packages/user/src/model/roles/handlers/index.ts index 9176aedec..d6ee174a0 100644 --- a/packages/user/src/model/roles/handlers/index.ts +++ b/packages/user/src/model/roles/handlers/index.ts @@ -1,13 +1,13 @@ -import createRole from "./createRole"; -import deleteRole from "./deleteRole"; -import getPermissions from "./getPermissions"; -import getRoles from "./getRoles"; -import updatePermissions from "./updatePermissions"; +import create from "./create"; +import deleteRole from "./delete"; +import role from "./role"; +import roles from "./roles"; +import update from "./update"; export default { + create, deleteRole, - createRole, - getRoles, - getPermissions, - updatePermissions, + role, + roles, + update, }; diff --git a/packages/user/src/model/roles/handlers/role.ts b/packages/user/src/model/roles/handlers/role.ts new file mode 100644 index 000000000..bda2933f5 --- /dev/null +++ b/packages/user/src/model/roles/handlers/role.ts @@ -0,0 +1,20 @@ +import Service from "../service"; + +import type { Role, RoleCreateInput, RoleUpdateInput } from "../../../types"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const role = async (request: SessionRequest, reply: FastifyReply) => { + const service = new Service( + request.config, + request.slonik + ); + + const { id } = request.params as { id: number }; + + const data = await service.findById(id); + + reply.send(data); +}; + +export default role; diff --git a/packages/user/src/model/roles/handlers/roles.ts b/packages/user/src/model/roles/handlers/roles.ts new file mode 100644 index 000000000..d5afa6080 --- /dev/null +++ b/packages/user/src/model/roles/handlers/roles.ts @@ -0,0 +1,32 @@ +import Service from "../service"; + +import type { Role, RoleCreateInput, RoleUpdateInput } from "../../../types"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const roles = async (request: SessionRequest, reply: FastifyReply) => { + const { config, query, slonik } = request; + + const service = new Service( + config, + slonik + ); + + const { limit, offset, filters, sort } = query as { + limit: number; + offset?: number; + filters?: string; + sort?: string; + }; + + const data = await service.list( + limit, + offset, + filters ? JSON.parse(filters) : undefined, + sort ? JSON.parse(sort) : undefined + ); + + reply.send(data); +}; + +export default roles; diff --git a/packages/user/src/model/roles/handlers/update.ts b/packages/user/src/model/roles/handlers/update.ts new file mode 100644 index 000000000..f587dbdbe --- /dev/null +++ b/packages/user/src/model/roles/handlers/update.ts @@ -0,0 +1,43 @@ +import CustomApiError from "../../../customApiError"; +import Service from "../service"; + +import type { Role, RoleCreateInput, RoleUpdateInput } from "../../../types"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const update = async (request: SessionRequest, reply: FastifyReply) => { + const service = new Service( + request.config, + request.slonik + ); + + const { id } = request.params as { id: number }; + + const input = request.body as RoleUpdateInput; + + try { + const data = await service.update(id, input); + + return reply.send(data); + } catch (error) { + if (error instanceof CustomApiError) { + reply.status(error.statusCode); + + return reply.send({ + message: error.message, + name: error.name, + statusCode: error.statusCode, + }); + } + + request.log.error(error); + reply.status(500); + + return reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default update; diff --git a/packages/user/src/model/roles/resolver.ts b/packages/user/src/model/roles/resolver.ts deleted file mode 100644 index 491136be7..000000000 --- a/packages/user/src/model/roles/resolver.ts +++ /dev/null @@ -1,186 +0,0 @@ -import mercurius from "mercurius"; - -import RoleService from "./service"; -import CustomApiError from "../../customApiError"; - -import type { MercuriusContext } from "mercurius"; - -const Mutation = { - createRole: async ( - parent: unknown, - arguments_: { - role: string; - permissions: string[]; - }, - context: MercuriusContext - ) => { - const { app } = context; - - try { - const service = new RoleService(); - - const createResponse = await service.createRole( - arguments_.role, - arguments_.permissions - ); - - return createResponse; - } catch (error) { - if (error instanceof CustomApiError) { - const mercuriusError = new mercurius.ErrorWithProps(error.name); - - mercuriusError.statusCode = error.statusCode; - - return mercuriusError; - } - - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong" - ); - - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, - - deleteRole: async ( - parent: unknown, - arguments_: { - role: string; - }, - context: MercuriusContext - ) => { - const { app } = context; - - try { - const service = new RoleService(); - - const { role } = arguments_; - - const deleteResponse = await service.deleteRole(role); - - return deleteResponse; - } catch (error) { - if (error instanceof CustomApiError) { - const mercuriusError = new mercurius.ErrorWithProps(error.name); - - mercuriusError.statusCode = error.statusCode; - - return mercuriusError; - } - - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong" - ); - - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, - - updateRolePermissions: async ( - parent: unknown, - arguments_: { - role: string; - permissions: string[]; - }, - context: MercuriusContext - ) => { - const { app } = context; - const { permissions, role } = arguments_; - - try { - const service = new RoleService(); - const updatedPermissionsResponse = await service.updateRolePermissions( - role, - permissions - ); - - return updatedPermissionsResponse; - } catch (error) { - if (error instanceof CustomApiError) { - const mercuriusError = new mercurius.ErrorWithProps(error.name); - - mercuriusError.statusCode = error.statusCode; - - return mercuriusError; - } - - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong" - ); - - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, -}; - -const Query = { - roles: async ( - parent: unknown, - arguments_: Record, - context: MercuriusContext - ) => { - const { app } = context; - - try { - const service = new RoleService(); - const roles = await service.getRoles(); - - return roles; - } catch (error) { - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong" - ); - - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, - rolePermissions: async ( - parent: unknown, - arguments_: { - role: string; - }, - context: MercuriusContext - ) => { - const { app } = context; - const { role } = arguments_; - let permissions: string[] = []; - - try { - if (role) { - const service = new RoleService(); - - permissions = await service.getPermissionsForRole(role); - } - - return permissions; - } catch (error) { - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong" - ); - - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, -}; - -export default { Mutation, Query }; diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts index 3b9de3ba1..2bd5db4c7 100644 --- a/packages/user/src/model/roles/service.ts +++ b/packages/user/src/model/roles/service.ts @@ -1,15 +1,55 @@ -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"; +import { roleSchema } from "../../schemas"; + +import type { + FilterInput, + Service, + SortInput, +} from "@dzangolab/fastify-slonik"; +import type { QueryResultRow } from "slonik"; + +/* eslint-disable brace-style */ +class RoleService< + Role extends QueryResultRow, + RoleCreateInput extends QueryResultRow, + RoleUpdateInput extends QueryResultRow + > + extends BaseService + implements Service +{ + /* eslint-enabled */ + static readonly TABLE = TABLE_ROLES; + + protected _validationSchema = roleSchema; + + all = async ( + fields: string[], + sort?: SortInput[], + filterInput?: FilterInput + ): Promise> => { + const query = this.factory.getAllSql(fields, sort, filterInput); + + const result = await this.database.connect((connection) => { + return connection.any(query); + }); + + return result as Partial; + }; + + create = async (data: RoleCreateInput) => { + const { permissions, ...dataInput } = data; -class RoleService { - createRole = async ( - role: string, - permissions?: string[] - ): Promise<{ status: "OK" }> => { - const { roles } = await UserRoles.getAllRoles(role); + const roleCount = await this.count({ + key: "role", + operator: "eq", + value: data.role as string, + }); - if (roles.includes(role)) { + if (roleCount !== 0) { throw new CustomApiError({ name: "ROLE_ALREADY_EXISTS", message: "Unable to create role as it already exists", @@ -17,26 +57,47 @@ class RoleService { }); } - const createRoleResponse = await UserRoles.createNewRoleOrAddPermissions( - role, - permissions || [] - ); + const query = this.factory.getCreateSql(dataInput as RoleCreateInput); - return { status: createRoleResponse.status }; + const result = (await this.database.connect(async (connection) => { + return connection.query(query).then((data) => { + return data.rows[0]; + }); + })) as Role; + + if (permissions) { + try { + await this.setRolePermissions( + result.id as number, + permissions as string[] + ); + } catch (error) { + await this.delete(result.id as number); + + throw error; + } + } + + return { + ...result, + permissions, + }; }; - deleteRole = async (role: string): Promise<{ status: "OK" }> => { - const response = await UserRoles.getUsersThatHaveRole(role); + delete = async (id: number | string): Promise => { + const role = await this.findById(id); - if (response.status === "UNKNOWN_ROLE_ERROR") { + if (!role) { throw new CustomApiError({ - name: response.status, + name: "UNKNOWN_ROLE_ERROR", message: `Invalid role`, statusCode: 422, }); } - if (response.users.length > 0) { + const isRoleAssigned = await this.isRoleAssigned(id); + + if (isRoleAssigned) { throw new CustomApiError({ name: "ROLE_IN_USE", message: @@ -45,52 +106,47 @@ class RoleService { }); } - const deleteRoleResponse = await UserRoles.deleteRole(role); + const query = this.factory.getDeleteSql(id); - return { status: deleteRoleResponse.status }; - }; + await this.database.connect((connection) => { + return connection.one(query); + }); - getPermissionsForRole = async (role: string): Promise => { - let permissions: string[] = []; + return role; + }; - const response = await UserRoles.getPermissionsForRole(role); + isRoleAssigned = async (id: number | string): Promise => { + const query = this.factory.getIsRoleAssignedSql(id); - if (response.status === "OK") { - permissions = response.permissions; - } + const result = await this.database.connect((connection) => { + return connection.one(query).then((columns) => columns.isRoleAssigned); + }); - return permissions; + return result as boolean; }; - getRoles = async (): Promise<{ role: string; permissions: string[] }[]> => { - let roles: { role: string; permissions: string[] }[] = []; - - const response = await UserRoles.getAllRoles(); - - 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); + setRolePermissions = async ( + roleId: string | number, + permissions: string[] + ) => { + const query = this.factory.getSetRolePermissionsSql(roleId, permissions); - return { - role, - permissions: response.status === "OK" ? response.permissions : [], - }; - }) - ); - } + await this.database.connect((connection) => { + return connection.any(query); + }); - return roles; + return { roleId, permissions }; }; - updateRolePermissions = async ( - role: string, - permissions: string[] - ): Promise<{ status: "OK"; permissions: string[] }> => { - const response = await UserRoles.getPermissionsForRole(role); + update = async ( + id: number | string, + data: RoleUpdateInput + ): Promise => { + const { permissions, ...dataInput } = data; - if (response.status === "UNKNOWN_ROLE_ERROR") { + const role = await this.findById(id); + + if (!role) { throw new CustomApiError({ name: "UNKNOWN_ROLE_ERROR", message: `Invalid role`, @@ -98,26 +154,50 @@ class RoleService { }); } - const rolePermissions = response.permissions; - - const newPermissions = permissions.filter( - (permission) => !rolePermissions.includes(permission) - ); + const result = await this.database.connect(async (connection) => { + return connection.transaction(async (transactionConnection) => { + await transactionConnection.query( + this.factory.getUpdateSql(id, dataInput as RoleUpdateInput) + ); + + if (permissions) { + await transactionConnection.query( + this.factory.getSetRolePermissionsSql(id, []) + ); + + await transactionConnection.query( + this.factory.getSetRolePermissionsSql(id, permissions as string[]) + ); + } + + return await transactionConnection.query( + this.factory.getFindByIdSql(id) + ); + }); + }); - const removedPermissions = rolePermissions.filter( - (permission) => !permissions.includes(permission) - ); + return result.rows[0] as Role; + }; - await UserRoles.removePermissionsFromRole(role, removedPermissions); - await UserRoles.createNewRoleOrAddPermissions(role, newPermissions); + get factory() { + if (!this.table) { + throw new Error(`Service table is not defined`); + } - const permissionsResponse = await this.getPermissionsForRole(role); + if (!this._factory) { + this._factory = new RoleSqlFactory< + Role, + RoleCreateInput, + RoleUpdateInput + >(this); + } - return { - status: "OK", - permissions: permissionsResponse, - }; - }; + return this._factory as RoleSqlFactory< + Role, + RoleCreateInput, + RoleUpdateInput + >; + } } export default RoleService; diff --git a/packages/user/src/model/roles/sqlFactory.ts b/packages/user/src/model/roles/sqlFactory.ts new file mode 100644 index 000000000..36a21174e --- /dev/null +++ b/packages/user/src/model/roles/sqlFactory.ts @@ -0,0 +1,176 @@ +import { + DefaultSqlFactory, + createFilterFragment, + createLimitFragment, + createSortFragment, + createTableIdentifier, +} from "@dzangolab/fastify-slonik"; +import humps from "humps"; +import { sql } from "slonik"; +import { z } from "zod"; + +import { TABLE_ROLE_PERMISSIONS, TABLE_USER_ROLES } from "../../constants"; + +import type { + FilterInput, + SortInput, + SqlFactory, +} from "@dzangolab/fastify-slonik"; +import type { QuerySqlToken, QueryResultRow } from "slonik"; + +/* eslint-disable brace-style */ +class RoleSqlFactory< + Role extends QueryResultRow, + RoleCreateInput extends QueryResultRow, + RoleUpdateInput extends QueryResultRow + > + extends DefaultSqlFactory + implements SqlFactory +{ + /* eslint-enabled */ + getAllSql = ( + fields: string[], + sort?: SortInput[], + filters?: FilterInput + ): QuerySqlToken => { + const identifiers = []; + + const fieldsObject: Record = {}; + + for (const field of fields) { + if (field != "permissions") { + identifiers.push(sql.identifier([humps.decamelize(field)])); + fieldsObject[humps.camelize(field)] = true; + } + } + + const tableIdentifier = createTableIdentifier(this.table, this.schema); + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + const permissionsFragment = fields.includes("permissions") + ? sql.fragment`, + COALESCE(role_permissions.permissions, '[]') AS permissions + ` + : sql.fragment``; + + const allSchema = + this.validationSchema._def.typeName === "ZodObject" + ? (this.validationSchema as z.AnyZodObject).pick(fieldsObject) + : z.any(); + + return sql.type(allSchema)` + SELECT + ${sql.join(identifiers, sql.fragment`, `)} + ${permissionsFragment} + 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 + ${createFilterFragment(filters, tableIdentifier)} + ${createSortFragment(tableIdentifier, this.getSortInput(sort))} + `; + }; + + getListSql = ( + limit: number, + offset?: number, + filters?: FilterInput, + sort?: SortInput[] + ): QuerySqlToken => { + const tableIdentifier = createTableIdentifier(this.table, this.schema); + + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + return sql.type(this.validationSchema)` + SELECT + ${this.getTableFragment()}.*, + COALESCE(role_permissions.permissions, '[]') 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 + ${createFilterFragment(filters, tableIdentifier)} + ${createSortFragment(tableIdentifier, this.getSortInput(sort))} + ${createLimitFragment(limit, offset)}; + `; + }; + + getFindByIdSql = (id: number | string): QuerySqlToken => { + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + return sql.type(this.validationSchema)` + SELECT + ${this.getTableFragment()}.*, + COALESCE(role_permissions.permissions, '[]') 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}; + `; + }; + + getIsRoleAssignedSql = (id: number | string) => { + const userRolesTableIdentifier = createTableIdentifier( + TABLE_USER_ROLES, + this.schema + ); + + const schema = z.object({ + isRoleAssigned: z.boolean(), + }); + + return sql.type(schema)` + SELECT EXISTS ( + SELECT * + FROM ${userRolesTableIdentifier} + WHERE role_id = ${id} + ) AS is_role_assigned; + `; + }; + + getSetRolePermissionsSql = ( + roleId: string | number, + permissions: string[] + ) => { + const rolePermissionsIdentifier = createTableIdentifier( + TABLE_ROLE_PERMISSIONS, + this.schema + ); + + if (permissions.length === 0) { + return sql.unsafe` + DELETE FROM ${rolePermissionsIdentifier} + WHERE role_id = ${roleId}; + `; + } + + return sql.unsafe` + INSERT INTO ${rolePermissionsIdentifier} ("role_id", "permission") + SELECT * + FROM ${sql.unnest( + permissions.map((permission) => { + return [roleId, permission]; + }), + ["int4", "varchar"] + )} ON CONFLICT DO NOTHING; + `; + }; +} + +export default RoleSqlFactory; diff --git a/packages/user/src/schemas/index.ts b/packages/user/src/schemas/index.ts index b9a901b08..4b757b435 100644 --- a/packages/user/src/schemas/index.ts +++ b/packages/user/src/schemas/index.ts @@ -1,3 +1,5 @@ export { default as emailSchema } from "./email"; export { default as passwordSchema } from "./password"; + +export { default as roleSchema } from "./roles"; diff --git a/packages/user/src/schemas/roles.ts b/packages/user/src/schemas/roles.ts new file mode 100644 index 000000000..2f9ccba55 --- /dev/null +++ b/packages/user/src/schemas/roles.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +const roleSchema = z.object({ + id: z.number(), + role: z.string().max(255), + permissions: z.optional(z.array(z.string().max(255))), + default: z.boolean(), +}); + +export default roleSchema; diff --git a/packages/user/src/types/index.ts b/packages/user/src/types/index.ts index 88833db90..8103cc06a 100644 --- a/packages/user/src/types/index.ts +++ b/packages/user/src/types/index.ts @@ -67,3 +67,5 @@ export type { export type { IsEmailOptions } from "./isEmailOptions"; export type { StrongPasswordOptions } from "./strongPasswordOptions"; + +export type { Role, RoleCreateInput, RoleUpdateInput } from "./roles"; diff --git a/packages/user/src/types/roles.ts b/packages/user/src/types/roles.ts new file mode 100644 index 000000000..cbf5260b2 --- /dev/null +++ b/packages/user/src/types/roles.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { roleSchema } from "../schemas"; + +type Role = z.infer; + +type RoleCreateInput = Omit; + +type RoleUpdateInput = Partial>; + +export type { Role, RoleCreateInput, RoleUpdateInput };