diff --git a/.gitignore b/.gitignore index d1a58db5..3c5667f7 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ packages/ui/src/icons # Database data/ + +#IDE +.idea diff --git a/packages/api/src/module/shared/commands/uncomplete-task.application-command.ts b/packages/api/src/module/shared/commands/uncomplete-task.application-command.ts new file mode 100644 index 00000000..ea28dad3 --- /dev/null +++ b/packages/api/src/module/shared/commands/uncomplete-task.application-command.ts @@ -0,0 +1,4 @@ +import { UncompleteTask } from '@/commands/uncomplete-task.domain-command'; +import { AbstractApplicationCommand } from '@/module/application-command-events'; + +export class UncompleteTaskApplicationCommand extends AbstractApplicationCommand {} diff --git a/packages/api/src/module/shared/commands/uncomplete-task.domain-command.ts b/packages/api/src/module/shared/commands/uncomplete-task.domain-command.ts new file mode 100644 index 00000000..b0a99c49 --- /dev/null +++ b/packages/api/src/module/shared/commands/uncomplete-task.domain-command.ts @@ -0,0 +1,4 @@ +export type UncompleteTask = { + type: 'UncompleteTask'; + data: { learningMaterialsId: string; taskId: string }; +}; diff --git a/packages/api/src/module/write/learning-materials-tasks/application/complete-task.command-handler.ts b/packages/api/src/module/write/learning-materials-tasks/application/complete-task.command-handler.ts index 3bf718c2..20c74d9a 100644 --- a/packages/api/src/module/write/learning-materials-tasks/application/complete-task.command-handler.ts +++ b/packages/api/src/module/write/learning-materials-tasks/application/complete-task.command-handler.ts @@ -2,7 +2,7 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CompleteTaskApplicationCommand } from '@/commands/complete-task.application-command'; -import { TaskWasCompleted } from '@/module/events/task-was-completed.domain-event'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; import { APPLICATION_SERVICE, ApplicationService } from '@/write/shared/application/application-service'; import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object'; @@ -18,7 +18,7 @@ export class CompleteTaskCommandHandler implements ICommandHandler { const eventStream = EventStreamName.from('LearningMaterialsTasks', command.data.learningMaterialsId); - await this.applicationService.execute( + await this.applicationService.execute( eventStream, { causationId: command.id, diff --git a/packages/api/src/module/write/learning-materials-tasks/application/uncomplete-task.command-handler.ts b/packages/api/src/module/write/learning-materials-tasks/application/uncomplete-task.command-handler.ts new file mode 100644 index 00000000..d793717c --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/application/uncomplete-task.command-handler.ts @@ -0,0 +1,29 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { UncompleteTaskApplicationCommand } from '@/module/commands/uncomplete-task.application-command'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; +import { uncompleteTask } from '@/write/learning-materials-tasks/domain/uncomplete-task'; +import { APPLICATION_SERVICE, ApplicationService } from '@/write/shared/application/application-service'; +import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object'; + +@CommandHandler(UncompleteTaskApplicationCommand) +export class UncompleteTaskCommandHandler implements ICommandHandler { + constructor( + @Inject(APPLICATION_SERVICE) + private readonly applicationService: ApplicationService, + ) {} + + async execute(command: UncompleteTaskApplicationCommand): Promise { + const eventStream = EventStreamName.from('LearningMaterialsTasks', command.data.learningMaterialsId); + + await this.applicationService.execute( + eventStream, + { + causationId: command.id, + correlationId: command.metadata.correlationId, + }, + (pastEvents) => uncompleteTask(pastEvents, command), + ); + } +} diff --git a/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.spec.ts b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.spec.ts index f9e2e2fe..5c664c72 100644 --- a/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.spec.ts +++ b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.spec.ts @@ -1,5 +1,7 @@ +import { TaskWasUncompleted } from '@/events/task-was-uncompleted-event.domain-event'; import { CompleteTask } from '@/module/commands/complete-task.domain-command'; import { TaskWasCompleted } from '@/module/events/task-was-completed.domain-event'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; import { completeTask } from './complete-task'; @@ -61,4 +63,50 @@ describe('complete task', () => { // Then expect(events).toThrowError('Task was already completed'); }); + + it('should complete uncompleted task', () => { + // given + const pastEvents: TaskWasUncompleted[] = [ + { + type: 'TaskWasUncompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]; + + // when + const events = completeTask(pastEvents, command); + + // then + expect(events).toStrictEqual([ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]); + }); + + it('should complete task if task was completed and then uncompleted', () => { + // given + const pastEvents: LearningMaterialsTasksDomainEvent[] = [ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + { + type: 'TaskWasUncompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]; + + // when + const events = completeTask(pastEvents, command); + + // then + expect(events).toStrictEqual([ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]); + }); }); diff --git a/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.ts b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.ts index 0d03642b..352fa6a9 100644 --- a/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.ts +++ b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.ts @@ -1,8 +1,9 @@ import { TaskWasCompleted } from '@/events/task-was-completed.domain-event'; import { CompleteTask } from '@/module/commands/complete-task.domain-command'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; export function completeTask( - pastEvents: TaskWasCompleted[], + pastEvents: LearningMaterialsTasksDomainEvent[], { data: { learningMaterialsId, taskId } }: CompleteTask, ): TaskWasCompleted[] { const state = pastEvents @@ -13,6 +14,9 @@ export function completeTask( case 'TaskWasCompleted': { return { completed: true }; } + case 'TaskWasUncompleted': { + return { completed: false }; + } default: { return acc; } diff --git a/packages/api/src/module/write/learning-materials-tasks/domain/events.ts b/packages/api/src/module/write/learning-materials-tasks/domain/events.ts new file mode 100644 index 00000000..30e33d52 --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/domain/events.ts @@ -0,0 +1,4 @@ +import { TaskWasCompleted } from '@/events/task-was-completed.domain-event'; +import { TaskWasUncompleted } from '@/events/task-was-uncompleted-event.domain-event'; + +export type LearningMaterialsTasksDomainEvent = TaskWasCompleted | TaskWasUncompleted; diff --git a/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.spec.ts b/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.spec.ts new file mode 100644 index 00000000..6882769a --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.spec.ts @@ -0,0 +1,60 @@ +import { UncompleteTask } from '@/commands/uncomplete-task.domain-command'; +import { TaskWasCompleted } from '@/events/task-was-completed.domain-event'; +import { TaskWasUncompleted } from '@/events/task-was-uncompleted-event.domain-event'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; +import { uncompleteTask } from '@/write/learning-materials-tasks/domain/uncomplete-task'; + +describe('uncomplete task', () => { + const command: UncompleteTask = { + type: 'UncompleteTask', + data: { learningMaterialsId: 'sbAPITNMsl2wW6j2cg1H2A', taskId: 'L9EXtwmBNBXgo_qh0uzbq' }, + }; + + it('should uncomplete completed task', () => { + // Given + const pastEvents: TaskWasCompleted[] = [ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: 'L9EXtwmBNBXgo_qh0uzbq' }, + }, + ]; + + // When + const events = uncompleteTask(pastEvents, command); + + // Then + expect(events).toStrictEqual([ + { + type: 'TaskWasUncompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]); + }); + + it('should throw an error if try to uncomplete uncompleted task', () => { + // given + const pastEvents: TaskWasUncompleted[] = [ + { + type: 'TaskWasUncompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]; + + // when + const events = () => uncompleteTask(pastEvents, command); + + // then + expect(events).toThrowError('Can not uncomplete task that was not completed yet.'); + }); + + it('should throw an error if try to uncomplete task that was neither completed nor uncompleted yet', () => { + // given + const pastEvents: LearningMaterialsTasksDomainEvent[] = []; + + // when + const events = () => uncompleteTask(pastEvents, command); + + // then + expect(events).toThrowError('Can not uncomplete task that was not completed yet.'); + }); +}); diff --git a/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.ts b/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.ts new file mode 100644 index 00000000..2ddd058a --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/domain/uncomplete-task.ts @@ -0,0 +1,41 @@ +import { UncompleteTask } from '@/commands/uncomplete-task.domain-command'; +import { TaskWasUncompleted } from '@/events/task-was-uncompleted-event.domain-event'; +import { LearningMaterialsTasksDomainEvent } from '@/write/learning-materials-tasks/domain/events'; + +export function uncompleteTask( + pastEvents: LearningMaterialsTasksDomainEvent[], + { data: { learningMaterialsId, taskId } }: UncompleteTask, +): TaskWasUncompleted[] { + const state = pastEvents + .filter(({ data }) => data.taskId === taskId) + .reduce<{ completed: boolean }>( + (acc, event) => { + switch (event.type) { + case 'TaskWasCompleted': { + return { completed: true }; + } + case 'TaskWasUncompleted': { + return { completed: false }; + } + default: { + return acc; + } + } + }, + { completed: false }, + ); + + if (!state.completed) { + throw new Error('Can not uncomplete task that was not completed yet.'); + } + + const newEvent: TaskWasUncompleted = { + type: 'TaskWasUncompleted', + data: { + taskId, + learningMaterialsId, + }, + }; + + return [newEvent]; +} diff --git a/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.spec.ts b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.spec.ts index 72ba47df..d80d63ab 100644 --- a/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.spec.ts +++ b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.spec.ts @@ -1,22 +1,41 @@ import { AsyncReturnType } from 'type-fest'; +import { UncompleteTaskApplicationCommand } from '@/commands/uncomplete-task.application-command'; +import { TaskWasUncompleted } from '@/events/task-was-uncompleted-event.domain-event'; import { CompleteTaskApplicationCommand } from '@/module/commands/complete-task.application-command'; import { TaskWasCompleted } from '@/module/events/task-was-completed.domain-event'; import { EventStreamName } from '../shared/application/event-stream-name.value-object'; import { learningMaterialsTasksTestModule } from './learning-materials-tasks.test-module'; +enum CommandType { + COMPLETE_TASK = 'Complete Task', + UNCOMPLETE_TASK = 'Uncomplete Task', +} + describe('learning materials tasks', () => { let module: AsyncReturnType; - const commandBuilder = (taskId = 'VmkxXnPG02CaUNV8Relzk', learningMaterialsId = 'ZpMpw2eh1llFCGKZJEN6r') => ({ - class: CompleteTaskApplicationCommand, - type: 'CompleteTask', + const commandBuilder = ( + type: string, + taskId = 'VmkxXnPG02CaUNV8Relzk', + learningMaterialsId = 'ZpMpw2eh1llFCGKZJEN6r', + ) => ({ + class: type === CommandType.COMPLETE_TASK ? CompleteTaskApplicationCommand : UncompleteTaskApplicationCommand, + type, data: { taskId, learningMaterialsId }, }); + beforeEach(async () => { + module = await learningMaterialsTasksTestModule(); + }); + + afterEach(async () => { + await module.close(); + }); + it('should change state of the task to complete', async () => { // Given - const command = commandBuilder(); + const command = commandBuilder(CommandType.COMPLETE_TASK); // When await module.executeCommand(() => command); @@ -34,7 +53,7 @@ describe('learning materials tasks', () => { it('should not change task state if task is already completed', async () => { // Given - const command = commandBuilder(); + const command = commandBuilder(CommandType.COMPLETE_TASK); // When await module.executeCommand(() => command); @@ -43,11 +62,34 @@ describe('learning materials tasks', () => { await expect(() => module.executeCommand(() => command)).rejects.toThrow(); }); - beforeEach(async () => { - module = await learningMaterialsTasksTestModule(); + it('should change state of the task to uncomplete when task was completed already', async () => { + // Given + const completeCommand = commandBuilder(CommandType.COMPLETE_TASK); + const uncompleteCommand = commandBuilder(CommandType.UNCOMPLETE_TASK); + + await module.executeCommand(() => completeCommand); + + // When + await module.executeCommand(() => uncompleteCommand); + + // Then + module.expectEventPublishedLastly({ + type: 'TaskWasUncompleted', + data: { + learningMaterialsId: uncompleteCommand.data.learningMaterialsId, + taskId: uncompleteCommand.data.taskId, + }, + streamName: EventStreamName.from('LearningMaterialsTasks', uncompleteCommand.data.learningMaterialsId), + }); }); - afterEach(async () => { - await module.close(); + it('should not change state of the task to uncomplete if task was not completed before', async () => { + // Given + const uncompleteCommand = commandBuilder(CommandType.UNCOMPLETE_TASK); + + // When&Then + await expect(() => module.executeCommand(() => uncompleteCommand)).rejects.toThrow( + 'Can not uncomplete task that was not completed yet.', + ); }); }); diff --git a/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.ts b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.ts index acdd9c64..f071a7c0 100644 --- a/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.ts +++ b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; +import { UncompleteTaskCommandHandler } from '@/write/learning-materials-tasks/application/uncomplete-task.command-handler'; + import { SharedModule } from '../shared/shared.module'; import { CompleteTaskCommandHandler } from './application/complete-task.command-handler'; import { LearningMaterialsTaskRestController } from './presentation/rest/process-st-events.rest-controller'; @Module({ imports: [SharedModule], - providers: [CompleteTaskCommandHandler], + providers: [CompleteTaskCommandHandler, UncompleteTaskCommandHandler], controllers: [LearningMaterialsTaskRestController], }) export class LearningMaterialsTasksModule {} diff --git a/packages/api/src/module/write/learning-materials-tasks/presentation/rest/process-st-events.rest-controller.ts b/packages/api/src/module/write/learning-materials-tasks/presentation/rest/process-st-events.rest-controller.ts index 49a44dc1..ca9dbfb1 100644 --- a/packages/api/src/module/write/learning-materials-tasks/presentation/rest/process-st-events.rest-controller.ts +++ b/packages/api/src/module/write/learning-materials-tasks/presentation/rest/process-st-events.rest-controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, HttpCode, Post } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; +import { UncompleteTaskApplicationCommand } from '@/commands/uncomplete-task.application-command'; import { CompleteTaskApplicationCommand } from '@/module/commands/complete-task.application-command'; import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory'; @@ -24,5 +25,13 @@ export class LearningMaterialsTaskRestController { await this.commandBus.execute(command); } + + const command = this.commandFactory.applicationCommand(() => ({ + class: UncompleteTaskApplicationCommand, + type: 'UncompleteTask', + data: { learningMaterialsId, taskId }, + })); + + await this.commandBus.execute(command); } }