feat: permissions & account paginations

This commit is contained in:
Kirill 2023-05-12 07:03:11 +03:00
parent 6fca987edf
commit ff74d5ca74
38 changed files with 964 additions and 62 deletions

@ -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-----

@ -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

@ -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",

@ -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);
};

2
src/constants/errors.ts Normal file

@ -0,0 +1,2 @@
export const ERROR_NOT_FOUND = new Error("record not found");
export const ERROR_INVALID_PARAMS = new Error("invalid params");

@ -0,0 +1,6 @@
import { ERROR_INVALID_PARAMS, ERROR_NOT_FOUND } from "./errors";
export const HTTP_STATUSES = new Map<Error, number>([
[ERROR_NOT_FOUND, 404],
[ERROR_INVALID_PARAMS, 400],
]);

@ -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<Permission> = [DELETE_ACCOUNT_PERMISSION];

@ -0,0 +1,45 @@
import { determinePaginationParameters } from "./helpers";
import type { PaginationParams } from "./types";
describe("determinePaginationParameters", () => {
const testCases: Array<{
input: PaginationParams;
result: ReturnType<typeof determinePaginationParameters>;
}> = [
{
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);
});
});

@ -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<PaginationParams> => {
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,
};
};

@ -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<GetAccountsResponse> => {
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<Account> => {
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<Account> => {
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<Account> => {
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<Account> => {
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<Account> => {
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<Account> => {
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<Account> => {
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;

@ -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();
};

@ -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;
};

@ -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);
}

@ -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<Permission[]> => PermissionModule.getAllPermissions();
export const getPermissionById = async (
request: GetPermissionByIdRequest,
reply: FastifyReply
): Promise<Permission | undefined> => {
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<Permission> => {
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<Permission | undefined> => {
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<Permission | undefined> => {
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<Permission | undefined> => {
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;
};

@ -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<Permission>;
}>;
export type UpdatePermissionRequest = FastifyRequest<{
Body?: ObjectWithPossibleFields<Permission>;
Params?: {
permissionId?: string;
};
}>;
export type GetPermissionByIdRequest = FastifyRequest<{
Params?: {
permissionId?: string;
};
}>;

@ -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,

@ -8,6 +8,7 @@ const schema: SchemaDefinition<Account> = {
userId: {
type: String,
required: true,
index: { unique: true },
},
nickname: {
type: String,

@ -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<Permission> = {
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<Permission>(schema, schemaSettings);
export const PermissionModel = model("Permission", PermissionSchema);

@ -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,
});
};

@ -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 });
};

@ -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<Role> {
const role = await RoleModel.findOne({ name: account.role }).lean();
if (!role) {
throw new Error("role not found");
}
return role;
}
}

@ -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<Role>;
}

@ -0,0 +1,6 @@
import { AccountService } from "@/services/account/account.service";
import { PermissionService } from "./permission.service";
export const PermissionModule = new PermissionService({
accountService: AccountService,
});

@ -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<AccountService>();
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);
});
});
});

@ -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<boolean> {
try {
const role = await this.accountService.determineAccountRole(account);
if (!role.permissions[PERMISSIONS.deleteAccount]) {
return false;
}
return true;
} catch {
return false;
}
}
public async getAllPermissions(): Promise<Permission[]> {
const permissions = await PermissionModel.find({}).lean();
return [...permissions, ...PERMISSION_LIST];
}
public async removePermission(id: string, permission: Permission): Promise<Permission> {
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;
}
}

@ -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: [] }],
};

@ -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"],

@ -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",
},
],
},
],
};

@ -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<string, SwaggerMessage> = {
200: {
type: "array",
description: "Массив аккаунтов",
items: account,
},
200: accounts,
};
export const getAccountResponse: Record<string, SwaggerMessage> = {
@ -32,7 +28,7 @@ export const setAccountRoleResponse: Record<string, SwaggerMessage> = {
404: swaggerError(404, "user not found"),
};
export const removeRoleResponse: Record<string, SwaggerMessage> = {
export const removeAccountResponse: Record<string, SwaggerMessage> = {
200: account,
400: swaggerError(400, "invalid user id"),
401: swaggerError(401, "invalid token"),

@ -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,
};

@ -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: "описание разрешения",
},
},
};

@ -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,
};

@ -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<string, SwaggerMessage> = {
200: permissions,
};
export const getPermissionResponse: Record<string, SwaggerMessage> = {
200: permission,
400: swaggerError(400, "invalid permission id"),
404: swaggerError(400, "permission not found"),
};
export const createPermissionResponse: Record<string, SwaggerMessage> = {
200: permission,
409: swaggerError(409, "permission already exist"),
};

@ -1,6 +1,6 @@
export type Eloquent = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
deletedAt?: Date;
isDeleted: boolean;
};

@ -0,0 +1,6 @@
import type { Eloquent } from "./eloquent.type";
export type Permission = Eloquent & {
name: string;
description: string;
};

@ -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);
});
});

1
src/utils/is-error.ts Normal file

@ -0,0 +1 @@
export const isError = (candidate: unknown): candidate is Error => candidate instanceof Error;

@ -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"