diff --git a/apps/backend/src/core/httpServer/httpServer.ts b/apps/backend/src/core/httpServer/httpServer.ts index 50f5a2d..6152eb1 100644 --- a/apps/backend/src/core/httpServer/httpServer.ts +++ b/apps/backend/src/core/httpServer/httpServer.ts @@ -16,6 +16,7 @@ import { SecurityMode } from '../../common/types/http/securityMode.js'; import { type DependencyInjectionContainer } from '../../libs/dependencyInjection/dependencyInjectionContainer.js'; import { type LoggerService } from '../../libs/logger/services/loggerService/loggerService.js'; import { type GroupHttpController } from '../../modules/groupModule/api/httpControllers/groupHttpController/groupHttpController.js'; +import { type PostHttpController } from '../../modules/groupModule/api/httpControllers/postHttpController/postHttpController.js'; import { groupSymbols } from '../../modules/groupModule/symbols.js'; import { type UserGroupHttpController } from '../../modules/userGroupModule/api/httpControllers/userGroupHttpController/userGroupHttpController.js'; import { userGroupSymbols } from '../../modules/userGroupModule/symbols.js'; @@ -51,6 +52,7 @@ export class HttpServer { this.container.get(symbols.applicationHttpController), this.container.get(groupSymbols.groupHttpController), this.container.get(userGroupSymbols.userGroupHttpController), + this.container.get(groupSymbols.postHttpController), ]; } diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/groupHttpController/groupHttpController.ts b/apps/backend/src/modules/groupModule/api/httpControllers/groupHttpController/groupHttpController.ts index e43edca..8846774 100644 --- a/apps/backend/src/modules/groupModule/api/httpControllers/groupHttpController/groupHttpController.ts +++ b/apps/backend/src/modules/groupModule/api/httpControllers/groupHttpController/groupHttpController.ts @@ -83,7 +83,7 @@ export class GroupHttpController implements HttpController { }, }, }, - securityMode: SecurityMode.basicAuth, + securityMode: SecurityMode.bearerToken, }), new HttpRoute({ description: 'Update Group name.', @@ -101,7 +101,7 @@ export class GroupHttpController implements HttpController { }, }, }, - securityMode: SecurityMode.basicAuth, + securityMode: SecurityMode.bearerToken, path: ':id/name', }), new HttpRoute({ @@ -178,7 +178,7 @@ export class GroupHttpController implements HttpController { private async createGroup( request: HttpRequest, ): Promise> { - this.accessControlService.verifyBasicAuth({ + this.accessControlService.verifyBearerToken({ authorizationHeader: request.headers['authorization'], }); @@ -198,7 +198,7 @@ export class GroupHttpController implements HttpController { private async updateGroupName( request: HttpRequest, ): Promise> { - this.accessControlService.verifyBasicAuth({ + this.accessControlService.verifyBearerToken({ authorizationHeader: request.headers['authorization'], }); @@ -220,7 +220,7 @@ export class GroupHttpController implements HttpController { private async deleteGroup( request: HttpRequest, ): Promise> { - this.accessControlService.verifyBasicAuth({ + this.accessControlService.verifyBearerToken({ authorizationHeader: request.headers['authorization'], }); diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/postHttpController.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/postHttpController.ts new file mode 100644 index 0000000..9eb2b09 --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/postHttpController.ts @@ -0,0 +1,222 @@ +import { + createPostBodyDTOSchema, + createPostResponseBodyDTOSchema, + type CreatePostBodyDTO, + type CreatePostResponseBodyDTO, + type CreatePostPathParamsDTO, +} from './schema/createPostSchema.js'; +import { + deletePostPathParamsDTOSchema, + deletePostResponseBodyDTOSchema, + type DeletePostPathParamsDTO, + type DeletePostResponseBodyDTO, +} from './schema/deletePostSchema.js'; +import { + findPostsResponseBodyDTOSchema, + type FindPostsResponseBodyDTO, + type FindPostsPathParamsDTO, +} from './schema/findPostsSchema.js'; +import { type PostDTO } from './schema/postDTO.js'; +import { + updatePostBodyDTOSchema, + updatePostPathParamsDTOSchema, + updatePostResponseBodyDTOSchema, + type UpdatePostBodyDTO, + type UpdatePostPathParamsDTO, + type UpdatePostResponseBodyDTO, +} from './schema/updatePostSchema.js'; +import { type HttpController } from '../../../../../common/types/http/httpController.js'; +import { HttpMethodName } from '../../../../../common/types/http/httpMethodName.js'; +import { type HttpRequest } from '../../../../../common/types/http/httpRequest.js'; +import { + type HttpOkResponse, + type HttpCreatedResponse, + type HttpNoContentResponse, +} from '../../../../../common/types/http/httpResponse.js'; +import { HttpRoute } from '../../../../../common/types/http/httpRoute.js'; +import { HttpStatusCode } from '../../../../../common/types/http/httpStatusCode.js'; +import { SecurityMode } from '../../../../../common/types/http/securityMode.js'; +import { type AccessControlService } from '../../../../authModule/application/services/accessControlService/accessControlService.js'; +import { type CreatePostCommandHandler } from '../../../application/commandHandlers/createPostCommandHandler/createPostCommandHandler.js'; +import { type DeletePostCommandHandler } from '../../../application/commandHandlers/deletePostCommandHandler/deletePostCommandHandler.js'; +import { type UpdatePostCommandHandler } from '../../../application/commandHandlers/updatePostCommandHandler/updatePostCommandHandler.js'; +import { type FindPostsQueryHandler } from '../../../application/queryHandlers/findPostsQueryHandler/findPostsQueryHandler.js'; +import { type Post } from '../../../domain/entities/post/post.js'; + +export class PostHttpController implements HttpController { + public basePath = '/api/groups/:groupId/posts'; + public tags = ['Post']; + + public constructor( + private readonly createPostCommandHandler: CreatePostCommandHandler, + private readonly updatePostCommandHandler: UpdatePostCommandHandler, + private readonly deletePostCommandHandler: DeletePostCommandHandler, + private readonly findPostsQueryHandler: FindPostsQueryHandler, + private readonly accessControlService: AccessControlService, + ) {} + + public getHttpRoutes(): HttpRoute[] { + return [ + new HttpRoute({ + description: 'Create post.', + handler: this.createPost.bind(this), + method: HttpMethodName.post, + schema: { + request: { + body: createPostBodyDTOSchema, + }, + response: { + [HttpStatusCode.created]: { + description: 'Post created.', + schema: createPostResponseBodyDTOSchema, + }, + }, + }, + securityMode: SecurityMode.bearerToken, + }), + new HttpRoute({ + description: 'Update Post content.', + handler: this.updatePost.bind(this), + method: HttpMethodName.patch, + schema: { + request: { + pathParams: updatePostPathParamsDTOSchema, + body: updatePostBodyDTOSchema, + }, + response: { + [HttpStatusCode.ok]: { + description: 'Post content updated.', + schema: updatePostResponseBodyDTOSchema, + }, + }, + }, + securityMode: SecurityMode.bearerToken, + path: ':id/name', + }), + new HttpRoute({ + description: 'Delete post.', + handler: this.deletePost.bind(this), + method: HttpMethodName.delete, + schema: { + request: { + pathParams: deletePostPathParamsDTOSchema, + }, + response: { + [HttpStatusCode.noContent]: { + description: 'Post deleted.', + schema: deletePostResponseBodyDTOSchema, + }, + }, + }, + path: ':id', + }), + new HttpRoute({ + description: 'Find posts.', + handler: this.findPosts.bind(this), + method: HttpMethodName.get, + schema: { + request: {}, + response: { + [HttpStatusCode.ok]: { + description: 'Posts found.', + schema: findPostsResponseBodyDTOSchema, + }, + }, + }, + securityMode: SecurityMode.bearerToken, + }), + ]; + } + + private async createPost( + request: HttpRequest, + ): Promise> { + const { userId } = await this.accessControlService.verifyBearerToken({ + authorizationHeader: request.headers['authorization'], + }); + + const { groupId } = request.pathParams; + + const { content } = request.body; + + const { post } = await this.createPostCommandHandler.execute({ + userId, + groupId, + content, + }); + + return { + body: this.mapPostToDTO(post), + statusCode: HttpStatusCode.created, + }; + } + + private async updatePost( + request: HttpRequest, + ): Promise> { + this.accessControlService.verifyBearerToken({ + authorizationHeader: request.headers['authorization'], + }); + + const { id } = request.pathParams; + + const { content } = request.body; + + const { post } = await this.updatePostCommandHandler.execute({ + id, + content, + }); + + return { + body: this.mapPostToDTO(post), + statusCode: HttpStatusCode.ok, + }; + } + + private async deletePost( + request: HttpRequest, + ): Promise> { + this.accessControlService.verifyBearerToken({ + authorizationHeader: request.headers['authorization'], + }); + + const { id } = request.pathParams; + + await this.deletePostCommandHandler.execute({ id }); + + return { + statusCode: HttpStatusCode.noContent, + body: null, + }; + } + + // TODO: check if user is a member of this group + private async findPosts( + request: HttpRequest, + ): Promise> { + await this.accessControlService.verifyBearerToken({ + authorizationHeader: request.headers['authorization'], + }); + + const { groupId } = request.pathParams; + + const { posts } = await this.findPostsQueryHandler.execute({ groupId }); + + return { + body: { + data: posts.map(this.mapPostToDTO), + }, + statusCode: HttpStatusCode.ok, + }; + } + + private mapPostToDTO(post: Post): PostDTO { + return { + id: post.getId(), + groupId: post.getGroupId(), + userId: post.getUserId(), + content: post.getContent(), + createdAt: post.getCreatedAt().toISOString(), + }; + } +} diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/createPostSchema.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/createPostSchema.ts new file mode 100644 index 0000000..db30a3e --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/createPostSchema.ts @@ -0,0 +1,31 @@ +import { type Static, Type } from '@sinclair/typebox'; + +import type * as contracts from '@common/contracts'; + +import { postDTO } from './postDTO.js'; +import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; + +export const createPostPathParamsDTOSchema = Type.Object({ + groupId: Type.String({ format: 'uuid' }), +}); + +export type CreatePostPathParamsDTO = TypeExtends< + contracts.CreatePostPathParams, + Static +>; + +export const createPostBodyDTOSchema = Type.Object({ + content: Type.String({ + minLength: 1, + maxLength: 256, + }), +}); + +export type CreatePostBodyDTO = TypeExtends>; + +export const createPostResponseBodyDTOSchema = postDTO; + +export type CreatePostResponseBodyDTO = TypeExtends< + contracts.CreatePostResponseBody, + Static +>; diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/deletePostSchema.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/deletePostSchema.ts new file mode 100644 index 0000000..f8ce728 --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/deletePostSchema.ts @@ -0,0 +1,18 @@ +import { type Static, Type } from '@sinclair/typebox'; + +import type * as contracts from '@common/contracts'; + +import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; + +export const deletePostPathParamsDTOSchema = Type.Object({ + id: Type.String({ format: 'uuid' }), +}); + +export type DeletePostPathParamsDTO = TypeExtends< + contracts.DeletePostPathParams, + Static +>; + +export const deletePostResponseBodyDTOSchema = Type.Null(); + +export type DeletePostResponseBodyDTO = Static; diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/findPostsSchema.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/findPostsSchema.ts new file mode 100644 index 0000000..7975de0 --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/findPostsSchema.ts @@ -0,0 +1,24 @@ +import { type Static, Type } from '@sinclair/typebox'; + +import type * as contracts from '@common/contracts'; + +import { postDTO } from './postDTO.js'; +import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; + +export const findPostsPathParamsDTOSchema = Type.Object({ + groupId: Type.String({ format: 'uuid' }), +}); + +export type FindPostsPathParamsDTO = TypeExtends< + contracts.FindPostsPathParams, + Static +>; + +export const findPostsResponseBodyDTOSchema = Type.Object({ + data: Type.Array(postDTO), +}); + +export type FindPostsResponseBodyDTO = TypeExtends< + Static, + contracts.FindPostsResponseBody +>; diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/postDTO.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/postDTO.ts new file mode 100644 index 0000000..88dac75 --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/postDTO.ts @@ -0,0 +1,11 @@ +import { type Static, Type } from '@sinclair/typebox'; + +export const postDTO = Type.Object({ + id: Type.String(), + userId: Type.String(), + groupId: Type.String(), + content: Type.String(), + createdAt: Type.String(), +}); + +export type PostDTO = Static; diff --git a/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/updatePostSchema.ts b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/updatePostSchema.ts new file mode 100644 index 0000000..b00e71f --- /dev/null +++ b/apps/backend/src/modules/groupModule/api/httpControllers/postHttpController/schema/updatePostSchema.ts @@ -0,0 +1,31 @@ +import { type Static, Type } from '@sinclair/typebox'; + +import type * as contracts from '@common/contracts'; + +import { postDTO } from './postDTO.js'; +import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; + +export const updatePostPathParamsDTOSchema = Type.Object({ + id: Type.String({ format: 'uuid' }), +}); + +export type UpdatePostPathParamsDTO = TypeExtends< + contracts.UpdatePostPathParams, + Static +>; + +export const updatePostBodyDTOSchema = Type.Object({ + content: Type.String({ + minLength: 1, + maxLength: 256, + }), +}); + +export type UpdatePostBodyDTO = TypeExtends>; + +export const updatePostResponseBodyDTOSchema = postDTO; + +export type UpdatePostResponseBodyDTO = TypeExtends< + contracts.UpdatePostResponseBody, + Static +>; diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandler.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandler.ts new file mode 100644 index 0000000..1bc46e1 --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandler.ts @@ -0,0 +1,14 @@ +import { type CommandHandler } from '../../../../../common/types/commandHandler.js'; +import { type Post } from '../../../domain/entities/post/post.js'; + +export interface CreatePostPayload { + readonly userId: string; + readonly groupId: string; + readonly content: string; +} + +export interface CreatePostResult { + readonly post: Post; +} + +export type CreatePostCommandHandler = CommandHandler; diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.integration.test.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.integration.test.ts new file mode 100644 index 0000000..6acb56c --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.integration.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Generator } from '@common/tests'; + +import { type CreatePostCommandHandler } from './createPostCommandHandler.js'; +import { testSymbols } from '../../../../../../tests/container/symbols.js'; +import { TestContainer } from '../../../../../../tests/container/testContainer.js'; +import { OperationNotValidError } from '../../../../../common/errors/operationNotValidError.js'; +import { type UserTestUtils } from '../../../../userModule/tests/utils/userTestUtils/userTestUtils.js'; +import { symbols } from '../../../symbols.js'; +import { type GroupTestUtils } from '../../../tests/utils/groupTestUtils/groupTestUtils.js'; +import { type PostTestUtils } from '../../../tests/utils/postTestUtils/postTestUtils.js'; + +describe('CreatePostCommandHandlerImpl', () => { + let commandHandler: CreatePostCommandHandler; + + let postTestUtils: PostTestUtils; + + let userTestUtils: UserTestUtils; + + let groupTestUtils: GroupTestUtils; + + beforeEach(async () => { + const container = TestContainer.create(); + + commandHandler = container.get(symbols.createPostCommandHandler); + + postTestUtils = container.get(testSymbols.postTestUtils); + + userTestUtils = container.get(testSymbols.userTestUtils); + + groupTestUtils = container.get(testSymbols.groupTestUtils); + + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + afterEach(async () => { + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + it('creates Post', async () => { + const content = Generator.words(5); + + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const { post } = await commandHandler.execute({ + content, + groupId: group.id, + userId: user.id, + }); + + expect(post.getState()).toEqual({ + content, + groupId: group.id, + userId: user.id, + }); + + const persistedPost = await postTestUtils.findById(post.getId()); + + expect(persistedPost).not.toBeNull(); + }); + + it('throws error - when User was not found', async () => { + const content = Generator.words(5); + + const group = await groupTestUtils.createAndPersist(); + + await expect(async () => { + await commandHandler.execute({ + content, + groupId: group.id, + userId: Generator.uuid(), + }); + }).toThrowErrorInstance({ + instance: OperationNotValidError, + }); + }); + + it('throws error - when Group was not found', async () => { + const content = Generator.words(5); + + const user = await userTestUtils.createAndPersist(); + + await expect(async () => { + await commandHandler.execute({ + content, + groupId: Generator.uuid(), + userId: user.id, + }); + }).toThrowErrorInstance({ + instance: OperationNotValidError, + }); + }); +}); diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.ts new file mode 100644 index 0000000..cb68daf --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.ts @@ -0,0 +1,65 @@ +import { + type CreatePostCommandHandler, + type CreatePostPayload, + type CreatePostResult, +} from './createPostCommandHandler.js'; +import { OperationNotValidError } from '../../../../../common/errors/operationNotValidError.js'; +import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js'; +import { type UserRepository } from '../../../../userModule/domain/repositories/userRepository/userRepository.js'; +import { type GroupRepository } from '../../../domain/repositories/groupRepository/groupRepository.js'; +import { type PostRepository } from '../../../domain/repositories/postRepository/postRepository.js'; + +export class CreatePostCommandHandlerImpl implements CreatePostCommandHandler { + public constructor( + private readonly postRepository: PostRepository, + private readonly userRepository: UserRepository, + private readonly groupRepository: GroupRepository, + private readonly loggerService: LoggerService, + ) {} + + public async execute(payload: CreatePostPayload): Promise { + const { userId, groupId, content } = payload; + + this.loggerService.debug({ + message: 'Creating Post...', + userId, + groupId, + content: content.substring(0, 20), + }); + + const user = await this.userRepository.findUser({ id: userId }); + + if (!user) { + throw new OperationNotValidError({ + reason: 'User not found.', + id: userId, + }); + } + + const group = await this.groupRepository.findGroup({ id: groupId }); + + if (!group) { + throw new OperationNotValidError({ + reason: 'Group not found.', + id: groupId, + }); + } + + const post = await this.postRepository.savePost({ + post: { + groupId, + userId, + content, + }, + }); + + this.loggerService.debug({ + message: 'Post created.', + id: post.getId(), + groupId, + userId, + }); + + return { post }; + } +} diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandler.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandler.ts new file mode 100644 index 0000000..90dd4d9 --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandler.ts @@ -0,0 +1,7 @@ +import { type CommandHandler } from '../../../../../common/types/commandHandler.js'; + +export interface DeletePostPayload { + readonly id: string; +} + +export type DeletePostCommandHandler = CommandHandler; diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.integration.test.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.integration.test.ts new file mode 100644 index 0000000..8265993 --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.integration.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Generator } from '@common/tests'; + +import { type DeletePostCommandHandler } from './deletePostCommandHandler.js'; +import { testSymbols } from '../../../../../../tests/container/symbols.js'; +import { TestContainer } from '../../../../../../tests/container/testContainer.js'; +import { ResourceNotFoundError } from '../../../../../common/errors/resourceNotFoundError.js'; +import { type UserTestUtils } from '../../../../userModule/tests/utils/userTestUtils/userTestUtils.js'; +import { symbols } from '../../../symbols.js'; +import { type GroupTestUtils } from '../../../tests/utils/groupTestUtils/groupTestUtils.js'; +import { type PostTestUtils } from '../../../tests/utils/postTestUtils/postTestUtils.js'; + +describe('DeletePostCommandHandler', () => { + let commandHandler: DeletePostCommandHandler; + + let postTestUtils: PostTestUtils; + + let userTestUtils: UserTestUtils; + + let groupTestUtils: GroupTestUtils; + + beforeEach(async () => { + const container = TestContainer.create(); + + commandHandler = container.get(symbols.deletePostCommandHandler); + + postTestUtils = container.get(testSymbols.postTestUtils); + + userTestUtils = container.get(testSymbols.userTestUtils); + + groupTestUtils = container.get(testSymbols.groupTestUtils); + + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + afterEach(async () => { + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + it('throws an error - when Post does not exist', async () => { + const invalidUuid = Generator.uuid(); + + await expect(async () => { + await commandHandler.execute({ id: invalidUuid }); + }).toThrowErrorInstance({ + instance: ResourceNotFoundError, + context: { + name: 'Post', + id: invalidUuid, + }, + }); + }); + + it('deletes the Post', async () => { + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const post = await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + await commandHandler.execute({ id: post.id }); + + const foundPost = await postTestUtils.findById(post.id); + + expect(foundPost).toBeNull(); + }); +}); diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.ts new file mode 100644 index 0000000..e1d5408 --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.ts @@ -0,0 +1,38 @@ +import { type DeletePostCommandHandler, type DeletePostPayload } from './deletePostCommandHandler.js'; +import { ResourceNotFoundError } from '../../../../../common/errors/resourceNotFoundError.js'; +import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js'; +import { type PostRepository } from '../../../domain/repositories/postRepository/postRepository.js'; + +export class DeletePostCommandHandlerImpl implements DeletePostCommandHandler { + public constructor( + private readonly postRepository: PostRepository, + private readonly loggerService: LoggerService, + ) {} + + public async execute(payload: DeletePostPayload): Promise { + const { id } = payload; + + this.loggerService.debug({ + message: 'Deleting Post...', + id, + }); + + const post = await this.postRepository.findPost({ + id, + }); + + if (!post) { + throw new ResourceNotFoundError({ + name: 'Post', + id, + }); + } + + await this.postRepository.deletePost({ id: post.getId() }); + + this.loggerService.debug({ + message: 'Post deleted.', + id, + }); + } +} diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandler.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandler.ts new file mode 100644 index 0000000..47ef65a --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandler.ts @@ -0,0 +1,13 @@ +import { type CommandHandler } from '../../../../../common/types/commandHandler.js'; +import { type Post } from '../../../domain/entities/post/post.js'; + +export interface UpdatePostPayload { + readonly id: string; + readonly content: string; +} + +export interface UpdatePostResult { + readonly post: Post; +} + +export type UpdatePostCommandHandler = CommandHandler; diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.integration.test.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.integration.test.ts new file mode 100644 index 0000000..0d674de --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.integration.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Generator } from '@common/tests'; + +import { type UpdatePostCommandHandler } from './updatePostCommandHandler.js'; +import { testSymbols } from '../../../../../../tests/container/symbols.js'; +import { TestContainer } from '../../../../../../tests/container/testContainer.js'; +import { ResourceNotFoundError } from '../../../../../common/errors/resourceNotFoundError.js'; +import { type UserTestUtils } from '../../../../userModule/tests/utils/userTestUtils/userTestUtils.js'; +import { symbols } from '../../../symbols.js'; +import { type GroupTestUtils } from '../../../tests/utils/groupTestUtils/groupTestUtils.js'; +import { type PostTestUtils } from '../../../tests/utils/postTestUtils/postTestUtils.js'; + +describe('UpdatePostCommandHandler', () => { + let commandHandler: UpdatePostCommandHandler; + + let postTestUtils: PostTestUtils; + + let userTestUtils: UserTestUtils; + + let groupTestUtils: GroupTestUtils; + + beforeEach(async () => { + const container = TestContainer.create(); + + commandHandler = container.get(symbols.updatePostCommandHandler); + + postTestUtils = container.get(testSymbols.postTestUtils); + + userTestUtils = container.get(testSymbols.userTestUtils); + + groupTestUtils = container.get(testSymbols.groupTestUtils); + + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + afterEach(async () => { + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + it('throws an error - when Post does not exist', async () => { + const invalidUuid = Generator.uuid(); + + await expect( + async () => + await commandHandler.execute({ + id: invalidUuid, + content: Generator.words(2), + }), + ).toThrowErrorInstance({ + instance: ResourceNotFoundError, + context: { + name: 'Post', + id: invalidUuid, + }, + }); + }); + + it('updates Post', async () => { + const content = Generator.words(5); + + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const post = await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + const { post: updatedPost } = await commandHandler.execute({ + id: post.id, + content, + }); + + const foundPost = await postTestUtils.findById(post.id); + + expect(updatedPost.getContent()).toEqual(content); + + expect(foundPost?.content).toEqual(content); + }); +}); diff --git a/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.ts b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.ts new file mode 100644 index 0000000..2f8447f --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.ts @@ -0,0 +1,45 @@ +import { + type UpdatePostCommandHandler, + type UpdatePostPayload, + type UpdatePostResult, +} from './updatePostCommandHandler.js'; +import { ResourceNotFoundError } from '../../../../../common/errors/resourceNotFoundError.js'; +import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js'; +import { type PostRepository } from '../../../domain/repositories/postRepository/postRepository.js'; + +export class UpdatePostCommandHandlerImpl implements UpdatePostCommandHandler { + public constructor( + private readonly postRepository: PostRepository, + private readonly loggerService: LoggerService, + ) {} + + public async execute(payload: UpdatePostPayload): Promise { + const { id, content } = payload; + + this.loggerService.debug({ + message: 'Updating Post...', + id, + content: content.substring(0, 20), + }); + + const existingPost = await this.postRepository.findPost({ id }); + + if (!existingPost) { + throw new ResourceNotFoundError({ + name: 'Post', + id, + }); + } + + existingPost.setContent({ content }); + + const post = await this.postRepository.savePost({ post: existingPost }); + + this.loggerService.debug({ + message: 'Post updated.', + id, + }); + + return { post }; + } +} diff --git a/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandler.ts b/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandler.ts new file mode 100644 index 0000000..e10593e --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandler.ts @@ -0,0 +1,12 @@ +import { type QueryHandler } from '../../../../../common/types/queryHandler.js'; +import { type Post } from '../../../domain/entities/post/post.js'; + +export interface FindPostsPayload { + readonly groupId: string; +} + +export interface FindPostsResult { + readonly posts: Post[]; +} + +export type FindPostsQueryHandler = QueryHandler; diff --git a/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandlerImpl.ts b/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandlerImpl.ts new file mode 100644 index 0000000..12557f2 --- /dev/null +++ b/apps/backend/src/modules/groupModule/application/queryHandlers/findPostsQueryHandler/findPostsQueryHandlerImpl.ts @@ -0,0 +1,14 @@ +import { type FindPostsResult, type FindPostsQueryHandler, type FindPostsPayload } from './findPostsQueryHandler.js'; +import { type PostRepository } from '../../../domain/repositories/postRepository/postRepository.js'; + +export class FindPostsQueryHandlerImpl implements FindPostsQueryHandler { + public constructor(private readonly postRepository: PostRepository) {} + + public async execute(payload: FindPostsPayload): Promise { + const { groupId } = payload; + + const posts = await this.postRepository.findPosts({ groupId }); + + return { posts }; + } +} diff --git a/apps/backend/src/modules/groupModule/domain/entities/post/post.ts b/apps/backend/src/modules/groupModule/domain/entities/post/post.ts new file mode 100644 index 0000000..da7d054 --- /dev/null +++ b/apps/backend/src/modules/groupModule/domain/entities/post/post.ts @@ -0,0 +1,65 @@ +export interface PostDraft { + readonly id: string; + readonly content: string; + readonly userId: string; + readonly groupId: string; + readonly createdAt: Date; +} + +export interface PostState { + content: string; + readonly userId: string; + readonly groupId: string; +} + +export interface SetConentPayload { + readonly content: string; +} + +export class Post { + private readonly id: string; + private readonly createdAt: Date; + private readonly state: PostState; + + public constructor(draft: PostDraft) { + const { id, groupId, content, userId, createdAt } = draft; + + this.id = id; + + this.createdAt = createdAt; + + this.state = { + groupId, + content, + userId, + }; + } + + public getId(): string { + return this.id; + } + + public getContent(): string { + return this.state.content; + } + + public getState(): PostState { + return this.state; + } + + public getUserId(): string { + return this.state.userId; + } + + public getGroupId(): string { + return this.state.groupId; + } + + public getCreatedAt(): Date { + return this.createdAt; + } + + public setContent(payload: SetConentPayload): void { + this.state.content = payload.content; + } +} diff --git a/apps/backend/src/modules/groupModule/domain/repositories/postRepository/postRepository.ts b/apps/backend/src/modules/groupModule/domain/repositories/postRepository/postRepository.ts new file mode 100644 index 0000000..49436c6 --- /dev/null +++ b/apps/backend/src/modules/groupModule/domain/repositories/postRepository/postRepository.ts @@ -0,0 +1,24 @@ +import { type PostState, type Post } from '../../entities/post/post.js'; + +export interface FindPostPayload { + readonly id: string; +} + +export interface SavePostPayload { + readonly post: PostState | Post; +} + +export interface FindPosts { + readonly groupId: string; +} + +export interface DeletePostPayload { + readonly id: string; +} + +export interface PostRepository { + findPost(payload: FindPostPayload): Promise; + findPosts(payload: FindPosts): Promise; + savePost(payload: SavePostPayload): Promise; + deletePost(payload: DeletePostPayload): Promise; +} diff --git a/apps/backend/src/modules/groupModule/groupModule.integration.test.ts b/apps/backend/src/modules/groupModule/groupModule.integration.test.ts index 1ce5524..e4dd357 100644 --- a/apps/backend/src/modules/groupModule/groupModule.integration.test.ts +++ b/apps/backend/src/modules/groupModule/groupModule.integration.test.ts @@ -1,6 +1,7 @@ import { beforeEach, expect, describe, it } from 'vitest'; import { GroupHttpController } from './api/httpControllers/groupHttpController/groupHttpController.js'; +import { PostHttpController } from './api/httpControllers/postHttpController/postHttpController.js'; import { groupSymbols } from './symbols.js'; import { Application } from '../../core/application.js'; import { type DependencyInjectionContainer } from '../../libs/dependencyInjection/dependencyInjectionContainer.js'; @@ -14,5 +15,7 @@ describe('GroupModule', () => { it('declares bindings', async () => { expect(container.get(groupSymbols.groupHttpController)).toBeInstanceOf(GroupHttpController); + + expect(container.get(groupSymbols.postHttpController)).toBeInstanceOf(PostHttpController); }); }); diff --git a/apps/backend/src/modules/groupModule/groupModule.ts b/apps/backend/src/modules/groupModule/groupModule.ts index 098817e..3f4bc47 100644 --- a/apps/backend/src/modules/groupModule/groupModule.ts +++ b/apps/backend/src/modules/groupModule/groupModule.ts @@ -1,20 +1,33 @@ import { GroupHttpController } from './api/httpControllers/groupHttpController/groupHttpController.js'; +import { PostHttpController } from './api/httpControllers/postHttpController/postHttpController.js'; import { type CreateGroupCommandHandler } from './application/commandHandlers/createGroupCommandHandler/createGroupCommandHandler.js'; import { CreateGroupCommandHandlerImpl } from './application/commandHandlers/createGroupCommandHandler/createGroupCommandHandlerImpl.js'; +import { type CreatePostCommandHandler } from './application/commandHandlers/createPostCommandHandler/createPostCommandHandler.js'; +import { CreatePostCommandHandlerImpl } from './application/commandHandlers/createPostCommandHandler/createPostCommandHandlerImpl.js'; import { type DeleteGroupCommandHandler } from './application/commandHandlers/deleteGroupCommandHandler/deleteGroupCommandHandler.js'; import { DeleteGroupCommandHandlerImpl } from './application/commandHandlers/deleteGroupCommandHandler/deleteGroupCommandHandlerImpl.js'; +import { type DeletePostCommandHandler } from './application/commandHandlers/deletePostCommandHandler/deletePostCommandHandler.js'; +import { DeletePostCommandHandlerImpl } from './application/commandHandlers/deletePostCommandHandler/deletePostCommandHandlerImpl.js'; import { type UpdateGroupNameCommandHandler } from './application/commandHandlers/updateGroupNameCommandHandler/updateGroupNameCommandHandler.js'; import { UpdateGroupNameCommandHandlerImpl } from './application/commandHandlers/updateGroupNameCommandHandler/updateGroupNameCommandHandlerImpl.js'; +import { type UpdatePostCommandHandler } from './application/commandHandlers/updatePostCommandHandler/updatePostCommandHandler.js'; +import { UpdatePostCommandHandlerImpl } from './application/commandHandlers/updatePostCommandHandler/updatePostCommandHandlerImpl.js'; import { type FindGroupByIdQueryHandler } from './application/queryHandlers/findGroupByIdQueryHandler/findGroupByIdQueryHandler.js'; import { FindGroupByIdQueryHandlerImpl } from './application/queryHandlers/findGroupByIdQueryHandler/findGroupByIdQueryHandlerImpl.js'; import { type FindGroupByNameQueryHandler } from './application/queryHandlers/findGroupByNameQueryHandler/findGroupByNameQueryHandler.js'; import { FindGroupByNameQueryHandlerImpl } from './application/queryHandlers/findGroupByNameQueryHandler/findGroupByNameQueryHandlerImpl.js'; import { type FindGroupsQueryHandler } from './application/queryHandlers/findGroupsQueryHandler/findGroupsQueryHandler.js'; import { FindGroupsQueryHandlerImpl } from './application/queryHandlers/findGroupsQueryHandler/findGroupsQueryHandlerImpl.js'; +import { type FindPostsQueryHandler } from './application/queryHandlers/findPostsQueryHandler/findPostsQueryHandler.js'; +import { FindPostsQueryHandlerImpl } from './application/queryHandlers/findPostsQueryHandler/findPostsQueryHandlerImpl.js'; import { type GroupRepository } from './domain/repositories/groupRepository/groupRepository.js'; +import { type PostRepository } from './domain/repositories/postRepository/postRepository.js'; import { type GroupMapper } from './infrastructure/repositories/groupRepository/groupMapper/groupMapper.js'; import { GroupMapperImpl } from './infrastructure/repositories/groupRepository/groupMapper/groupMapperImpl.js'; import { GroupRepositoryImpl } from './infrastructure/repositories/groupRepository/groupRepositoryImpl.js'; +import { type PostMapper } from './infrastructure/repositories/postRepository/postMapper/postMapper.js'; +import { PostMapperImpl } from './infrastructure/repositories/postRepository/postMapper/postMapperImpl.js'; +import { PostRepositoryImpl } from './infrastructure/repositories/postRepository/postRepositoryImpl.js'; import { symbols } from './symbols.js'; import { coreSymbols } from '../../core/symbols.js'; import { type DatabaseClient } from '../../libs/database/clients/databaseClient/databaseClient.js'; @@ -24,11 +37,15 @@ import { type LoggerService } from '../../libs/logger/services/loggerService/log import { type UuidService } from '../../libs/uuid/services/uuidService/uuidService.js'; import { type AccessControlService } from '../authModule/application/services/accessControlService/accessControlService.js'; import { authSymbols } from '../authModule/symbols.js'; +import { type UserRepository } from '../userModule/domain/repositories/userRepository/userRepository.js'; +import { userSymbols } from '../userModule/symbols.js'; export class GroupModule implements DependencyInjectionModule { public declareBindings(container: DependencyInjectionContainer): void { container.bind(symbols.groupMapper, () => new GroupMapperImpl()); + container.bind(symbols.postMapper, () => new PostMapperImpl()); + this.bindRepositories(container); this.bindCommandHandlers(container); @@ -48,6 +65,16 @@ export class GroupModule implements DependencyInjectionModule { container.get(coreSymbols.uuidService), ), ); + + container.bind( + symbols.postRepository, + () => + new PostRepositoryImpl( + container.get(coreSymbols.databaseClient), + container.get(symbols.postMapper), + container.get(coreSymbols.uuidService), + ), + ); } private bindCommandHandlers(container: DependencyInjectionContainer): void { @@ -77,6 +104,35 @@ export class GroupModule implements DependencyInjectionModule { container.get(coreSymbols.loggerService), ), ); + + container.bind( + symbols.createPostCommandHandler, + () => + new CreatePostCommandHandlerImpl( + container.get(symbols.postRepository), + container.get(userSymbols.userRepository), + container.get(symbols.groupRepository), + container.get(coreSymbols.loggerService), + ), + ); + + container.bind( + symbols.deletePostCommandHandler, + () => + new DeletePostCommandHandlerImpl( + container.get(symbols.postRepository), + container.get(coreSymbols.loggerService), + ), + ); + + container.bind( + symbols.updatePostCommandHandler, + () => + new UpdatePostCommandHandlerImpl( + container.get(symbols.postRepository), + container.get(coreSymbols.loggerService), + ), + ); } private bindQueryHandlers(container: DependencyInjectionContainer): void { @@ -94,6 +150,11 @@ export class GroupModule implements DependencyInjectionModule { symbols.findGroupsQueryHandler, () => new FindGroupsQueryHandlerImpl(container.get(symbols.groupRepository)), ); + + container.bind( + symbols.findPostsQueryHandler, + () => new FindPostsQueryHandlerImpl(container.get(symbols.postRepository)), + ); } private bindHttpControllers(container: DependencyInjectionContainer): void { @@ -110,5 +171,17 @@ export class GroupModule implements DependencyInjectionModule { container.get(authSymbols.accessControlService), ), ); + + container.bind( + symbols.postHttpController, + () => + new PostHttpController( + container.get(symbols.createPostCommandHandler), + container.get(symbols.updatePostCommandHandler), + container.get(symbols.deletePostCommandHandler), + container.get(symbols.findPostsQueryHandler), + container.get(authSymbols.accessControlService), + ), + ); } } diff --git a/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/groupDatabaseMigrationSource.ts b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/groupDatabaseMigrationSource.ts index 9bd388b..6812505 100644 --- a/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/groupDatabaseMigrationSource.ts +++ b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/groupDatabaseMigrationSource.ts @@ -1,10 +1,11 @@ import { M1CreateGroupTableMigration } from './migrations/m1CreateGroupTableMigration.js'; +import { M2CreatePostTableMigration } from './migrations/m2CreatePostTableMigration.js'; import { type Migration } from '../../../../../libs/database/types/migration.js'; import { type MigrationSource } from '../../../../../libs/database/types/migrationSource.js'; export class GroupDatabaseMigrationSource implements MigrationSource { public async getMigrations(): Promise { - return [new M1CreateGroupTableMigration()]; + return [new M1CreateGroupTableMigration(), new M2CreatePostTableMigration()]; } public getMigrationName(migration: Migration): string { diff --git a/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/migrations/m2CreatePostTableMigration.ts b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/migrations/m2CreatePostTableMigration.ts new file mode 100644 index 0000000..d0a6519 --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/migrations/m2CreatePostTableMigration.ts @@ -0,0 +1,30 @@ +import { type DatabaseClient } from '../../../../../../libs/database/clients/databaseClient/databaseClient.js'; +import { type Migration } from '../../../../../../libs/database/types/migration.js'; + +export class M2CreatePostTableMigration implements Migration { + public readonly name = 'M2CreatePostTableMigration'; + + private readonly tableName = 'posts'; + + public async up(databaseClient: DatabaseClient): Promise { + await databaseClient.schema.createTable(this.tableName, (table) => { + table.text('id').primary(); + + table.text('userId').notNullable(); + + table.text('groupId').notNullable(); + + table.text('content').notNullable(); + + table.dateTime('createdAt').notNullable(); + + table.foreign('userId').references('id').inTable('users').onDelete('CASCADE'); + + table.foreign('groupId').references('id').inTable('groups').onDelete('CASCADE'); + }); + } + + public async down(databaseClient: DatabaseClient): Promise { + await databaseClient.schema.dropTable(this.tableName); + } +} diff --git a/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postRawEntity.ts b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postRawEntity.ts new file mode 100644 index 0000000..00cbecf --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postRawEntity.ts @@ -0,0 +1,7 @@ +export interface PostRawEntity { + readonly id: string; + readonly userId: string; + readonly groupId: string; + readonly content: string; + readonly createdAt: Date; +} diff --git a/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postTable.ts b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postTable.ts new file mode 100644 index 0000000..2a96a2e --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/databases/groupDatabase/tables/postTable/postTable.ts @@ -0,0 +1 @@ +export const postTable = 'posts'; diff --git a/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapper.ts b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapper.ts new file mode 100644 index 0000000..3118c6f --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapper.ts @@ -0,0 +1,7 @@ +import { type Post } from '../../../../domain/entities/post/post.js'; +import { type PostRawEntity } from '../../../databases/groupDatabase/tables/postTable/postRawEntity.js'; + +export interface PostMapper { + mapToDomain(raw: PostRawEntity): Post; + mapToPersistence(domain: Post): PostRawEntity; +} diff --git a/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.ts b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.ts new file mode 100644 index 0000000..3936c84 --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.ts @@ -0,0 +1,25 @@ +import { type PostMapper } from './postMapper.js'; +import { Post } from '../../../../domain/entities/post/post.js'; +import { type PostRawEntity } from '../../../databases/groupDatabase/tables/postTable/postRawEntity.js'; + +export class PostMapperImpl implements PostMapper { + public mapToDomain(raw: PostRawEntity): Post { + return new Post({ + id: raw.id, + content: raw.content, + groupId: raw.groupId, + userId: raw.userId, + createdAt: raw.createdAt, + }); + } + + public mapToPersistence(domain: Post): PostRawEntity { + return { + id: domain.getId(), + content: domain.getContent(), + groupId: domain.getGroupId(), + userId: domain.getUserId(), + createdAt: domain.getCreatedAt(), + }; + } +} diff --git a/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.unit.test.ts b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.unit.test.ts new file mode 100644 index 0000000..d6db662 --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postMapper/postMapperImpl.unit.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, describe, it } from 'vitest'; + +import { PostMapperImpl } from './postMapperImpl.js'; +import { PostTestFactory } from '../../../../tests/factories/postTestFactory/postTestFactory.js'; + +describe('PostMapperImpl', () => { + let postMapperImpl: PostMapperImpl; + + const postTestFactory = new PostTestFactory(); + + beforeEach(async () => { + postMapperImpl = new PostMapperImpl(); + }); + + it('maps from post raw entity to domain post', async () => { + const postEntity = postTestFactory.createRaw(); + + const post = postMapperImpl.mapToDomain(postEntity); + + expect(post).toEqual({ + id: postEntity.id, + createdAt: postEntity.createdAt, + state: { + content: postEntity.content, + userId: postEntity.userId, + groupId: postEntity.groupId, + }, + }); + }); + + it('maps from domain post to post raw entity', () => { + const post = postTestFactory.create(); + + const postRawEntity = postMapperImpl.mapToPersistence(post); + + expect(postRawEntity).toEqual({ + id: post.getId(), + content: post.getContent(), + groupId: post.getGroupId(), + userId: post.getUserId(), + createdAt: post.getCreatedAt(), + }); + }); +}); diff --git a/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.integration.test.ts b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.integration.test.ts new file mode 100644 index 0000000..4b15a4d --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.integration.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Generator } from '@common/tests'; + +import { testSymbols } from '../../../../../../tests/container/symbols.js'; +import { TestContainer } from '../../../../../../tests/container/testContainer.js'; +import { type UserTestUtils } from '../../../../userModule/tests/utils/userTestUtils/userTestUtils.js'; +import { Post } from '../../../domain/entities/post/post.js'; +import { type PostRepository } from '../../../domain/repositories/postRepository/postRepository.js'; +import { symbols } from '../../../symbols.js'; +import { PostTestFactory } from '../../../tests/factories/postTestFactory/postTestFactory.js'; +import { type GroupTestUtils } from '../../../tests/utils/groupTestUtils/groupTestUtils.js'; +import { type PostTestUtils } from '../../../tests/utils/postTestUtils/postTestUtils.js'; + +describe('PostRepositoryImpl', () => { + let postRepository: PostRepository; + + let postTestUtils: PostTestUtils; + + let userTestUtils: UserTestUtils; + + let groupTestUtils: GroupTestUtils; + + const postTestFactory = new PostTestFactory(); + + beforeEach(async () => { + const container = TestContainer.create(); + + postRepository = container.get(symbols.postRepository); + + postTestUtils = container.get(testSymbols.postTestUtils); + + userTestUtils = container.get(testSymbols.userTestUtils); + + groupTestUtils = container.get(testSymbols.groupTestUtils); + + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + afterEach(async () => { + await postTestUtils.truncate(); + + await userTestUtils.truncate(); + + await groupTestUtils.truncate(); + }); + + describe('findById', () => { + it('returns null - when Post was not found', async () => { + const res = await postRepository.findPost({ id: Generator.uuid() }); + + expect(res).toBeNull(); + }); + + it('returns Post', async () => { + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const createdPost = await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + const post = await postRepository.findPost({ id: createdPost.id }); + + expect(post?.getId()).toEqual(createdPost.id); + }); + }); + + describe('findManyByGroup', () => { + it('returns Posts', async () => { + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + const posts = await postRepository.findPosts({ groupId: group.id }); + + expect(posts.length).toBe(1); + + expect(posts[0]?.getGroupId()).toBe(group.id); + }); + }); + + describe('Save', () => { + it('creates Post', async () => { + const content = Generator.word(); + + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const post = await postRepository.savePost({ + post: { + content, + userId: user.id, + groupId: group.id, + }, + }); + + expect(post).toBeInstanceOf(Post); + + expect(post.getState()).toEqual({ + content, + userId: user.id, + groupId: group.id, + }); + + const foundPost = await postTestUtils.findById(post.getId()); + + expect(foundPost).toBeDefined(); + }); + + it('updates Post', async () => { + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const postRawEntity = await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + const newContent = Generator.words(2); + + const post = postTestFactory.create(postRawEntity); + + post.setContent({ content: newContent }); + + const upatedPost = await postRepository.savePost({ + post, + }); + + expect(upatedPost).toBeInstanceOf(Post); + + expect(upatedPost.getContent()).toBe(newContent); + + const persistedPost = await postTestUtils.findById(postRawEntity.id); + + expect(persistedPost?.content).toBe(newContent); + }); + }); + + describe('delete', () => { + it('deletes Post', async () => { + const user = await userTestUtils.createAndPersist(); + + const group = await groupTestUtils.createAndPersist(); + + const createdPost = await postTestUtils.createAndPersist({ + input: { + userId: user.id, + groupId: group.id, + }, + }); + + await postRepository.deletePost({ id: createdPost.id }); + + const deletedPost = await postTestUtils.findById(createdPost.id); + + expect(deletedPost).toBeNull(); + }); + }); +}); diff --git a/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.ts b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.ts new file mode 100644 index 0000000..6369a4b --- /dev/null +++ b/apps/backend/src/modules/groupModule/infrastructure/repositories/postRepository/postRepositoryImpl.ts @@ -0,0 +1,145 @@ +import { type PostMapper } from './postMapper/postMapper.js'; +import { RepositoryError } from '../../../../../common/errors/repositoryError.js'; +import { type DatabaseClient } from '../../../../../libs/database/clients/databaseClient/databaseClient.js'; +import { type UuidService } from '../../../../../libs/uuid/services/uuidService/uuidService.js'; +import { Post, type PostState } from '../../../domain/entities/post/post.js'; +import { + type FindPostPayload, + type PostRepository, + type FindPosts, + type SavePostPayload, + type DeletePostPayload, +} from '../../../domain/repositories/postRepository/postRepository.js'; +import { type PostRawEntity } from '../../databases/groupDatabase/tables/postTable/postRawEntity.js'; +import { postTable } from '../../databases/groupDatabase/tables/postTable/postTable.js'; + +type CreatePostPayload = { post: PostState }; + +type UpdatePostPayload = { post: Post }; + +export class PostRepositoryImpl implements PostRepository { + public constructor( + private readonly databaseClient: DatabaseClient, + private readonly postMapper: PostMapper, + private readonly uuidService: UuidService, + ) {} + + public async findPost(payload: FindPostPayload): Promise { + const { id } = payload; + + let rawEntity: PostRawEntity | undefined; + + try { + rawEntity = await this.databaseClient(postTable).select('*').where({ id }).first(); + } catch (error) { + throw new RepositoryError({ + entity: 'Post', + operation: 'find', + error, + }); + } + + if (!rawEntity) { + return null; + } + + return this.postMapper.mapToDomain(rawEntity); + } + + // TODO: add pagination + public async findPosts(payload: FindPosts): Promise { + const { groupId } = payload; + + let rawEntities: PostRawEntity[]; + + try { + rawEntities = await this.databaseClient(postTable) + .select('*') + .where({ groupId }) + .orderBy('createdAt', 'desc'); + } catch (error) { + throw new RepositoryError({ + entity: 'Post', + operation: 'find', + error, + }); + } + + return rawEntities.map((rawEntity) => this.postMapper.mapToDomain(rawEntity)); + } + + public async savePost(payload: SavePostPayload): Promise { + const { post } = payload; + + if (post instanceof Post) { + return this.update({ post }); + } + + return this.create({ post }); + } + + private async create(payload: CreatePostPayload): Promise { + const { post } = payload; + + let rawEntities: PostRawEntity[]; + + try { + rawEntities = await this.databaseClient(postTable) + .insert({ + id: this.uuidService.generateUuid(), + groupId: post.groupId, + userId: post.userId, + content: post.content, + createdAt: new Date(), + }) + .returning('*'); + } catch (error) { + throw new RepositoryError({ + entity: 'Post', + operation: 'create', + error, + }); + } + + const rawEntity = rawEntities[0] as PostRawEntity; + + return this.postMapper.mapToDomain(rawEntity); + } + + private async update(payload: UpdatePostPayload): Promise { + const { post } = payload; + + let rawEntities: PostRawEntity[]; + + try { + rawEntities = await this.databaseClient(postTable) + .update(post.getState()) + .where({ id: post.getId() }) + .returning('*'); + } catch (error) { + throw new RepositoryError({ + entity: 'Post', + operation: 'update', + error, + }); + } + + const rawEntity = rawEntities[0] as PostRawEntity; + + return this.postMapper.mapToDomain(rawEntity); + } + + public async deletePost(payload: DeletePostPayload): Promise { + const { id } = payload; + + try { + await this.databaseClient(postTable).delete().where({ id }); + } catch (error) { + throw new RepositoryError({ + entity: 'Post', + operation: 'delete', + error, + }); + } + } +} diff --git a/apps/backend/src/modules/groupModule/symbols.ts b/apps/backend/src/modules/groupModule/symbols.ts index 93cc503..d0e3333 100644 --- a/apps/backend/src/modules/groupModule/symbols.ts +++ b/apps/backend/src/modules/groupModule/symbols.ts @@ -10,11 +10,22 @@ export const symbols = { findGroupByNameQueryHandler: Symbol('findGroupByNameQueryHandler'), findGroupByIdQueryHandler: Symbol('findGroupByIdQueryHandler'), + postMapper: Symbol('postMapper'), + postRepository: Symbol('postRepository'), + + createPostCommandHandler: Symbol('createPostommandHandler'), + deletePostCommandHandler: Symbol('deletePostCommandHandler'), + updatePostCommandHandler: Symbol('updatePostCommandHandler'), + + findPostsQueryHandler: Symbol('findPostsQueryHandler'), + groupHttpController: Symbol('groupHttpController'), + postHttpController: Symbol('postHttpController'), }; export const groupSymbols = { groupHttpController: symbols.groupHttpController, + postHttpController: symbols.postHttpController, groupRepository: symbols.groupRepository, groupMapper: symbols.groupMapper, }; diff --git a/apps/backend/src/modules/groupModule/tests/factories/postTestFactory/postTestFactory.ts b/apps/backend/src/modules/groupModule/tests/factories/postTestFactory/postTestFactory.ts new file mode 100644 index 0000000..352712c --- /dev/null +++ b/apps/backend/src/modules/groupModule/tests/factories/postTestFactory/postTestFactory.ts @@ -0,0 +1,28 @@ +import { Generator } from '@common/tests'; + +import { Post, type PostState } from '../../../domain/entities/post/post.js'; +import { type PostRawEntity } from '../../../infrastructure/databases/groupDatabase/tables/postTable/postRawEntity.js'; + +export class PostTestFactory { + public createRaw(overrides: Partial = {}): PostRawEntity { + return { + id: Generator.uuid(), + content: Generator.words(), + groupId: Generator.uuid(), + userId: Generator.uuid(), + createdAt: Generator.pastDate(), + ...overrides, + }; + } + + public create(overrides: Partial = {}): Post { + return new Post({ + id: Generator.uuid(), + content: Generator.words(), + groupId: Generator.uuid(), + userId: Generator.uuid(), + createdAt: Generator.pastDate(), + ...overrides, + }); + } +} diff --git a/apps/backend/src/modules/groupModule/tests/utils/postTestUtils/postTestUtils.ts b/apps/backend/src/modules/groupModule/tests/utils/postTestUtils/postTestUtils.ts new file mode 100644 index 0000000..5a0dfa8 --- /dev/null +++ b/apps/backend/src/modules/groupModule/tests/utils/postTestUtils/postTestUtils.ts @@ -0,0 +1,43 @@ +import { type DatabaseClient } from '../../../../../libs/database/clients/databaseClient/databaseClient.js'; +import { type PostRawEntity } from '../../../infrastructure/databases/groupDatabase/tables/postTable/postRawEntity.js'; +import { postTable } from '../../../infrastructure/databases/groupDatabase/tables/postTable/postTable.js'; +import { PostTestFactory } from '../../factories/postTestFactory/postTestFactory.js'; + +interface CreateAndPersistPayload { + readonly input?: Partial; +} + +export class PostTestUtils { + private readonly postTestFactory = new PostTestFactory(); + + public constructor(private readonly databaseClient: DatabaseClient) {} + + public async createAndPersist(payload: CreateAndPersistPayload = {}): Promise { + const { input } = payload; + + const post = this.postTestFactory.createRaw(input); + + const rawEntities = await this.databaseClient(postTable).insert( + { + id: post.id, + content: post.id, + groupId: post.groupId, + userId: post.userId, + createdAt: post.createdAt, + }, + '*', + ); + + return rawEntities[0] as PostRawEntity; + } + + public async findById(id: string): Promise { + const group = await this.databaseClient(postTable).where({ id }).first(); + + return group || null; + } + + public async truncate(): Promise { + await this.databaseClient(postTable).delete(); + } +} diff --git a/apps/backend/tests/container/symbols.ts b/apps/backend/tests/container/symbols.ts index 928fc1e..71e7262 100644 --- a/apps/backend/tests/container/symbols.ts +++ b/apps/backend/tests/container/symbols.ts @@ -1,4 +1,5 @@ export const testSymbols = { + postTestUtils: Symbol('postTestUtils'), userGroupTestUtils: Symbol('userGroupTestUtils'), groupTestUtils: Symbol('groupTestUtils'), userTestUtils: Symbol('userTestUtils'), diff --git a/apps/backend/tests/container/testContainer.ts b/apps/backend/tests/container/testContainer.ts index ceee1da..182d5d4 100644 --- a/apps/backend/tests/container/testContainer.ts +++ b/apps/backend/tests/container/testContainer.ts @@ -4,6 +4,7 @@ import { coreSymbols } from '../../src/core/symbols.js'; import { type DatabaseClient } from '../../src/libs/database/clients/databaseClient/databaseClient.js'; import { type DependencyInjectionContainer } from '../../src/libs/dependencyInjection/dependencyInjectionContainer.js'; import { GroupTestUtils } from '../../src/modules/groupModule/tests/utils/groupTestUtils/groupTestUtils.js'; +import { PostTestUtils } from '../../src/modules/groupModule/tests/utils/postTestUtils/postTestUtils.js'; import { UserGroupTestUtils } from '../../src/modules/userGroupModule/tests/utils/userGroupTestUtils/userGroupTestUtils.js'; import { type EmailService } from '../../src/modules/userModule/application/services/emailService/emailService.js'; import { symbols as userSymbols } from '../../src/modules/userModule/symbols.js'; @@ -15,6 +16,11 @@ export class TestContainer { public static create(): DependencyInjectionContainer { const container = Application.createContainer(); + container.bind( + testSymbols.postTestUtils, + () => new PostTestUtils(container.get(coreSymbols.databaseClient)), + ); + container.bind( testSymbols.userGroupTestUtils, () => new UserGroupTestUtils(container.get(coreSymbols.databaseClient)), diff --git a/common/contracts/src/index.ts b/common/contracts/src/index.ts index 00d85c5..6c18182 100644 --- a/common/contracts/src/index.ts +++ b/common/contracts/src/index.ts @@ -43,3 +43,13 @@ export * from './schemas/userGroup/findUsersByGroupId.js'; export * from './schemas/userGroup/updateUserGroup.js'; export * from './schemas/userGroup/deleteUserGroup.js'; + +export * from './schemas/post/createPost.js'; + +export * from './schemas/post/findPosts.js'; + +export * from './schemas/post/findPosts.js'; + +export * from './schemas/post/updatePost.js'; + +export * from './schemas/post/deletePost.js'; diff --git a/common/contracts/src/schemas/post/createPost.ts b/common/contracts/src/schemas/post/createPost.ts new file mode 100644 index 0000000..a6805b2 --- /dev/null +++ b/common/contracts/src/schemas/post/createPost.ts @@ -0,0 +1,11 @@ +import { type Post } from './post.js'; + +export interface CreatePostPathParams { + readonly groupId: string; +} + +export interface CreatePostRequestBody { + readonly content: string; +} + +export type CreatePostResponseBody = Post; diff --git a/common/contracts/src/schemas/post/deletePost.ts b/common/contracts/src/schemas/post/deletePost.ts new file mode 100644 index 0000000..4b8e29e --- /dev/null +++ b/common/contracts/src/schemas/post/deletePost.ts @@ -0,0 +1,3 @@ +export interface DeletePostPathParams { + readonly id: string; +} diff --git a/common/contracts/src/schemas/post/findPosts.ts b/common/contracts/src/schemas/post/findPosts.ts new file mode 100644 index 0000000..0685a4a --- /dev/null +++ b/common/contracts/src/schemas/post/findPosts.ts @@ -0,0 +1,9 @@ +import { type Post } from './post.js'; + +export interface FindPostsPathParams { + readonly groupId: string; +} + +export interface FindPostsResponseBody { + readonly data: Post[]; +} diff --git a/common/contracts/src/schemas/post/post.ts b/common/contracts/src/schemas/post/post.ts new file mode 100644 index 0000000..718d68a --- /dev/null +++ b/common/contracts/src/schemas/post/post.ts @@ -0,0 +1,7 @@ +export interface Post { + readonly id: string; + readonly userId: string; + readonly groupId: string; + readonly content: string; + readonly createdAt: string; +} diff --git a/common/contracts/src/schemas/post/updatePost.ts b/common/contracts/src/schemas/post/updatePost.ts new file mode 100644 index 0000000..be1bf0a --- /dev/null +++ b/common/contracts/src/schemas/post/updatePost.ts @@ -0,0 +1,11 @@ +import { type Post } from './post.js'; + +export interface UpdatePostPathParams { + readonly id: string; +} + +export interface UpdatePostRequestBody { + readonly content: string; +} + +export type UpdatePostResponseBody = Post; diff --git a/docker-compose.yml b/docker-compose.yml index 984e0f9..76ce10f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,3 @@ services: - POSTGRES_USER=local - POSTGRES_PASSWORD=local - POSTGRES_DB=neighbourly - volumes: - - ./data:/var/lib/postgresql/data