diff --git a/.env.example b/.env.example index acae095..611e74b 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,3 @@ DB_PORT=27017 DB_USERNAME=test DB_PASSWORD=test DB_NAME=admin - -# Other -PUBLIC_ACCESS_SECRET_KEY= | - -----BEGIN PUBLIC KEY----- - MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCLW1tlHyKC9AG0hGpmkksET2DE - r7ojSPemxFWAgFgcPJWQ7x3uNbsdJ3bIZFoA/FClaWKMCZmjnH9tv0bKZtY/CDhM - ZEyHpMruRSn6IKrxjtQZWy4uv/w6MzUeyBYG0OvNCiYpdvz5SkAGAUHD5ZNFqn2w - KKFD0I2Dr59BFVSGJwIDAQAB - -----END PUBLIC KEY----- \ No newline at end of file diff --git a/deployments/dev/docker-compose.yaml b/deployments/dev/docker-compose.yaml index 1770fa0..313f985 100644 --- a/deployments/dev/docker-compose.yaml +++ b/deployments/dev/docker-compose.yaml @@ -2,37 +2,38 @@ version: "3.3" services: admin: + container_name: hub-admin-backend-service + tty: true build: context: ../../. dockerfile: Dockerfile + env_file: + - .env environment: - - DB_HOST=10.6.0.11 + - DB_HOST=admin-mongo - DB_PORT=27017 - ENVIRONMENT=staging - HTTP_HOST=0.0.0.0 - HTTP_PORT=8005 - - AUTH_SERVICE_HOST=http://pena-auth-service + - AUTH_SERVICE_HOST=http://auth - AUTH_SERVICE_PORT=8000 - DB_USERNAME=test - DB_PASSWORD=test - DB_NAME=admin - - PUBLIC_ACCESS_SECRET_KEY= | - -----BEGIN PUBLIC KEY----- - MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCLW1tlHyKC9AG0hGpmkksET2DE - r7ojSPemxFWAgFgcPJWQ7x3uNbsdJ3bIZFoA/FClaWKMCZmjnH9tv0bKZtY/CDhM - ZEyHpMruRSn6IKrxjtQZWy4uv/w6MzUeyBYG0OvNCiYpdvz5SkAGAUHD5ZNFqn2w - KKFD0I2Dr59BFVSGJwIDAQAB - -----END PUBLIC KEY----- networks: - dev + depends_on: + - admin-mongo + ports: + - 8005:8005 - mongo: + admin-mongo: image: "mongo:6.0.3" environment: MONGO_INITDB_ROOT_USERNAME: test MONGO_INITDB_ROOT_PASSWORD: test ports: - - "27017:27017" + - 27017:27017 networks: - dev diff --git a/package.json b/package.json index 11d76c9..806c1b5 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "test": "jest --coverage", "test:watch": "jest --watch", "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", - "compose:start:dev": "docker-compose -f deployments/dev/docker-compose.yaml up -d", - "compose:stop:dev": "docker-compose -f deployments/dev/docker-compose.yaml down --volumes --rmi local", + "compose:dev:start": "docker-compose -f deployments/dev/docker-compose.yaml up -d", + "compose:dev:stop": "docker-compose -f deployments/dev/docker-compose.yaml down --volumes --rmi local", "code:check": "prettier --check \"src/**/*.{ts,tsx,js,css,scss,html}\"", "code:format": "prettier --write \"src/**/*.{ts,tsx,js,css,scss,html}\"", "code:format:specific-file": "prettier --write", @@ -25,6 +25,8 @@ "@fastify/cookie": "^8.3.0", "@fastify/cors": "^8.2.0", "@fastify/jwt": "^6.3.3", + "@fastify/swagger": "^8.2.1", + "@fastify/swagger-ui": "^1.3.0", "axios": "^1.2.1", "bcryptjs": "^2.4.3", "dotenv": "^16.0.3", @@ -33,11 +35,9 @@ "fastify-print-routes": "^2.0.6", "jsonwebtoken": "^8.5.1", "mongoose": "^6.7.2", + "nodemon": "^2.0.20", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.0", - "nodemon": "^2.0.20", - "@fastify/swagger": "^8.2.1", - "@fastify/swagger-ui": "^1.3.0", "typescript-transform-paths": "^3.4.4" }, "devDependencies": { @@ -57,6 +57,7 @@ "eslint-plugin-prettier": "^4.2.1", "husky": "^7.0.4", "jest": "^29.3.1", + "jest-mock-extended": "^3.0.4", "nodemon": "^2.0.20", "prettier": "^2.7.1", "ts-jest": "^29.0.3", diff --git a/src/configuration/combine-routes.ts b/src/configuration/combine-routes.ts index 7d242f6..4533a8b 100644 --- a/src/configuration/combine-routes.ts +++ b/src/configuration/combine-routes.ts @@ -4,10 +4,12 @@ import { setAccountRoutes } from "@/routes/account.routes"; import { setPrivilegeRoutes } from "@/routes/privilege.routes"; import { setRoleRoutes } from "@/routes/role.routes"; import { setTariffRoutes } from "@/routes/tariff.routes"; +import { setPermissionRoutes } from "@/routes/permission.routes"; export const combineRoutes = (router: Router): void => { router.group("/role", setRoleRoutes); router.group("/account", setAccountRoutes); router.group("/privilege", setPrivilegeRoutes); router.group("/tariff", setTariffRoutes); + router.group("/permission", setPermissionRoutes); }; diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 0000000..a07394d --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,2 @@ +export const ERROR_NOT_FOUND = new Error("record not found"); +export const ERROR_INVALID_PARAMS = new Error("invalid params"); diff --git a/src/constants/http-statuses.ts b/src/constants/http-statuses.ts new file mode 100644 index 0000000..40beb45 --- /dev/null +++ b/src/constants/http-statuses.ts @@ -0,0 +1,6 @@ +import { ERROR_INVALID_PARAMS, ERROR_NOT_FOUND } from "./errors"; + +export const HTTP_STATUSES = new Map([ + [ERROR_NOT_FOUND, 404], + [ERROR_INVALID_PARAMS, 400], +]); diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts new file mode 100644 index 0000000..3c8398f --- /dev/null +++ b/src/constants/permissions.ts @@ -0,0 +1,15 @@ +import type { Permission } from "@/types/models/permission.type"; + +const DELETE_ACCOUNT_PERMISSION: Permission = { + name: "hub-admin-backend-service.deleteAccount", + description: "Разрешение на удаление чужого аккаунта пользователем", + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, +}; + +export const PERMISSIONS = { + deleteAccount: DELETE_ACCOUNT_PERMISSION.name, +} as const; + +export const PERMISSION_LIST: Array = [DELETE_ACCOUNT_PERMISSION]; diff --git a/src/handlers/account/helpers.test.ts b/src/handlers/account/helpers.test.ts new file mode 100644 index 0000000..a1224f5 --- /dev/null +++ b/src/handlers/account/helpers.test.ts @@ -0,0 +1,45 @@ +import { determinePaginationParameters } from "./helpers"; + +import type { PaginationParams } from "./types"; + +describe("determinePaginationParameters", () => { + const testCases: Array<{ + input: PaginationParams; + result: ReturnType; + }> = [ + { + input: { + page: 1, + limit: 2, + }, + result: { + page: 1, + limit: 2, + }, + }, + { + input: { + page: -100, + limit: 200, + }, + result: { + page: 1, + limit: 100, + }, + }, + { + input: { + page: 6, + limit: -100, + }, + result: { + page: 6, + limit: 100, + }, + }, + ]; + + test.each(testCases)("Успешное определение значений пагинации %j", ({ input, result }) => { + expect(determinePaginationParameters(input)).toEqual(result); + }); +}); diff --git a/src/handlers/account/helpers.ts b/src/handlers/account/helpers.ts new file mode 100644 index 0000000..9590b6f --- /dev/null +++ b/src/handlers/account/helpers.ts @@ -0,0 +1,18 @@ +import type { ObjectWithRequiredFields } from "@/types/object-with-required-fields"; +import type { PaginationParams } from "./types"; + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 100; + +export const determinePaginationParameters = ({ + page: optionalPage, + limit: optionalLimit, +}: PaginationParams): ObjectWithRequiredFields => { + const page = !optionalPage || optionalPage < 1 ? DEFAULT_PAGE : optionalPage; + const limit = !optionalLimit || optionalLimit < 1 ? DEFAULT_LIMIT : optionalLimit; + + return { + page, + limit: limit > DEFAULT_LIMIT ? DEFAULT_LIMIT : limit, + }; +}; diff --git a/src/handlers/account/index.ts b/src/handlers/account/index.ts index 56f9a3f..6b11672 100644 --- a/src/handlers/account/index.ts +++ b/src/handlers/account/index.ts @@ -2,14 +2,28 @@ import { Types } from "mongoose"; import { AccountModel } from "@/models/account.model"; import { RoleModel } from "@/models/role.model"; +import { PermissionModule } from "@/services/permission/permission.module"; import { getUser } from "@/clients/auth"; import { validateEmptyFields } from "@/utils/validate-empty-fields"; +import { determinePaginationParameters } from "./helpers"; import type { FastifyReply, FastifyRequest } from "fastify"; -import type { GetAccountRequest, SetAccountRoleRequest } from "./types"; +import type { Account } from "@/types/models/account.type"; +import type { GetAccountRequest, SetAccountRoleRequest, GetAccountsRequest, GetAccountsResponse } from "./types"; -export const getAllAccounts = async () => AccountModel.find({}).lean(); +export const getAccounts = async (request: GetAccountsRequest): Promise => { + const { page, limit } = determinePaginationParameters(request?.query || {}); + + const accountsCount = await AccountModel.countDocuments(); + + const totalPages = Math.ceil(accountsCount / limit); + const offset = (page - 1) * limit; + + const accounts = await AccountModel.find({}).sort({ createdAt: "desc" }).skip(offset).limit(limit).lean(); + + return { accounts, totalPages }; +}; export const createAccount = async (request: FastifyRequest, reply: FastifyReply) => { if (!Types.ObjectId.isValid(request.user.id)) { @@ -38,17 +52,17 @@ export const createAccount = async (request: FastifyRequest, reply: FastifyReply return createdAccount.save(); }; -export const getAccountByID = async (request: GetAccountRequest, reply: FastifyReply) => { +export const getAccountByID = async (request: GetAccountRequest, reply: FastifyReply): Promise => { const [getAccountRequestParams, error] = validateEmptyFields(request.params || {}, ["userId"]); if (error) { reply.status(400); - return error; + throw error; } if (!Types.ObjectId.isValid(getAccountRequestParams.userId)) { reply.status(400); - return new Error("invalid user id"); + throw new Error("invalid user id"); } const account = await AccountModel.findOne({ userId: getAccountRequestParams.userId }).lean(); @@ -61,10 +75,10 @@ export const getAccountByID = async (request: GetAccountRequest, reply: FastifyR return account; }; -export const getAccount = async (request: GetAccountRequest, reply: FastifyReply) => { +export const getAccount = async (request: GetAccountRequest, reply: FastifyReply): Promise => { if (!Types.ObjectId.isValid(request.user.id)) { reply.status(400); - return new Error("invalid user id"); + throw new Error("invalid user id"); } const account = await AccountModel.findOne({ userId: request.user.id }).lean(); @@ -105,10 +119,10 @@ export const setAccountRole = async (request: SetAccountRoleRequest, reply: Fast return account; }; -export const removeAccount = async (request: FastifyRequest, reply: FastifyReply) => { +export const removeAccount = async (request: FastifyRequest, reply: FastifyReply): Promise => { if (!Types.ObjectId.isValid(request.user.id)) { reply.status(400); - return new Error("invalid user id"); + throw new Error("invalid user id"); } const account = await AccountModel.findOneAndUpdate( @@ -118,32 +132,92 @@ export const removeAccount = async (request: FastifyRequest, reply: FastifyReply if (!account) { reply.status(404); - return new Error("account not found"); + throw new Error("account not found"); } return account; }; -export const deleteAccount = async (request: FastifyRequest, reply: FastifyReply) => { +export const removeAccountById = async (request: GetAccountRequest, reply: FastifyReply): Promise => { + const [{ userId }, error] = validateEmptyFields(request.params || {}, ["userId"]); + + if (error) { + reply.status(400); + throw error; + } + + if (!Types.ObjectId.isValid(userId)) { + reply.status(400); + throw new Error("invalid user id"); + } + + const account = await AccountModel.findOne({ userId: request.user.id }); + + if (!account) { + reply.status(404); + throw new Error("account not found"); + } + + const isAvailableToDelete = await PermissionModule.deleteAccount(account); + + if (!isAvailableToDelete) { + throw new Error("the user doesn't have the permission to delete account"); + } + + await account.updateOne({ $set: { isDeleted: true, deletedAt: new Date() } }); + + return account; +}; + +export const deleteAccount = async (request: FastifyRequest, reply: FastifyReply): Promise => { if (!Types.ObjectId.isValid(request.user.id)) { reply.status(400); - return new Error("invalid user id"); + throw new Error("invalid user id"); } const account = await AccountModel.findByIdAndDelete({ userId: request.user.id }).lean(); if (!account) { reply.status(404); - return new Error("account not found"); + throw new Error("account not found"); } return account; }; -export const restoreAccount = async (request: FastifyRequest, reply: FastifyReply) => { +export const deleteAccountById = async (request: GetAccountRequest, reply: FastifyReply): Promise => { + const [{ userId }, error] = validateEmptyFields(request.params || {}, ["userId"]); + + if (error) { + reply.status(400); + throw error; + } + + if (!Types.ObjectId.isValid(userId)) { + reply.status(400); + throw new Error("invalid user id"); + } + + const account = await AccountModel.findByIdAndDelete({ userId: request.user.id }).lean(); + + if (!account) { + reply.status(404); + throw new Error("account not found"); + } + + const isAvailableToDelete = await PermissionModule.deleteAccount(account); + + if (!isAvailableToDelete) { + throw new Error("the user doesn't have the permission to delete account"); + } + + return account; +}; + +export const restoreAccount = async (request: FastifyRequest, reply: FastifyReply): Promise => { if (!Types.ObjectId.isValid(request.user.id)) { reply.status(400); - return new Error("invalid user id"); + throw new Error("invalid user id"); } const account = await AccountModel.findOneAndUpdate( @@ -153,7 +227,7 @@ export const restoreAccount = async (request: FastifyRequest, reply: FastifyRepl if (!account) { reply.status(404); - return new Error("account not found"); + throw new Error("account not found"); } return account; diff --git a/src/handlers/account/middleware.ts b/src/handlers/account/middleware.ts new file mode 100644 index 0000000..e2bd859 --- /dev/null +++ b/src/handlers/account/middleware.ts @@ -0,0 +1,42 @@ +import { Types } from "mongoose"; + +import { AccountModel } from "@/models/account.model"; +import { RoleModel } from "@/models/role.model"; + +import { PERMISSIONS } from "@/constants/permissions"; + +import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction as Done } from "fastify"; + +export const determineIsAvailableToDeleteAccount = async (request: FastifyRequest, reply: FastifyReply, done: Done) => { + console.info("---------------------------determineIsAvailableToDeleteAccount------------------------------"); + + try { + if (!Types.ObjectId.isValid(request.user.id)) { + throw new Error("invalid user id"); + } + + const account = await AccountModel.findOne({ userId: request.user.id }).lean(); + + if (!account) { + throw new Error("account not found"); + } + + const role = await RoleModel.findOne({ name: account.role }).lean(); + + if (!role) { + throw new Error("role not found"); + } + + console.info("permisson", role.permissions[PERMISSIONS.deleteAccount]); + + if (!role.permissions[PERMISSIONS.deleteAccount]) { + throw new Error("the user doesn't have the permission to delete account"); + } + } catch (nativeError) { + console.info("determineIsAvailableToDeleteAccount error", (nativeError as Error).message); + reply.status(401); + return reply.send(nativeError); + } + + done(); +}; diff --git a/src/handlers/account/types.ts b/src/handlers/account/types.ts index 0d7ccd2..d6a83ed 100644 --- a/src/handlers/account/types.ts +++ b/src/handlers/account/types.ts @@ -1,4 +1,5 @@ import type { FastifyRequest } from "fastify"; +import type { Account } from "@/types/models/account.type"; export type GetAccountRequest = FastifyRequest<{ Params?: { @@ -12,3 +13,17 @@ export type SetAccountRoleRequest = FastifyRequest<{ role?: string; }; }>; + +export type GetAccountsRequest = FastifyRequest<{ + Querystring?: PaginationParams; +}>; + +export type GetAccountsResponse = { + accounts: Account[]; + totalPages: number; +}; + +export type PaginationParams = { + page?: number; + limit?: number; +}; diff --git a/src/handlers/auth/middleware.ts b/src/handlers/auth/middleware.ts index c5119ea..8c5033c 100644 --- a/src/handlers/auth/middleware.ts +++ b/src/handlers/auth/middleware.ts @@ -1,6 +1,8 @@ import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction as Done } from "fastify"; export const verifyUser = async (request: FastifyRequest, reply: FastifyReply, done: Done) => { + console.info("---------------------------verifyUser------------------------------"); + try { const { id } = await request.jwtVerify<{ id?: string }>(); @@ -12,6 +14,7 @@ export const verifyUser = async (request: FastifyRequest, reply: FastifyReply, d request.user = { id }; } catch (nativeError) { reply.status(401); + console.info("---------------------------verifyUser error------------------------------", nativeError); return reply.send(nativeError); } diff --git a/src/handlers/permission/index.ts b/src/handlers/permission/index.ts new file mode 100644 index 0000000..aa5b925 --- /dev/null +++ b/src/handlers/permission/index.ts @@ -0,0 +1,166 @@ +import { Types } from "mongoose"; + +import { PermissionModule } from "@/services/permission/permission.module"; +import { PermissionModel } from "@/models/permission.model"; + +import { validateEmptyFields } from "@/utils/validate-empty-fields"; + +import type { FastifyReply } from "fastify"; +import type { Permission } from "@/types/models/permission.type"; +import type { CreatePermissionRequest, GetPermissionByIdRequest, UpdatePermissionRequest } from "./types"; + +export const getAllPermissions = async (): Promise => PermissionModule.getAllPermissions(); + +export const getPermissionById = async ( + request: GetPermissionByIdRequest, + reply: FastifyReply +): Promise => { + const [{ permissionId }, validateParamsError] = validateEmptyFields(request.params || {}, ["permissionId"]); + + if (validateParamsError) { + reply.status(400); + throw validateParamsError; + } + + if (!Types.ObjectId.isValid(permissionId)) { + reply.status(400); + throw new Error("invalid permission id"); + } + + const permission = await PermissionModel.findOne({ + _id: new Types.ObjectId(permissionId), + isDeleted: false, + }); + + if (!permission) { + reply.status(404); + throw new Error("permission not found"); + } + + return permission; +}; + +export const createPermission = async (request: CreatePermissionRequest, reply: FastifyReply): Promise => { + const [permission, error] = validateEmptyFields(request.body || {}, ["name", "description"]); + + if (error) { + reply.status(400); + throw error; + } + + const newPermission = new PermissionModel(permission); + + return newPermission.save(); +}; + +export const updatePermission = async ( + request: UpdatePermissionRequest, + reply: FastifyReply +): Promise => { + const [permission, validateBodyError] = validateEmptyFields(request.body || {}, ["name", "description"]); + const [{ permissionId }, validateParamsError] = validateEmptyFields(request.params || {}, ["permissionId"]); + + if (validateBodyError) { + reply.status(400); + throw validateBodyError; + } + + if (validateParamsError) { + reply.status(400); + throw validateParamsError; + } + + if (!Types.ObjectId.isValid(permissionId)) { + reply.status(400); + throw new Error("invalid permission id"); + } + + const findedPermission = await PermissionModel.findByIdAndUpdate(permissionId, { + $set: { + ...permission, + updatedAt: new Date(), + }, + }); + + if (!findedPermission) { + reply.status(404); + throw new Error("permission not found"); + } + + return permission; +}; + +export const removePermission = async ( + request: GetPermissionByIdRequest, + reply: FastifyReply +): Promise => { + const [{ permissionId }, validateParamsError] = validateEmptyFields(request.params || {}, ["permissionId"]); + + if (validateParamsError) { + reply.status(400); + throw validateParamsError; + } + + if (!Types.ObjectId.isValid(permissionId)) { + reply.status(400); + throw new Error("invalid permission id"); + } + + const permission = await PermissionModel.findOneAndUpdate( + { + _id: new Types.ObjectId(permissionId), + isDeleted: false, + }, + { + $set: { + deletedAt: new Date(), + isDeleted: true, + }, + } + ); + + if (!permission) { + reply.status(404); + throw new Error("permission not found"); + } + + return permission; +}; + +export const restorePermission = async ( + request: GetPermissionByIdRequest, + reply: FastifyReply +): Promise => { + const [{ permissionId }, validateParamsError] = validateEmptyFields(request.params || {}, ["permissionId"]); + + if (validateParamsError) { + reply.status(400); + throw validateParamsError; + } + + if (!Types.ObjectId.isValid(permissionId)) { + reply.status(400); + throw new Error("invalid permission id"); + } + + const permission = await PermissionModel.findOneAndUpdate( + { + _id: new Types.ObjectId(permissionId), + isDeleted: true, + }, + { + $set: { + deletedAt: undefined, + updatedAt: new Date(), + isDeleted: false, + }, + } + ); + + if (!permission) { + reply.status(404); + throw new Error("permission not found"); + } + + return permission; +}; diff --git a/src/handlers/permission/types.ts b/src/handlers/permission/types.ts new file mode 100644 index 0000000..b5666c9 --- /dev/null +++ b/src/handlers/permission/types.ts @@ -0,0 +1,20 @@ +import type { FastifyRequest } from "fastify"; +import type { Permission } from "@/types/models/permission.type"; +import type { ObjectWithPossibleFields } from "@/types/object-with-possible-fields"; + +export type CreatePermissionRequest = FastifyRequest<{ + Body?: ObjectWithPossibleFields; +}>; + +export type UpdatePermissionRequest = FastifyRequest<{ + Body?: ObjectWithPossibleFields; + Params?: { + permissionId?: string; + }; +}>; + +export type GetPermissionByIdRequest = FastifyRequest<{ + Params?: { + permissionId?: string; + }; +}>; diff --git a/src/index.ts b/src/index.ts index 5a30e29..f2460b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ import { Server } from "./server"; import { CONFIGURATION } from "@/constants/configuration"; +console.info("server configuration: \n", JSON.stringify(CONFIGURATION, null, 2)); +console.info("env: \n", JSON.stringify(process.env, null, 2)); + const server = new Server({ serverOptions: CONFIGURATION.http, databaseOptions: CONFIGURATION.db, diff --git a/src/models/account.model.ts b/src/models/account.model.ts index c6c8b16..ed4a017 100644 --- a/src/models/account.model.ts +++ b/src/models/account.model.ts @@ -8,6 +8,7 @@ const schema: SchemaDefinition = { userId: { type: String, required: true, + index: { unique: true }, }, nickname: { type: String, diff --git a/src/models/permission.model.ts b/src/models/permission.model.ts new file mode 100644 index 0000000..4008e28 --- /dev/null +++ b/src/models/permission.model.ts @@ -0,0 +1,28 @@ +import { Schema, model, SchemaDefinition } from "mongoose"; + +import { eloquentSchema } from "./eloquent.schema"; + +import type { Permission } from "@/types/models/permission.type"; + +const schema: SchemaDefinition = { + name: { + type: String, + required: true, + index: { unique: true }, + }, + description: { + type: String, + required: true, + default: "Разрешение на создание ролей", + }, + ...eloquentSchema, +}; + +const schemaSettings = { + versionKey: false, + collection: "permissions", +}; + +const PermissionSchema = new Schema(schema, schemaSettings); + +export const PermissionModel = model("Permission", PermissionSchema); diff --git a/src/routes/account.routes.ts b/src/routes/account.routes.ts index 5eb89e4..d694916 100644 --- a/src/routes/account.routes.ts +++ b/src/routes/account.routes.ts @@ -5,9 +5,11 @@ import { getAccountByID, getAccount, setAccountRole, - getAllAccounts, + getAccounts, deleteAccount, + deleteAccountById, removeAccount, + removeAccountById, restoreAccount, } from "@/handlers/account"; import { verifyUser } from "@/handlers/auth/middleware"; @@ -19,17 +21,30 @@ import { setAccountRoleSchema, getAccountsSchema, removeAccountSchema, + removeAccountByIdSchema, restoreAccountSchema, deleteAccountSchema, + deleteAccountByIdSchema, } from "@/swagger/account"; export const setAccountRoutes = (router: Router): void => { - router.get("/all", getAllAccounts, { schema: getAccountsSchema }); + router.get("/pagination", getAccounts, { schema: getAccountsSchema }); router.get("/:userId", getAccountByID, { schema: getAccountByIdSchema }); router.get("/", getAccount, { preHandler: [verifyUser], schema: getAccountSchema }); + router.post("/", createAccount, { preHandler: [verifyUser], schema: createAccountSchema }); router.post("/restore", restoreAccount, { preHandler: [verifyUser], schema: restoreAccountSchema }); + router.patch("/role", setAccountRole, { preHandler: [verifyUser], schema: setAccountRoleSchema }); + router.delete("/", removeAccount, { preHandler: [verifyUser], schema: removeAccountSchema }); + router.delete("/:userId", removeAccountById, { + preHandler: [verifyUser], + schema: removeAccountByIdSchema, + }); router.delete("/delete", deleteAccount, { preHandler: [verifyUser], schema: deleteAccountSchema }); + router.delete("/delete/:userId", deleteAccountById, { + preHandler: [verifyUser], + schema: deleteAccountByIdSchema, + }); }; diff --git a/src/routes/permission.routes.ts b/src/routes/permission.routes.ts new file mode 100644 index 0000000..ca2cf5c --- /dev/null +++ b/src/routes/permission.routes.ts @@ -0,0 +1,31 @@ +import { Router } from "@/server/router"; + +import { + getAllPermissions, + getPermissionById, + createPermission, + updatePermission, + removePermission, + restorePermission, +} from "@/handlers/permission"; + +import { + getAllPermissionsSchema, + getPermissionByIdSchema, + createPermissionSchema, + removePermissionSchema, + restorePermissionSchema, + updatePermissionSchema, +} from "@/swagger/permission"; + +export const setPermissionRoutes = (router: Router): void => { + router.get("/all", getAllPermissions, { schema: getAllPermissionsSchema }); + router.get("/:permissionId", getPermissionById, { schema: getPermissionByIdSchema }); + + router.post("/", createPermission, { schema: createPermissionSchema }); + router.post("/restore/:permissionId", restorePermission, { schema: restorePermissionSchema }); + + router.patch("/:permissionId", updatePermission, { schema: updatePermissionSchema }); + + router.delete("/:permissionId", removePermission, { schema: removePermissionSchema }); +}; diff --git a/src/services/account/account.service.ts b/src/services/account/account.service.ts new file mode 100644 index 0000000..22e138c --- /dev/null +++ b/src/services/account/account.service.ts @@ -0,0 +1,16 @@ +import { RoleModel } from "@/models/role.model"; + +import type { Role } from "@/types/models/role.type"; +import type { Account } from "@/types/models/account.type"; + +export class AccountService { + public static async determineAccountRole(account: Account): Promise { + const role = await RoleModel.findOne({ name: account.role }).lean(); + + if (!role) { + throw new Error("role not found"); + } + + return role; + } +} diff --git a/src/services/permission/permission.interface.ts b/src/services/permission/permission.interface.ts new file mode 100644 index 0000000..6af23f9 --- /dev/null +++ b/src/services/permission/permission.interface.ts @@ -0,0 +1,6 @@ +import type { Account } from "@/types/models/account.type"; +import type { Role } from "@/types/models/role.type"; + +export interface AccountService { + determineAccountRole(account: Account): Promise; +} diff --git a/src/services/permission/permission.module.ts b/src/services/permission/permission.module.ts new file mode 100644 index 0000000..4ce181a --- /dev/null +++ b/src/services/permission/permission.module.ts @@ -0,0 +1,6 @@ +import { AccountService } from "@/services/account/account.service"; +import { PermissionService } from "./permission.service"; + +export const PermissionModule = new PermissionService({ + accountService: AccountService, +}); diff --git a/src/services/permission/permission.service.test.ts b/src/services/permission/permission.service.test.ts new file mode 100644 index 0000000..6538d57 --- /dev/null +++ b/src/services/permission/permission.service.test.ts @@ -0,0 +1,78 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { PermissionService } from "./permission.service"; + +import { PERMISSIONS } from "@/constants/permissions"; + +import type { AccountService } from "./permission.interface"; +import type { Account } from "@/types/models/account.type"; + +const DEFAULT_ACCOUNT: Account = { + userId: "userId", + nickname: "nickname", + avatar: "/img/avatar.png", + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, +}; + +describe("PermissionService", () => { + const accountService = mock(); + + beforeEach(() => { + mockReset(accountService); + }); + + describe("deleteAccount", () => { + test("Успешное определение доступа на удаление аккаунта", async () => { + const permissionService = new PermissionService({ accountService }); + + accountService.determineAccountRole.mockResolvedValueOnce({ + name: "admin", + permissions: { + [PERMISSIONS.deleteAccount]: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }); + + const isAvailableToDeleteAccount = await permissionService.deleteAccount(DEFAULT_ACCOUNT); + + expect(accountService.determineAccountRole).toHaveBeenCalledWith(DEFAULT_ACCOUNT); + expect(accountService.determineAccountRole).toHaveBeenCalledTimes(1); + expect(isAvailableToDeleteAccount).toBe(true); + }); + + test("У пользователя нет прав на удаление", async () => { + const permissionService = new PermissionService({ accountService }); + + accountService.determineAccountRole.mockResolvedValueOnce({ + name: "admin", + permissions: { + read: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + isDeleted: false, + }); + + const isAvailableToDeleteAccount = await permissionService.deleteAccount(DEFAULT_ACCOUNT); + + expect(accountService.determineAccountRole).toHaveBeenCalledWith(DEFAULT_ACCOUNT); + expect(accountService.determineAccountRole).toHaveBeenCalledTimes(1); + expect(isAvailableToDeleteAccount).toBe(false); + }); + + test("Ошибка должна успешно пробрасываться", async () => { + const permissionService = new PermissionService({ accountService }); + + accountService.determineAccountRole.mockRejectedValueOnce(new Error("role not found")); + + expect(permissionService.deleteAccount(DEFAULT_ACCOUNT)).resolves.toBe(false); + expect(accountService.determineAccountRole).toHaveBeenCalledWith(DEFAULT_ACCOUNT); + expect(accountService.determineAccountRole).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/services/permission/permission.service.ts b/src/services/permission/permission.service.ts new file mode 100644 index 0000000..5b4f5ed --- /dev/null +++ b/src/services/permission/permission.service.ts @@ -0,0 +1,58 @@ +import { Types } from "mongoose"; + +import { PermissionModel } from "@/models/permission.model"; + +import { PERMISSIONS, PERMISSION_LIST } from "@/constants/permissions"; +import { ERROR_INVALID_PARAMS, ERROR_NOT_FOUND } from "@/constants/errors"; + +import type { Account } from "@/types/models/account.type"; +import type { AccountService } from "./permission.interface"; +import type { Permission } from "@/types/models/permission.type"; + +type PermissionServiceDeps = { + readonly accountService: AccountService; +}; + +export class PermissionService { + private accountService: AccountService; + + constructor(deps: PermissionServiceDeps) { + this.accountService = deps.accountService; + } + + public async deleteAccount(account: Account): Promise { + try { + const role = await this.accountService.determineAccountRole(account); + + if (!role.permissions[PERMISSIONS.deleteAccount]) { + return false; + } + + return true; + } catch { + return false; + } + } + + public async getAllPermissions(): Promise { + const permissions = await PermissionModel.find({}).lean(); + + return [...permissions, ...PERMISSION_LIST]; + } + + public async removePermission(id: string, permission: Permission): Promise { + if (!Types.ObjectId.isValid(id)) { + throw ERROR_INVALID_PARAMS; + } + + const findedPermission = await PermissionModel.findByIdAndUpdate(id, { + $set: permission, + }); + + if (!findedPermission) { + throw ERROR_NOT_FOUND; + } + + return permission; + } +} diff --git a/src/swagger/account/index.ts b/src/swagger/account/index.ts index 4336f89..b1eb654 100644 --- a/src/swagger/account/index.ts +++ b/src/swagger/account/index.ts @@ -1,18 +1,19 @@ -import { getAccountParams, setAccountRoleBody } from "./inputs"; +import { getAccountParams, setAccountRoleBody, getAccountsQuerystring } from "./inputs"; import { getAccountResponse, createAccountResponse, setAccountRoleResponse, getAccountsResponse, - removeRoleResponse, + removeAccountResponse, } from "./responses"; import type { SwaggerSchema } from "@/types/swagger.type"; export const getAccountsSchema: SwaggerSchema = { summary: "Получение информации об аккаунтах", - description: "Получение всех аккаунтов из БД", + description: "Получение список аккаунтов с пагинацией из БД", tags: ["account"], + querystring: getAccountsQuerystring, response: getAccountsResponse, }; @@ -51,7 +52,16 @@ export const removeAccountSchema: SwaggerSchema = { summary: "Удаление аккаунта", description: "Помечает аккаунт удалённым, но не удаляет его из БД", tags: ["account"], - response: removeRoleResponse, + response: removeAccountResponse, + security: [{ bearer: [] }], +}; + +export const removeAccountByIdSchema: SwaggerSchema = { + summary: "Удаление аккаунта по ID", + description: "Помечает аккаунт удалённым, но не удаляет его из БД", + tags: ["account"], + params: getAccountParams, + response: removeAccountResponse, security: [{ bearer: [] }], }; @@ -59,7 +69,16 @@ export const deleteAccountSchema: SwaggerSchema = { summary: "Удаление аккаунта", description: "Удаляет аккаунт из БД окончательно", tags: ["account"], - response: removeRoleResponse, + response: removeAccountResponse, + security: [{ bearer: [] }], +}; + +export const deleteAccountByIdSchema: SwaggerSchema = { + summary: "Удаление аккаунта по ID", + description: "Удаляет аккаунт из БД окончательно", + tags: ["account"], + params: getAccountParams, + response: removeAccountResponse, security: [{ bearer: [] }], }; @@ -67,6 +86,6 @@ export const restoreAccountSchema: SwaggerSchema = { summary: "Восстановление аккаунта", description: "Восстанавливает аккаунт, который не был удалён окончательно", tags: ["account"], - response: removeRoleResponse, + response: removeAccountResponse, security: [{ bearer: [] }], }; diff --git a/src/swagger/account/inputs.ts b/src/swagger/account/inputs.ts index 81971fb..fbc12e1 100644 --- a/src/swagger/account/inputs.ts +++ b/src/swagger/account/inputs.ts @@ -11,6 +11,20 @@ export const getAccountParams: SwaggerMessage = { }, }; +export const getAccountsQuerystring: SwaggerMessage = { + type: "object", + properties: { + page: { + type: "number", + description: "номер страницы", + }, + limit: { + type: "number", + description: "Лимит количества аккаунтов (больше 100 не обрабатывается)", + }, + }, +}; + export const setAccountRoleBody: SwaggerMessage = { type: "object", required: ["userId", "role"], diff --git a/src/swagger/account/models.ts b/src/swagger/account/models.ts index c32cc26..579d1f6 100644 --- a/src/swagger/account/models.ts +++ b/src/swagger/account/models.ts @@ -47,3 +47,44 @@ export const account: SwaggerMessage = { }, ], }; + +export const accounts: SwaggerMessage = { + description: "Список аккаунтов", + type: "object", + properties: { + accounts: { + type: "array", + description: "Массив аккаунтов", + items: account, + }, + totalPages: { type: "number" }, + }, + examples: [ + { + totalPages: 10, + accounts: [ + { + _id: "807f1f77bcf81cd799439011", + userId: "507f1f77bcf86cd799439011", + nickname: "Ivanov Ivan Ivanovich", + avatar: "/media/avatar/default-avatar.jpg", + role: "user", + isDeleted: false, + createdAt: "2017-07-21T17:32:28Z", + updatedAt: "2017-07-21T17:32:28Z", + }, + { + _id: "807f1f77bcf81cd799439011", + userId: "507f1f77bcf86cd799439011", + nickname: "Ivanov Ivan Ivanovich", + avatar: "/media/avatar/default-avatar.jpg", + role: "user", + isDeleted: true, + createdAt: "2017-07-21T17:32:28Z", + updatedAt: "2019-04-14T15:32:15Z", + deletedAt: "2021-08-17T13:23:44Z", + }, + ], + }, + ], +}; diff --git a/src/swagger/account/responses.ts b/src/swagger/account/responses.ts index 35020fd..a716bbc 100644 --- a/src/swagger/account/responses.ts +++ b/src/swagger/account/responses.ts @@ -1,15 +1,11 @@ import { swaggerError } from "@/utils/swagger-error"; -import { account } from "./models"; +import { account, accounts } from "./models"; import type { SwaggerMessage } from "@/types/swagger.type"; export const getAccountsResponse: Record = { - 200: { - type: "array", - description: "Массив аккаунтов", - items: account, - }, + 200: accounts, }; export const getAccountResponse: Record = { @@ -32,7 +28,7 @@ export const setAccountRoleResponse: Record = { 404: swaggerError(404, "user not found"), }; -export const removeRoleResponse: Record = { +export const removeAccountResponse: Record = { 200: account, 400: swaggerError(400, "invalid user id"), 401: swaggerError(401, "invalid token"), diff --git a/src/swagger/permission/index.ts b/src/swagger/permission/index.ts new file mode 100644 index 0000000..dfb3d3b --- /dev/null +++ b/src/swagger/permission/index.ts @@ -0,0 +1,51 @@ +import { getPermissionParams, createPermissionBody } from "./inputs"; +import { getPermissionResponse, getAllPermissionsResponse, createPermissionResponse } from "./responses"; + +import type { SwaggerSchema } from "@/types/swagger.type"; + +export const getAllPermissionsSchema: SwaggerSchema = { + summary: "Получение информации об разрешениях", + description: "Получение списка всех разрешений из БД", + tags: ["permission"], + response: getAllPermissionsResponse, +}; + +export const getPermissionByIdSchema: SwaggerSchema = { + summary: "Получение информации об аккаунте", + description: "Получение аккаунта по ID", + tags: ["permission"], + params: getPermissionParams, + response: getPermissionResponse, +}; + +export const createPermissionSchema: SwaggerSchema = { + summary: "Создание разрешения", + tags: ["permission"], + body: createPermissionBody, + response: createPermissionResponse, +}; + +export const removePermissionSchema: SwaggerSchema = { + summary: "Удаление разрешения", + description: "Помечает разрешение удалённым, но не удаляет его из БД", + tags: ["permission"], + params: getPermissionParams, + response: getPermissionResponse, +}; + +export const restorePermissionSchema: SwaggerSchema = { + summary: "Восстановление разрешения", + description: "Восстанавливает разрешение, которое не было удалёно окончательно", + tags: ["permission"], + params: getPermissionParams, + response: getPermissionResponse, +}; + +export const updatePermissionSchema: SwaggerSchema = { + summary: "Обновление разрешения", + description: "Обновляет данные о разрешении", + tags: ["permission"], + params: getPermissionParams, + body: createPermissionBody, + response: getPermissionResponse, +}; diff --git a/src/swagger/permission/inputs.ts b/src/swagger/permission/inputs.ts new file mode 100644 index 0000000..ccd56e9 --- /dev/null +++ b/src/swagger/permission/inputs.ts @@ -0,0 +1,41 @@ +import type { SwaggerMessage } from "@/types/swagger.type"; + +export const getPermissionParams: SwaggerMessage = { + type: "object", + required: ["permissionId"], + properties: { + permissionId: { + type: "string", + description: "ID разрешения", + }, + }, +}; + +export const getAccountsQuerystring: SwaggerMessage = { + type: "object", + properties: { + page: { + type: "number", + description: "номер страницы", + }, + limit: { + type: "number", + description: "Лимит количества аккаунтов (больше 100 не обрабатывается)", + }, + }, +}; + +export const createPermissionBody: SwaggerMessage = { + type: "object", + required: ["name", "description"], + properties: { + name: { + type: "string", + description: "имя разрешения", + }, + description: { + type: "string", + description: "описание разрешения", + }, + }, +}; diff --git a/src/swagger/permission/models.ts b/src/swagger/permission/models.ts new file mode 100644 index 0000000..95d9e62 --- /dev/null +++ b/src/swagger/permission/models.ts @@ -0,0 +1,42 @@ +import type { SwaggerMessage } from "@/types/swagger.type"; + +export const permission: SwaggerMessage = { + description: "Разрешение", + type: "object", + properties: { + _id: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + isDeleted: { type: "boolean" }, + createdAt: { + type: "string", + format: "date-time", + }, + updatedAt: { + type: "string", + format: "date-time", + }, + deletedAt: { + type: "string", + format: "date-time", + }, + }, + examples: [ + { + _id: "807f1f77bcf81cd799439011", + name: "hub-admin-backend-service.deleteAccount", + nickname: "Разрешение на удаление аккаунта", + avatar: "/media/avatar/default-avatar.jpg", + role: "user", + isDeleted: false, + createdAt: "2017-07-21T17:32:28Z", + updatedAt: "2017-07-21T17:32:28Z", + }, + ], +}; + +export const permissions: SwaggerMessage = { + description: "Список разрешений", + type: "array", + items: permission, +}; diff --git a/src/swagger/permission/responses.ts b/src/swagger/permission/responses.ts new file mode 100644 index 0000000..862573d --- /dev/null +++ b/src/swagger/permission/responses.ts @@ -0,0 +1,20 @@ +import { swaggerError } from "@/utils/swagger-error"; + +import { permission, permissions } from "./models"; + +import type { SwaggerMessage } from "@/types/swagger.type"; + +export const getAllPermissionsResponse: Record = { + 200: permissions, +}; + +export const getPermissionResponse: Record = { + 200: permission, + 400: swaggerError(400, "invalid permission id"), + 404: swaggerError(400, "permission not found"), +}; + +export const createPermissionResponse: Record = { + 200: permission, + 409: swaggerError(409, "permission already exist"), +}; diff --git a/src/types/models/eloquent.type.ts b/src/types/models/eloquent.type.ts index 7f4e259..3952c84 100644 --- a/src/types/models/eloquent.type.ts +++ b/src/types/models/eloquent.type.ts @@ -1,6 +1,6 @@ -export type Eloquent = { - createdAt: Date; - updatedAt: Date; - deletedAt: Date; - isDeleted: boolean; -}; +export type Eloquent = { + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; + isDeleted: boolean; +}; diff --git a/src/types/models/permission.type.ts b/src/types/models/permission.type.ts new file mode 100644 index 0000000..aac9d74 --- /dev/null +++ b/src/types/models/permission.type.ts @@ -0,0 +1,6 @@ +import type { Eloquent } from "./eloquent.type"; + +export type Permission = Eloquent & { + name: string; + description: string; +}; diff --git a/src/utils/is-error.test.ts b/src/utils/is-error.test.ts new file mode 100644 index 0000000..b4cb829 --- /dev/null +++ b/src/utils/is-error.test.ts @@ -0,0 +1,16 @@ +import { isError } from "./is-error"; + +describe("isError", () => { + test("Должно возвращать значение true, если это ошибка Error", () => { + const error: Error = new Error("An error occurred"); + + expect(isError(error)).toBe(true); + }); + + test("Должно возвращать значение false, если это ошибка Error", () => { + expect(isError(undefined)).toBe(false); + expect(isError(null)).toBe(false); + expect(isError("")).toBe(false); + expect(isError({})).toBe(false); + }); +}); diff --git a/src/utils/is-error.ts b/src/utils/is-error.ts new file mode 100644 index 0000000..008d4be --- /dev/null +++ b/src/utils/is-error.ts @@ -0,0 +1 @@ +export const isError = (candidate: unknown): candidate is Error => candidate instanceof Error; diff --git a/yarn.lock b/yarn.lock index a50d2f6..02a9685 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3943,6 +3943,13 @@ jest-message-util@^29.3.1: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-3.0.4.tgz#12a5f993d27aa46232012c439a9b7c54f3b0a1fd" + integrity sha512-2ynEZ7IEJNrhrgshklDMhrOdnmW4Nt+PhkyRqZxRgpwMo7JjmFWMzyp0+eSyk+H9KK1QjXI5xTZIw6x7cVDcRg== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^29.3.1: version "29.3.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.3.1.tgz#60287d92e5010979d01f218c6b215b688e0f313e" @@ -5604,6 +5611,11 @@ trim-newlines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + ts-jest@^29.0.3: version "29.0.3" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.0.3.tgz#63ea93c5401ab73595440733cefdba31fcf9cb77"