diff --git a/backend/package-lock.json b/backend/package-lock.json index 264cec0..61ba410 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -47,6 +47,7 @@ "@babel/runtime": "^7.14.0", "@commitlint/cli": "^17.5.0", "@commitlint/config-conventional": "^17.4.4", + "@faker-js/faker": "^8.4.1", "babel-eslint": "^10.1.0", "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-transform-runtime": "^6.23.0", @@ -2612,6 +2613,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fastify/busboy": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-1.2.1.tgz", @@ -13769,6 +13786,12 @@ } } }, + "@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true + }, "@fastify/busboy": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-1.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index a09b6dd..bae087e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,7 @@ "@babel/runtime": "^7.14.0", "@commitlint/cli": "^17.5.0", "@commitlint/config-conventional": "^17.4.4", + "@faker-js/faker": "^8.4.1", "babel-eslint": "^10.1.0", "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-transform-runtime": "^6.23.0", diff --git a/backend/src/core/api/comment/comment.controller.js b/backend/src/core/api/comment/comment.controller.js new file mode 100644 index 0000000..bbcfdba --- /dev/null +++ b/backend/src/core/api/comment/comment.controller.js @@ -0,0 +1,43 @@ +import { ValidHttpResponse } from 'packages/handler/response/validHttp.response'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from 'core/common/constants'; +import { MessageDto } from 'core/common/dto'; +import { + AddCommentDto, + COMMENT_PARENT_TYPE, + CommentService, +} from 'core/modules/comment'; + +class Controller { + constructor() { + this.commentService = CommentService; + } + + createComment = async req => { + const { parentType, parentId, content } = AddCommentDto(req.body); + await this.commentService.createComment( + parentId, + parentType, + content, + req.user.payload.id, + ); + return ValidHttpResponse.toOkResponse(MessageDto('Comment created')); + }; + + getCommentPaginationByParentIdAndType = async req => { + const parentId = req.params.id; + const page = req.query.page || DEFAULT_PAGE; + const size = req.query.size || DEFAULT_PAGE_SIZE; + const parentType = req.query.keyword || 'POST'; + const data = + await this.commentService.getCommentPaginationByParentIdAndType( + parentId, + COMMENT_PARENT_TYPE[parentType], + page, + size, + ); + + return ValidHttpResponse.toOkResponse(data); + }; +} + +export const CommentController = new Controller(); diff --git a/backend/src/core/api/comment/comment.resolver.js b/backend/src/core/api/comment/comment.resolver.js new file mode 100644 index 0000000..64bb0f0 --- /dev/null +++ b/backend/src/core/api/comment/comment.resolver.js @@ -0,0 +1,29 @@ +import { Module } from 'packages/handler/Module'; +import { RecordId, page, size, keyword } from 'core/common/swagger'; +import { CommentController } from './comment.controller'; + +export const CommentResolver = Module.builder() + .addPrefix({ + prefixPath: '/comments', + tag: 'comments', + module: 'CommentModule', + }) + .register([ + { + route: '', + method: 'post', + controller: CommentController.createComment, + body: 'AddCommentDto', + model: { $ref: 'MessageDto' }, + preAuthorization: true, + }, + { + route: '/:id/nested', + method: 'get', + params: [page, size, keyword, RecordId], + controller: CommentController.getCommentPaginationByParentIdAndType, + model: { $ref: 'PaginationCommentDto' }, + description: + 'Get list nested comments of a post or comment (Keyword POST or COMMENT)', + }, + ]); diff --git a/backend/src/core/api/index.js b/backend/src/core/api/index.js index 0967c34..a7ceb2f 100644 --- a/backend/src/core/api/index.js +++ b/backend/src/core/api/index.js @@ -5,14 +5,17 @@ import { HelpSignalResolver } from 'core/api/helpSignal'; import { HandlerResolver } from '../../packages/handler/HandlerResolver'; import { AuthResolver } from './auth/auth.resolver'; import { EmergencyResolver } from './emergency'; +import { CommentResolver } from './comment/comment.resolver'; +import { UserReactResolver } from './user-react/user-react.resolver'; -export const ModuleResolver = HandlerResolver - .builder() +export const ModuleResolver = HandlerResolver.builder() .addSwaggerBuilder(ApiDocument) .addModule([ AuthResolver, UserResolver, MediaResolver, HelpSignalResolver, - EmergencyResolver + EmergencyResolver, + CommentResolver, + UserReactResolver, ]); diff --git a/backend/src/core/api/user-react/user-react.controller.js b/backend/src/core/api/user-react/user-react.controller.js new file mode 100644 index 0000000..a39d9ba --- /dev/null +++ b/backend/src/core/api/user-react/user-react.controller.js @@ -0,0 +1,38 @@ +import { ValidHttpResponse } from 'packages/handler/response/validHttp.response'; +import { MessageDto } from 'core/common/dto'; +import { UserReactService } from 'core/modules/user-react/service/user-react.service'; +import { + AddUserReactDto, + DeleteUserReactDto, +} from 'core/modules/user-react/dto'; + +class Controller { + constructor() { + this.userReactService = UserReactService; + } + + createUserReact = async req => { + const { reactableId, reactableType, reactType } = AddUserReactDto( + req.body, + ); + await this.userReactService.createUserReact( + req.user.payload.id, + reactableId, + reactableType, + reactType, + ); + return ValidHttpResponse.toOkResponse(MessageDto('UserReact created')); + }; + + deleteUserReact = async req => { + const { reactableType, reactableId } = DeleteUserReactDto(req.body); + await this.userReactService.deleteUserReact( + req.user.payload.id, + reactableId, + reactableType, + ); + return ValidHttpResponse.toOkResponse(MessageDto('UserReact deleted')); + }; +} + +export const UserReactController = new Controller(); diff --git a/backend/src/core/api/user-react/user-react.resolver.js b/backend/src/core/api/user-react/user-react.resolver.js new file mode 100644 index 0000000..b89ffb3 --- /dev/null +++ b/backend/src/core/api/user-react/user-react.resolver.js @@ -0,0 +1,31 @@ +import { Module } from 'packages/handler/Module'; +import { UserReactController } from './user-react.controller'; + +export const UserReactResolver = Module.builder() + .addPrefix({ + prefixPath: '/user-reacts', + tag: 'user-reacts', + module: 'UserReactModule', + }) + .register([ + { + route: '', + method: 'post', + controller: UserReactController.createUserReact, + body: 'AddUserReactDto', + model: { $ref: 'MessageDto' }, + preAuthorization: true, + description: + 'Do LIKE or DISLIKE to a comment or post actioned by user', + }, + { + route: '', + method: 'delete', + controller: UserReactController.deleteUserReact, + body: 'DeleteUserReactDto', + model: { $ref: 'MessageDto' }, + description: + 'Delete a LIKE or DISLIKE of a comment or post actioned by owner', + preAuthorization: true, + }, + ]); diff --git a/backend/src/core/common/constants/default-params.constant.js b/backend/src/core/common/constants/default-params.constant.js new file mode 100644 index 0000000..bfa8fe1 --- /dev/null +++ b/backend/src/core/common/constants/default-params.constant.js @@ -0,0 +1,3 @@ +export const DEFAULT_PAGE_SIZE = 50; +export const DEFAULT_PAGE = 1; +export const DEFAULT_KEYWORD = ''; diff --git a/backend/src/core/common/constants/index.js b/backend/src/core/common/constants/index.js index 291c8ad..0d096bf 100644 --- a/backend/src/core/common/constants/index.js +++ b/backend/src/core/common/constants/index.js @@ -1 +1,2 @@ export * from './cloudinary.constant'; +export * from './default-params.constant'; diff --git a/backend/src/core/common/dto/index.js b/backend/src/core/common/dto/index.js new file mode 100644 index 0000000..65e0f92 --- /dev/null +++ b/backend/src/core/common/dto/index.js @@ -0,0 +1,2 @@ +export * from './id-array.dto'; +export * from './message.dto'; diff --git a/backend/src/core/common/dto/message.dto.js b/backend/src/core/common/dto/message.dto.js new file mode 100644 index 0000000..da9e199 --- /dev/null +++ b/backend/src/core/common/dto/message.dto.js @@ -0,0 +1,10 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; + +ApiDocument.addModel('MessageDto', { + message: SwaggerDocument.ApiProperty({ type: 'string', readOnly: true }), +}); + +export const MessageDto = message => ({ + message, +}); diff --git a/backend/src/core/common/swagger/index.js b/backend/src/core/common/swagger/index.js index a3afaa1..48b77e7 100644 --- a/backend/src/core/common/swagger/index.js +++ b/backend/src/core/common/swagger/index.js @@ -4,3 +4,6 @@ export * from './upload-file'; export * from './help-signal-id'; export * from './user-id'; export * from './emergency-id'; +export * from './keyword'; +export * from './page'; +export * from './page-size'; diff --git a/backend/src/core/common/swagger/keyword.js b/backend/src/core/common/swagger/keyword.js new file mode 100644 index 0000000..5f67ef5 --- /dev/null +++ b/backend/src/core/common/swagger/keyword.js @@ -0,0 +1,9 @@ +import { SwaggerDocument } from '../../../packages/swagger'; + +export const keyword = SwaggerDocument.ApiParams({ + name: 'keyword', + paramsIn: 'query', + required: false, + type: 'string', + description: 'keyword is used to query search', +}); diff --git a/backend/src/core/common/swagger/page-size.js b/backend/src/core/common/swagger/page-size.js new file mode 100644 index 0000000..0a9a787 --- /dev/null +++ b/backend/src/core/common/swagger/page-size.js @@ -0,0 +1,9 @@ +import { SwaggerDocument } from '../../../packages/swagger'; + +export const size = SwaggerDocument.ApiParams({ + name: 'size', + paramsIn: 'query', + required: false, + type: 'number', + description: 'Number of items per page', +}); diff --git a/backend/src/core/common/swagger/page.js b/backend/src/core/common/swagger/page.js new file mode 100644 index 0000000..450f920 --- /dev/null +++ b/backend/src/core/common/swagger/page.js @@ -0,0 +1,9 @@ +import { SwaggerDocument } from '../../../packages/swagger'; + +export const page = SwaggerDocument.ApiParams({ + name: 'page', + paramsIn: 'query', + required: false, + type: 'number', + description: 'get current page', +}); diff --git a/backend/src/core/database/migrations/20240201073309_users.js b/backend/src/core/database/migrations/20240201073309_users.js index f19ad0f..d4e039b 100644 --- a/backend/src/core/database/migrations/20240201073309_users.js +++ b/backend/src/core/database/migrations/20240201073309_users.js @@ -17,6 +17,8 @@ exports.up = async knex => { table.dateTime('birthday').defaultTo(null); table.string('address').defaultTo(null); table.dateTime('deleted_at').defaultTo(null); + table.decimal('latitude'); + table.decimal('longitude'); table.timestamps(false, true); }); @@ -29,4 +31,7 @@ exports.up = async knex => { `); }; -exports.down = knex => knex.schema.dropTable(tableName); +exports.down = async knex => { + await knex.schema.dropTable(tableName); + await knex.raw(`DROP TRIGGER IF EXISTS update_timestamp ON ${tableName};`); +}; diff --git a/backend/src/core/database/migrations/20240209194811_add_column_latiude_longitude_users.js b/backend/src/core/database/migrations/20240209194811_add_column_latiude_longitude_users.js deleted file mode 100644 index 7c2f7e0..0000000 --- a/backend/src/core/database/migrations/20240209194811_add_column_latiude_longitude_users.js +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-check -/** - * @param {import("knex")} knex - */ -const tableName = 'users'; -exports.up = async knex => { - await knex.schema.alterTable(tableName, table => { - table.double('latitude').nullable(); - table.double('longitude').nullable(); - }); -}; - -exports.down = knex => knex.schema.table(tableName, table => { - table.dropColumn('latitude'); - table.dropColumn('longitude'); -}); diff --git a/backend/src/core/database/migrations/20240307043035_create_posts.js b/backend/src/core/database/migrations/20240307043035_create_posts.js new file mode 100644 index 0000000..4d6dc0b --- /dev/null +++ b/backend/src/core/database/migrations/20240307043035_create_posts.js @@ -0,0 +1,33 @@ +/** + * @param { import("knex").Knex } knex + */ +const tableName = 'posts'; +exports.up = async knex => { + await knex.schema.createTable(tableName, table => { + table.increments('id').unsigned().primary(); + table.text('content'); + table + .integer('owner_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + await knex.raw(` + CREATE TRIGGER update_timestamp + BEFORE UPDATE + ON ${tableName} + FOR EACH ROW + EXECUTE PROCEDURE update_timestamp(); + `); +}; +/** + * @param { import("knex").Knex } knex + */ +exports.down = async knex => { + await knex.schema.dropTable(tableName); + await knex.raw(`DROP TRIGGER IF EXISTS update_timestamp ON ${tableName};`); +}; diff --git a/backend/src/core/database/migrations/20240307043251_create_user_reacts.js b/backend/src/core/database/migrations/20240307043251_create_user_reacts.js new file mode 100644 index 0000000..8498a5e --- /dev/null +++ b/backend/src/core/database/migrations/20240307043251_create_user_reacts.js @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + */ +const tableName = 'user_reacts'; +exports.up = async knex => { + await knex.schema.createTable(tableName, table => { + table + .integer('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.integer('reactable_id').unsigned().notNullable(); + table.integer('reactable_type').unsigned().notNullable(); + table.integer('react_type').unsigned().notNullable(); + table.primary(['user_id', 'reactable_id', 'reactable_type']); + }); +}; +/** + * @param { import("knex").Knex } knex + */ +exports.down = async knex => { + await knex.schema.dropTable(tableName); +}; diff --git a/backend/src/core/database/migrations/20240307043923_create_comments.js b/backend/src/core/database/migrations/20240307043923_create_comments.js new file mode 100644 index 0000000..b4fb08d --- /dev/null +++ b/backend/src/core/database/migrations/20240307043923_create_comments.js @@ -0,0 +1,35 @@ +/** + * @param { import("knex").Knex } knex + */ +const tableName = 'comments'; +exports.up = async knex => { + await knex.schema.createTable(tableName, table => { + table.increments('id').unsigned().primary(); + table.integer('parent_id').unsigned().notNullable(); + table.integer('parent_type').unsigned().notNullable(); + table.text('content'); + table + .integer('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + await knex.raw(` + CREATE TRIGGER update_timestamp + BEFORE UPDATE + ON ${tableName} + FOR EACH ROW + EXECUTE PROCEDURE update_timestamp(); + `); +}; +/** + * @param { import("knex").Knex } knex + */ +exports.down = async knex => { + await knex.schema.dropTable(tableName); + await knex.raw(`DROP TRIGGER IF EXISTS update_timestamp ON ${tableName};`); +}; diff --git a/backend/src/core/database/seeds/01-users.js b/backend/src/core/database/seeds/01-users.js index 0bf1971..b894dad 100644 --- a/backend/src/core/database/seeds/01-users.js +++ b/backend/src/core/database/seeds/01-users.js @@ -1,29 +1,24 @@ /** * @param {import("knex")} knex */ +import { fakerVI } from '@faker-js/faker'; -exports.seed = knex => knex('users') - .del() - .then(() => knex('users').insert([ - { - full_name: 'User1', - email: 'user1@gmail.com', - phone_number: '0938734952', - latitude: 40.73061, - longitude: -73.935242, - }, - { - full_name: 'User2', - email: 'user2@gmail.com', - phone_number: '0938734343', - latitude: 40.73061, - longitude: -73.62520, - }, - { - full_name: 'User3', - email: 'user3@gmail.com', - phone_number: '0784944433', - latitude: 41.73061, - longitude: -73.935242 - }, - ])); +export const numUsers = 100; +const tableName = 'users'; +exports.seed = async knex => { + await knex(tableName).del(); + + // eslint-disable-next-line no-unused-vars + const users = Array.from({ length: numUsers }, (_, index) => ({ + full_name: fakerVI.person.fullName(), + email: fakerVI.internet.email(), + phone_number: fakerVI.string.numeric({ length: 10 }), + avatar: fakerVI.image.avatar(), + birthday: fakerVI.date.birthdate(), + address: fakerVI.location.streetAddress(), + latitude: fakerVI.location.latitude(), + longitude: fakerVI.location.longitude(), + })); + + await knex(tableName).insert(users); +}; diff --git a/backend/src/core/database/seeds/04-posts.js b/backend/src/core/database/seeds/04-posts.js new file mode 100644 index 0000000..edb0b2f --- /dev/null +++ b/backend/src/core/database/seeds/04-posts.js @@ -0,0 +1,19 @@ +/** + * @param {import("knex")} knex + */ +import { fakerVI } from '@faker-js/faker'; +import { numUsers } from './01-users'; + +export const numPosts = 50; +const tableName = 'posts'; +exports.seed = async knex => { + await knex(tableName).del(); + + // eslint-disable-next-line no-unused-vars + const posts = Array.from({ length: numUsers }, (_, index) => ({ + content: fakerVI.lorem.sentence(), + owner_id: fakerVI.number.int({ min: 1, max: numUsers }), + })); + + await knex(tableName).insert(posts); +}; diff --git a/backend/src/core/database/seeds/05-comments.js b/backend/src/core/database/seeds/05-comments.js new file mode 100644 index 0000000..9c0356c --- /dev/null +++ b/backend/src/core/database/seeds/05-comments.js @@ -0,0 +1,23 @@ +/** + * @param {import("knex")} knex + */ +import { fakerVI } from '@faker-js/faker'; +import { numUsers } from './01-users'; +import { numPosts } from './04-posts'; +import { COMMENT_PARENT_TYPE } from '../../modules/comment/comment.const'; + +export const numComments = 50; +const tableName = 'comments'; +exports.seed = async knex => { + await knex(tableName).del(); + + // eslint-disable-next-line no-unused-vars + const comments = Array.from({ length: numUsers }, (_, index) => ({ + content: fakerVI.lorem.sentence(), + parent_type: COMMENT_PARENT_TYPE.POST, + parent_id: fakerVI.number.int({ min: 1, max: numPosts }), + user_id: fakerVI.number.int({ min: 1, max: numUsers }), + })); + + await knex(tableName).insert(comments); +}; diff --git a/backend/src/core/database/seeds/06-user_reacts.js b/backend/src/core/database/seeds/06-user_reacts.js new file mode 100644 index 0000000..5e6ff2e --- /dev/null +++ b/backend/src/core/database/seeds/06-user_reacts.js @@ -0,0 +1,43 @@ +/** + * @param {import("knex")} knex + */ +import { fakerVI } from '@faker-js/faker'; +import { numUsers } from './01-users'; +import { numPosts } from './04-posts'; +import { numComments } from './05-comments'; +import { REACTION_OF_TYPE } from '../../modules/user-react/user-react.const'; + +const tableName = 'user_reacts'; +exports.seed = async knex => { + await knex(tableName).del(); + const reactionArray = Object.values(REACTION_OF_TYPE); + // eslint-disable-next-line no-unused-vars + const userReactPosts = Array.from({ length: numUsers }, (_, index) => ({ + reactable_type: REACTION_OF_TYPE.POST, + user_id: index + 1, + reactable_id: fakerVI.number.int({ min: 1, max: numPosts }), + react_type: + reactionArray[ + fakerVI.number.int({ + max: reactionArray.length - 1, + }) + ], + })); + + await knex(tableName).insert(userReactPosts); + + // eslint-disable-next-line no-unused-vars + const userReactComments = Array.from({ length: numUsers }, (_, index) => ({ + reactable_type: REACTION_OF_TYPE.COMMENT, + user_id: index + 1, + reactable_id: fakerVI.number.int({ min: 1, max: numComments }), + react_type: + reactionArray[ + fakerVI.number.int({ + max: reactionArray.length - 1, + }) + ], + })); + + await knex(tableName).insert(userReactComments); +}; diff --git a/backend/src/core/modules/comment/comment.const.js b/backend/src/core/modules/comment/comment.const.js new file mode 100644 index 0000000..6d67d75 --- /dev/null +++ b/backend/src/core/modules/comment/comment.const.js @@ -0,0 +1,5 @@ +export const COMMENT_PARENT_TYPE = { POST: 0, COMMENT: 1 }; +export const COMMENT_PARENT_TYPE_IN_REQUEST = { + POST: 'POST', + COMMENT: 'COMMENT', +}; diff --git a/backend/src/core/modules/comment/dto/comment.add.dto.js b/backend/src/core/modules/comment/dto/comment.add.dto.js new file mode 100644 index 0000000..c95465a --- /dev/null +++ b/backend/src/core/modules/comment/dto/comment.add.dto.js @@ -0,0 +1,21 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; +import { + COMMENT_PARENT_TYPE, + COMMENT_PARENT_TYPE_IN_REQUEST, +} from '../comment.const'; + +ApiDocument.addModel('AddCommentDto', { + parentId: SwaggerDocument.ApiProperty({ type: 'int' }), + parentType: SwaggerDocument.ApiProperty({ + type: 'enum', + model: COMMENT_PARENT_TYPE_IN_REQUEST, + }), + content: SwaggerDocument.ApiProperty({ type: 'string' }), +}); + +export const AddCommentDto = body => ({ + parentId: body.parentId, + parentType: COMMENT_PARENT_TYPE[body.parentType], + content: body.content, +}); diff --git a/backend/src/core/modules/comment/dto/comment.dto.js b/backend/src/core/modules/comment/dto/comment.dto.js new file mode 100644 index 0000000..9792e59 --- /dev/null +++ b/backend/src/core/modules/comment/dto/comment.dto.js @@ -0,0 +1,18 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; + +ApiDocument.addModel('CommentDto', { + id: SwaggerDocument.ApiProperty({ type: 'int' }), + content: SwaggerDocument.ApiProperty({ type: 'string' }), + updatedAt: SwaggerDocument.ApiProperty({ type: 'dateTime' }), + totalLikes: SwaggerDocument.ApiProperty({ type: 'int' }), + totalDisLikes: SwaggerDocument.ApiProperty({ type: 'int' }), +}); + +export const CommentDto = comment => ({ + id: comment.id, + content: comment.content, + updatedAt: comment.updatedAt, + totalLikes: comment.totalLikes, + totalDisLikes: comment.totalDisLikes, +}); diff --git a/backend/src/core/modules/comment/dto/index.js b/backend/src/core/modules/comment/dto/index.js new file mode 100644 index 0000000..adfee5b --- /dev/null +++ b/backend/src/core/modules/comment/dto/index.js @@ -0,0 +1,3 @@ +export * from './comment.add.dto'; +export * from './comment.dto'; +export * from './pagination-comment.dto'; diff --git a/backend/src/core/modules/comment/dto/pagination-comment.dto.js b/backend/src/core/modules/comment/dto/pagination-comment.dto.js new file mode 100644 index 0000000..f2f10c9 --- /dev/null +++ b/backend/src/core/modules/comment/dto/pagination-comment.dto.js @@ -0,0 +1,17 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; + +ApiDocument.addModel('PaginationCommentDto', { + data: SwaggerDocument.ApiProperty({ + type: 'array', + model: 'CommentDto', + }), + totalPages: SwaggerDocument.ApiProperty({ type: 'int', readOnly: true }), + totalElements: SwaggerDocument.ApiProperty({ type: 'int', readOnly: true }), +}); + +export const PaginationCommentDto = pageable => ({ + data: pageable.data, + totalPages: Math.ceil(pageable.total / pageable.pageSize), + totalElements: pageable.total, +}); diff --git a/backend/src/core/modules/comment/index.js b/backend/src/core/modules/comment/index.js new file mode 100644 index 0000000..ee205d9 --- /dev/null +++ b/backend/src/core/modules/comment/index.js @@ -0,0 +1,4 @@ +export * from './service'; +export * from './repository'; +export * from './dto'; +export * from './comment.const'; diff --git a/backend/src/core/modules/comment/repository/comment.repository.js b/backend/src/core/modules/comment/repository/comment.repository.js new file mode 100644 index 0000000..bd792a2 --- /dev/null +++ b/backend/src/core/modules/comment/repository/comment.repository.js @@ -0,0 +1,33 @@ +import { DataRepository } from 'packages/restBuilder/core/dataHandler'; + +class Repository extends DataRepository { + findByParentIdAndType(offset, pageSize, parentId, parentType) { + return this.query() + .where('comments.parent_id', '=', parentId) + .andWhere('comments.parent_type', '=', parentType) + .select('comments.id', 'comments.content', { + updatedAt: 'comments.updated_at', + }) + .offset(offset) + .limit(pageSize); + } + + countByParentIdAndType(parentId, parentType) { + return this.query() + .where('comments.parent_id', '=', parentId) + .andWhere('comments.parent_type', '=', parentType) + .count('comments.id') + .first(); + } + + insertOne(parent_id, parent_type, content, user_id) { + return this.query().insert({ + parent_id, + parent_type, + content, + user_id, + }); + } +} + +export const CommentRepository = new Repository('comments'); diff --git a/backend/src/core/modules/comment/repository/index.js b/backend/src/core/modules/comment/repository/index.js new file mode 100644 index 0000000..79896d6 --- /dev/null +++ b/backend/src/core/modules/comment/repository/index.js @@ -0,0 +1,2 @@ +export * from './comment.repository'; +export * from './user-react.repository'; diff --git a/backend/src/core/modules/comment/repository/user-react.repository.js b/backend/src/core/modules/comment/repository/user-react.repository.js new file mode 100644 index 0000000..acbfe61 --- /dev/null +++ b/backend/src/core/modules/comment/repository/user-react.repository.js @@ -0,0 +1,24 @@ +import { REACTION_TYPE } from 'core/modules/user-react/user-react.const'; +import { DataRepository } from 'packages/restBuilder/core/dataHandler'; + +class Repository extends DataRepository { + countLikes(type, id) { + return this.query() + .where('user_reacts.reactable_id', '=', id) + .andWhere('user_reacts.reactable_type', '=', type) + .andWhere('user_reacts.react_type', '=', REACTION_TYPE.LIKE) + .count('*') + .first(); + } + + countDisLikes(type, id) { + return this.query() + .where('user_reacts.reactable_id', '=', id) + .andWhere('user_reacts.reactable_type', '=', type) + .andWhere('user_reacts.react_type', '=', REACTION_TYPE.DISLIKE) + .count('*') + .first(); + } +} + +export const UserReactRepository = new Repository('user_reacts'); diff --git a/backend/src/core/modules/comment/service/comment.service.js b/backend/src/core/modules/comment/service/comment.service.js new file mode 100644 index 0000000..132b81a --- /dev/null +++ b/backend/src/core/modules/comment/service/comment.service.js @@ -0,0 +1,63 @@ +import { REACTION_OF_TYPE } from 'core/modules/user-react/user-react.const'; +import { CommentDto, PaginationCommentDto } from '../dto'; +import { CommentRepository, UserReactRepository } from '../repository'; + +class Service { + constructor() { + this.commentRepository = CommentRepository; + this.userReactRepository = UserReactRepository; + } + + async createComment(parentId, parentType, content, userId){ + await this.commentRepository.insertOne( + parentId, + parentType, + content, + userId, + ); + } + + async getCommentPaginationByParentIdAndType( + parentId, + parentType, + page, + size, + ) { + const offset = (page - 1) * size; + const total = await this.commentRepository.countByParentIdAndType( + parentId, + parentType, + ); + const data = await this.commentRepository.findByParentIdAndType( + offset, + size, + parentId, + parentType, + ); + const commentWithReactions = await Promise.all( + data.map(async e => { + const totalLikes = await this.userReactRepository.countLikes( + REACTION_OF_TYPE.COMMENT, + e.id, + ); + const totalDisLikes = + await this.userReactRepository.countDisLikes( + REACTION_OF_TYPE.COMMENT, + e.id, + ); + return { + ...e, + totalLikes: parseInt(totalLikes.count, 10), + totalDisLikes: parseInt(totalDisLikes.count, 10), + }; + }), + ); + return PaginationCommentDto({ + data: commentWithReactions.map(e => CommentDto(e)), + pageSize: size, + total: parseInt(total.count, 10), + }); + } +} + +export const CommentService = new Service(); diff --git a/backend/src/core/modules/comment/service/index.js b/backend/src/core/modules/comment/service/index.js new file mode 100644 index 0000000..8de243b --- /dev/null +++ b/backend/src/core/modules/comment/service/index.js @@ -0,0 +1 @@ +export * from './comment.service'; diff --git a/backend/src/core/modules/user-react/dto/index.js b/backend/src/core/modules/user-react/dto/index.js new file mode 100644 index 0000000..d14f9c9 --- /dev/null +++ b/backend/src/core/modules/user-react/dto/index.js @@ -0,0 +1,2 @@ +export * from './user-react.add.dto'; +export * from './user-react.delete.dto'; diff --git a/backend/src/core/modules/user-react/dto/user-react.add.dto.js b/backend/src/core/modules/user-react/dto/user-react.add.dto.js new file mode 100644 index 0000000..6453a4c --- /dev/null +++ b/backend/src/core/modules/user-react/dto/user-react.add.dto.js @@ -0,0 +1,26 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; +import { + REACTION_IN_REQUEST_OF_TYPE, + REACTION_OF_TYPE, + REACTION_TYPE, + REACTION_TYPE_IN_REQUEST, +} from '../user-react.const'; + +ApiDocument.addModel('AddUserReactDto', { + reactableId: SwaggerDocument.ApiProperty({ type: 'int' }), + reactableType: SwaggerDocument.ApiProperty({ + type: 'enum', + model: REACTION_IN_REQUEST_OF_TYPE, + }), + reactType: SwaggerDocument.ApiProperty({ + type: 'enum', + model: REACTION_TYPE_IN_REQUEST, + }), +}); + +export const AddUserReactDto = body => ({ + reactableId: body.reactableId, + reactableType: REACTION_OF_TYPE[body.reactableType], + reactType: REACTION_TYPE[body.reactType], +}); diff --git a/backend/src/core/modules/user-react/dto/user-react.delete.dto.js b/backend/src/core/modules/user-react/dto/user-react.delete.dto.js new file mode 100644 index 0000000..ea3e775 --- /dev/null +++ b/backend/src/core/modules/user-react/dto/user-react.delete.dto.js @@ -0,0 +1,19 @@ +import { ApiDocument } from 'core/config/swagger.config'; +import { SwaggerDocument } from 'packages/swagger'; +import { + REACTION_IN_REQUEST_OF_TYPE, + REACTION_OF_TYPE, +} from '../user-react.const'; + +ApiDocument.addModel('DeleteUserReactDto', { + reactableId: SwaggerDocument.ApiProperty({ type: 'int' }), + reactableType: SwaggerDocument.ApiProperty({ + type: 'enum', + model: REACTION_IN_REQUEST_OF_TYPE, + }), +}); + +export const DeleteUserReactDto = body => ({ + reactableId: body.reactableId, + reactableType: REACTION_OF_TYPE[body.reactableType], +}); diff --git a/backend/src/core/modules/user-react/index.js b/backend/src/core/modules/user-react/index.js new file mode 100644 index 0000000..ed76d56 --- /dev/null +++ b/backend/src/core/modules/user-react/index.js @@ -0,0 +1,4 @@ +export * from './service'; +export * from './repository'; +export * from './dto'; +export * from './user-react.const'; diff --git a/backend/src/core/modules/user-react/repository/index.js b/backend/src/core/modules/user-react/repository/index.js new file mode 100644 index 0000000..c628c75 --- /dev/null +++ b/backend/src/core/modules/user-react/repository/index.js @@ -0,0 +1 @@ +export * from './user-react.repository'; diff --git a/backend/src/core/modules/user-react/repository/user-react.repository.js b/backend/src/core/modules/user-react/repository/user-react.repository.js new file mode 100644 index 0000000..68bbc1e --- /dev/null +++ b/backend/src/core/modules/user-react/repository/user-react.repository.js @@ -0,0 +1,50 @@ +import { DataRepository } from 'packages/restBuilder/core/dataHandler'; +import { REACTION_TYPE } from '../user-react.const'; + +class Repository extends DataRepository { + countLikes(type, id) { + return this.query() + .where('user_reacts.reactable_id', '=', id) + .andWhere('user_reacts.reactable_type', '=', type) + .andWhere('user_reacts.react_type', '=', REACTION_TYPE.LIKE) + .count('user_reacts.id') + .first(); + } + + countDisLikes(type, id) { + return this.query() + .where('user_reacts.reactable_id', '=', id) + .andWhere('user_reacts.reactable_type', '=', type) + .andWhere('user_reacts.react_type', '=', REACTION_TYPE.DISLIKE) + .count('user_reacts.id') + .first(); + } + + upsertOne(user_id, reactable_id, reactable_type, react_type, trx = null) { + let query = this.query() + .insert({ + user_id, + reactable_id, + reactable_type, + react_type, + }) + .onConflict(['user_id', 'reactable_id', 'reactable_type']) + .merge(); + if (trx) query = query.transacting(trx); + return query; + } + + deleteOne(user_id, reactable_id, reactable_type, trx = null) { + let query = this.query() + .where({ + user_id, + reactable_id, + reactable_type, + }) + .del(); + if (trx) query = query.transacting(trx); + return query; + } +} + +export const UserReactRepository = new Repository('user_reacts'); diff --git a/backend/src/core/modules/user-react/service/index.js b/backend/src/core/modules/user-react/service/index.js new file mode 100644 index 0000000..3482fd7 --- /dev/null +++ b/backend/src/core/modules/user-react/service/index.js @@ -0,0 +1 @@ +export * from './user-react.service'; diff --git a/backend/src/core/modules/user-react/service/user-react.service.js b/backend/src/core/modules/user-react/service/user-react.service.js new file mode 100644 index 0000000..394666f --- /dev/null +++ b/backend/src/core/modules/user-react/service/user-react.service.js @@ -0,0 +1,47 @@ +import { getTransaction } from 'core/database'; +import { logger } from 'packages/logger'; +import { InternalServerException } from 'packages/httpException'; +import { UserReactRepository } from '../repository'; + +class Service { + constructor() { + this.userReactRepository = UserReactRepository; + } + + async createUserReact(userId, reactableId, reactableType, reactType) { + const trx = await getTransaction(); + try { + await this.userReactRepository.upsertOne( + userId, + reactableId, + reactableType, + reactType, + trx, + ); + } catch (error) { + trx.rollback(); + logger.error(error.message); + throw new InternalServerException(); + } + trx.commit(); + } + + async deleteUserReact(userId, reactableId, reactableType) { + const trx = await getTransaction(); + try { + await this.userReactRepository.deleteOne( + userId, + reactableId, + reactableType, + trx, + ); + } catch (error) { + trx.rollback(); + logger.error(error.message); + throw new InternalServerException(); + } + trx.commit(); + } +} + +export const UserReactService = new Service(); diff --git a/backend/src/core/modules/user-react/user-react.const.js b/backend/src/core/modules/user-react/user-react.const.js new file mode 100644 index 0000000..79d139b --- /dev/null +++ b/backend/src/core/modules/user-react/user-react.const.js @@ -0,0 +1,7 @@ +export const REACTION_TYPE = { LIKE: 0, DISLIKE: 1 }; +export const REACTION_TYPE_IN_REQUEST = { + LIKE: 'LIKE', + DISLIKE: 'DISLIKE', +}; +export const REACTION_OF_TYPE = { POST: 0, COMMENT: 1 }; +export const REACTION_IN_REQUEST_OF_TYPE = { POST: 'POST', COMMENT: 'COMMENT' }; diff --git a/backend/src/packages/swagger/core/core.js b/backend/src/packages/swagger/core/core.js index 9f267ba..6b8c3aa 100644 --- a/backend/src/packages/swagger/core/core.js +++ b/backend/src/packages/swagger/core/core.js @@ -7,43 +7,59 @@ export class SwaggerBuilder { return new SwaggerBuilder(); } + #deepIterate = obj => { + Object.keys(obj).forEach(key => { + const value = obj[key]; + + // If the key is '$ref', update the value + if (key === '$ref') { + obj[key] = `#/components/schemas/${value}`; + } + + // Recursively iterate if the value is an object or an array + if (typeof value === 'object' && value !== null) { + this.#deepIterate(value); + } + }); + }; + + #configureResponseSchema = model => { + this.#deepIterate(model); + return model; + }; + #toResponseSuccess = model => ({ 200: { description: 'successful operation', - content: model ? { - 'application/json': { - schema: { - type: 'array', - items: { - $ref: `#/components/schemas/${model}`, - }, + content: model + ? { + 'application/json': { + schema: this.#configureResponseSchema(model), }, - }, - } : '', + } + : '', }, - }) + }); #toErrors = errors => { const responses = {}; errors.forEach(error => { if (!error.status || !error.description) { - throw new Error('Error in swagger must contain status and description'); + throw new Error( + 'Error in swagger must contain status and description', + ); } responses[error.status] = { - description: error.description + description: error.description, }; }); return responses; - } + }; addConfig(options) { const { - openapi, - info, - servers, - auth, - basePath, + openapi, info, servers, auth, basePath } = options; this.instance.openapi = openapi; @@ -92,7 +108,7 @@ export class SwaggerBuilder { body, params = [], consumes = [], - errors = [] + errors = [], } = options; const responses = {}; @@ -103,30 +119,32 @@ export class SwaggerBuilder { this.instance.paths[route][method] = { tags: tags.length ? tags : [tags], description, - security: security ? [ - { - bearerAuth: [], - }, - ] : [], - produces: [ - 'application/json', - ], + security: security + ? [ + { + bearerAuth: [], + }, + ] + : [], + produces: ['application/json'], consumes, parameters: params, - requestBody: body ? { - content: { - 'application/json': { - schema: { - $ref: `#/components/schemas/${body}`, + requestBody: body + ? { + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/${body}`, + }, }, }, - }, - required: true, - } : {}, + required: true, + } + : {}, responses: { ...responses, ...this.#toResponseSuccess(model), - ...this.#toErrors(errors) + ...this.#toErrors(errors), }, }; } @@ -138,7 +156,7 @@ export class SwaggerBuilder { items: { type: 'object', properties, - } + }, }; } else { this.instance.components.schemas[name] = { diff --git a/backend/src/packages/swagger/core/document.js b/backend/src/packages/swagger/core/document.js index bdf0206..ea13ebb 100644 --- a/backend/src/packages/swagger/core/document.js +++ b/backend/src/packages/swagger/core/document.js @@ -6,7 +6,7 @@ export class SwaggerDocument { static DEFAULT_GENERATOR = 'string'; - static PRIMITIVE_TYPES = ['string', 'int', 'dateTime', 'bool']; + static PRIMITIVE_TYPES = ['string', 'int', 'dateTime', 'bool', 'date']; static type = { string: { @@ -20,6 +20,10 @@ export class SwaggerDocument { type: 'string', format: 'date-time', }, + date: { + type: 'string', + format: 'date', + }, bool: { type: 'boolean', default: true, @@ -36,27 +40,27 @@ export class SwaggerDocument { type: 'array', items: { ...SwaggerDocument.type[item], - ...params - } + ...params, + }, }; } return { type: 'array', items: { $ref: `#/components/schemas/${item}`, - } + }, }; }, enum: (enumModel, params = {}) => ({ type: 'string', enum: Object.values(enumModel), - ...params + ...params, }), model: (dtoModel, params = {}) => ({ $ref: `#/components/schemas/${dtoModel}`, - ...params + ...params, }), - } + }; /** * @@ -72,9 +76,7 @@ export class SwaggerDocument { example, } = options; let swaggerType; - if (type === 'enum' - || type === 'model' - || type === 'array') { + if (type === 'enum' || type === 'model' || type === 'array') { swaggerType = SwaggerDocument.type[type](model, { example }); } else { swaggerType = SwaggerDocument.type[type]; @@ -90,7 +92,7 @@ export class SwaggerDocument { /** * - * @param {{type?: DocumentType, model?: string, required?: boolean, readOnly?: boolean, example?: string, name?: string, paramsIn?: string, description?: string}} options + * @param {{type?: DocumentType, model?: string, required?: boolean, readOnly?: boolean, example?: string, name?: string, paramsIn?: string, description?: string, defaultValue?: string}} options * @returns */ static ApiParams(options) { @@ -101,14 +103,12 @@ export class SwaggerDocument { paramsIn = 'query', required = true, example, - description + description, } = options; let swaggerType; - if (type === 'enum' - || type === 'model' - || type === 'array') { + if (type === 'enum' || type === 'model' || type === 'array') { swaggerType = SwaggerDocument.type[type](model, { example }); } else { swaggerType = SwaggerDocument.type[type]; @@ -120,7 +120,7 @@ export class SwaggerDocument { schema: swaggerType, required, example, - description + description, }; } diff --git a/backend/yarn.lock b/backend/yarn.lock index e9d0930..c1c2e4d 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1154,6 +1154,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@faker-js/faker@^8.4.1": + version "8.4.1" + resolved "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz" + integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg== + "@fastify/busboy@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@fastify/busboy/-/busboy-1.2.1.tgz"