diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 65c05f51..b87bb3c2 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -4,7 +4,7 @@ datasource db { } generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" previewFeatures = ["interactiveTransactions"] } @@ -23,18 +23,18 @@ model AuthUser { } model RegisteredEmails { - userId String @id - email String @unique + userId String @id + email String @unique } model LearningMaterials { - id String @id - url String - courseUserId String @unique + id String @id + url String + courseUserId String @unique } model Event { - id String @id + id String @id type String streamId String streamCategory String @@ -42,7 +42,7 @@ model Event { occurredAt DateTime data Json metadata Json - globalOrder Int @default(autoincrement()) + globalOrder Int @default(autoincrement()) } model Course { @@ -57,8 +57,8 @@ enum Role { } model CourseProgress { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - courseUserId String @unique - learningMaterialsId String @unique - learningMaterialsCompletedTasks Int + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + courseUserId String? @unique + learningMaterialsId String @unique + learningMaterialsCompletedTasks Int } diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 8f483136..41d373f3 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -11,6 +11,7 @@ import { CourseProgressReadModule } from '@/read/course-progress/course-progress import { LearningMaterialsReadModule } from '@/read/learning-materials/learning-materials.read-module'; import { env } from '@/shared/env'; import { EmailConfirmationWriteModule } from '@/write/email-confirmation/email-confirmation.write-module'; +import { LearningMaterialsTasksModule } from '@/write/learning-materials-tasks/learning-materials-tasks.write-module'; import { LearningMaterialsUrlWriteModule } from '@/write/learning-materials-url/learning-materials-url.write-module'; import { UserRegistrationWriteModule } from '@/write/user-registration/user-registration.write-module'; @@ -19,7 +20,12 @@ import { CoursesModule } from './crud/courses/courses.module'; const isProduction = env.NODE_ENV === 'production'; -const writeModules = [LearningMaterialsUrlWriteModule, UserRegistrationWriteModule, EmailConfirmationWriteModule]; +const writeModules = [ + LearningMaterialsUrlWriteModule, + UserRegistrationWriteModule, + LearningMaterialsTasksModule, + EmailConfirmationWriteModule, +]; const readModules = [LearningMaterialsReadModule, CourseProgressReadModule]; const automationModules = [SendEmailWhenLearningMaterialsUrlWasGeneratedAutomationModule]; const eventModelingModules = [...writeModules, ...readModules, ...automationModules]; diff --git a/packages/api/src/module/read/course-progress/course-progress.read-module.spec.ts b/packages/api/src/module/read/course-progress/course-progress.read-module.spec.ts index f16b4fe8..43d4d1bb 100644 --- a/packages/api/src/module/read/course-progress/course-progress.read-module.spec.ts +++ b/packages/api/src/module/read/course-progress/course-progress.read-module.spec.ts @@ -177,40 +177,4 @@ describe('Read Slice | CourseProgress', () => { }, }); }); - - it('when taskWasUnCompleted and learningMaterialsCompletedTasks is equal to 0 then learningMaterialsCompletedTasks should be 0', async () => { - // Given - const { id, courseUserId, learningMaterialsId, initialLearningMaterialCompletedTask } = givenData(uuid()); - - // When - await moduleUnderTest.eventOccurred( - learningMaterialsUrlWasGeneratedWithId(id), - EventStreamName.from('LearningMaterialsUrl', courseUserId), - ); - - // Then - await moduleUnderTest.expectReadModel({ - learningMaterialsId, - readModel: { - learningMaterialsId, - courseUserId, - learningMaterialsCompletedTasks: initialLearningMaterialCompletedTask, - }, - }); - - // When - await moduleUnderTest.eventOccurred( - statusTask(learningMaterialsId, 'TaskWasUncompleted'), - EventStreamName.from('LearningMaterialsTasks', courseUserId), - ); - // Then - await moduleUnderTest.expectReadModel({ - learningMaterialsId, - readModel: { - learningMaterialsId, - courseUserId, - learningMaterialsCompletedTasks: 0, - }, - }); - }); }); diff --git a/packages/api/src/module/read/course-progress/course-progress.read-module.ts b/packages/api/src/module/read/course-progress/course-progress.read-module.ts index 3f62bcf1..1a30fc12 100644 --- a/packages/api/src/module/read/course-progress/course-progress.read-module.ts +++ b/packages/api/src/module/read/course-progress/course-progress.read-module.ts @@ -18,44 +18,48 @@ export class CourseProgressReadModule { constructor(private readonly prismaService: PrismaService) {} @OnEvent('LearningMaterialsUrl.LearningMaterialsUrlWasGenerated') - async onLearningResourcesUrlWasGenerated(event: ApplicationEvent) { - await this.prismaService.courseProgress.create({ - data: { - courseUserId: event.data.courseUserId, - learningMaterialsId: event.data.learningMaterialsId, - learningMaterialsCompletedTasks: 0, - }, + async onLearningResourcesUrlWasGenerated({ + data: { learningMaterialsId, courseUserId }, + }: ApplicationEvent) { + await this.prismaService.courseProgress.upsert({ + where: { learningMaterialsId }, + create: { learningMaterialsId, learningMaterialsCompletedTasks: 0, courseUserId }, + update: { courseUserId }, }); } @OnEvent('LearningMaterialsTasks.TaskWasUncompleted') - async onTaskWasUncompleted(event: ApplicationEvent) { - const where = { learningMaterialsId: event.data.learningMaterialsId }; - const courseProgress = await this.prismaService.courseProgress.findUnique({ where }); - - if (!courseProgress || courseProgress.learningMaterialsCompletedTasks === 0) { - return; - } - - await this.prismaService.courseProgress.update({ - where, - data: { - learningMaterialsCompletedTasks: { decrement: 1 }, - }, - }); + async onTaskWasUncompleted({ data: { learningMaterialsId } }: ApplicationEvent) { + await this.prismaService.courseProgress.upsert( + this.courseProgressStateUpdated({ learningMaterialsId, completedTasks: 'decrement' }), + ); } @OnEvent('LearningMaterialsTasks.TaskWasCompleted') - async onTaskWasCompleted(event: ApplicationEvent) { - await this.prismaService.courseProgress.update({ - where: { - learningMaterialsId: event.data.learningMaterialsId, - }, - data: { + async onTaskWasCompleted({ data: { learningMaterialsId } }: ApplicationEvent) { + await this.prismaService.courseProgress.upsert( + this.courseProgressStateUpdated({ learningMaterialsId, completedTasks: 'increment' }), + ); + } + + private courseProgressStateUpdated({ + learningMaterialsId, + completedTasks, + }: { + learningMaterialsId: string; + completedTasks: 'increment' | 'decrement'; + }) { + return { + where: { learningMaterialsId }, + update: { learningMaterialsCompletedTasks: { - increment: 1, + [completedTasks]: 1, }, }, - }); + create: { + learningMaterialsId, + learningMaterialsCompletedTasks: completedTasks === 'increment' ? 1 : 0, + }, + }; } } diff --git a/packages/api/src/module/shared/commands/complete-task.application-command.ts b/packages/api/src/module/shared/commands/complete-task.application-command.ts new file mode 100644 index 00000000..7f91f7a6 --- /dev/null +++ b/packages/api/src/module/shared/commands/complete-task.application-command.ts @@ -0,0 +1,5 @@ +import { AbstractApplicationCommand } from '@/module/application-command-events'; + +import { CompleteTask } from './complete-task.domain-command'; + +export class CompleteTaskApplicationCommand extends AbstractApplicationCommand {} diff --git a/packages/api/src/module/shared/commands/complete-task.domain-command.ts b/packages/api/src/module/shared/commands/complete-task.domain-command.ts new file mode 100644 index 00000000..cecacf33 --- /dev/null +++ b/packages/api/src/module/shared/commands/complete-task.domain-command.ts @@ -0,0 +1,4 @@ +export type CompleteTask = { + type: 'CompleteTask'; + 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 new file mode 100644 index 00000000..3bf718c2 --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/application/complete-task.command-handler.ts @@ -0,0 +1,30 @@ +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 { APPLICATION_SERVICE, ApplicationService } from '@/write/shared/application/application-service'; +import { EventStreamName } from '@/write/shared/application/event-stream-name.value-object'; + +import { completeTask } from '../domain/complete-task'; + +@CommandHandler(CompleteTaskApplicationCommand) +export class CompleteTaskCommandHandler implements ICommandHandler { + constructor( + @Inject(APPLICATION_SERVICE) + private readonly applicationService: ApplicationService, + ) {} + + async execute(command: CompleteTaskApplicationCommand): Promise { + const eventStream = EventStreamName.from('LearningMaterialsTasks', command.data.learningMaterialsId); + + await this.applicationService.execute( + eventStream, + { + causationId: command.id, + correlationId: command.metadata.correlationId, + }, + (pastEvents) => completeTask(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 new file mode 100644 index 00000000..f9e2e2fe --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.spec.ts @@ -0,0 +1,64 @@ +import { CompleteTask } from '@/module/commands/complete-task.domain-command'; +import { TaskWasCompleted } from '@/module/events/task-was-completed.domain-event'; + +import { completeTask } from './complete-task'; + +describe('complete task', () => { + const command: CompleteTask = { + type: 'CompleteTask', + data: { learningMaterialsId: 'sbAPITNMsl2wW6j2cg1H2A', taskId: 'L9EXtwmBNBXgo_qh0uzbq' }, + }; + + it('should return task was completed', () => { + // Given + const pastEvents: TaskWasCompleted[] = []; + + // When + const events = completeTask(pastEvents, command); + + // Then + expect(events).toStrictEqual([ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]); + }); + + it('should return task was completed if other tasks are completed', () => { + // Given + const pastEvents: TaskWasCompleted[] = [ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: 'BIt23CR3dLKkHn_a2IM4V' }, + }, + ]; + + // When + const events = completeTask(pastEvents, command); + + // Then + expect(events).toStrictEqual([ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]); + }); + + it('should throw exception if task was already completed', () => { + // Given + const pastEvents: TaskWasCompleted[] = [ + { + type: 'TaskWasCompleted', + data: { learningMaterialsId: command.data.learningMaterialsId, taskId: command.data.taskId }, + }, + ]; + + // When + const events = () => completeTask(pastEvents, command); + + // Then + expect(events).toThrowError('Task was already completed'); + }); +}); 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 new file mode 100644 index 00000000..0d03642b --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/domain/complete-task.ts @@ -0,0 +1,37 @@ +import { TaskWasCompleted } from '@/events/task-was-completed.domain-event'; +import { CompleteTask } from '@/module/commands/complete-task.domain-command'; + +export function completeTask( + pastEvents: TaskWasCompleted[], + { data: { learningMaterialsId, taskId } }: CompleteTask, +): TaskWasCompleted[] { + const state = pastEvents + .filter(({ data }) => data.taskId === taskId) + .reduce<{ completed: boolean }>( + (acc, event) => { + switch (event.type) { + case 'TaskWasCompleted': { + return { completed: true }; + } + default: { + return acc; + } + } + }, + { completed: false }, + ); + + if (state.completed) { + throw new Error('Task was already completed'); + } + + const newEvent: TaskWasCompleted = { + type: 'TaskWasCompleted', + data: { + taskId, + learningMaterialsId, + }, + }; + + return [newEvent]; +} diff --git a/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.test-module.ts b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.test-module.ts new file mode 100644 index 00000000..40621bdb --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.test-module.ts @@ -0,0 +1,5 @@ +import { initWriteTestModule } from '@/shared/test-utils'; + +export async function learningMaterialsTasksTestModule() { + return initWriteTestModule(); +} 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 new file mode 100644 index 00000000..72ba47df --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.spec.ts @@ -0,0 +1,53 @@ +import { AsyncReturnType } from 'type-fest'; + +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'; + +describe('learning materials tasks', () => { + let module: AsyncReturnType; + const commandBuilder = (taskId = 'VmkxXnPG02CaUNV8Relzk', learningMaterialsId = 'ZpMpw2eh1llFCGKZJEN6r') => ({ + class: CompleteTaskApplicationCommand, + type: 'CompleteTask', + data: { taskId, learningMaterialsId }, + }); + + it('should change state of the task to complete', async () => { + // Given + const command = commandBuilder(); + + // When + await module.executeCommand(() => command); + + // Then + await module.expectEventPublishedLastly({ + type: 'TaskWasCompleted', + data: { + learningMaterialsId: command.data.learningMaterialsId, + taskId: command.data.taskId, + }, + streamName: EventStreamName.from('LearningMaterialsTasks', command.data.learningMaterialsId), + }); + }); + + it('should not change task state if task is already completed', async () => { + // Given + const command = commandBuilder(); + + // When + await module.executeCommand(() => command); + + // Then + await expect(() => module.executeCommand(() => command)).rejects.toThrow(); + }); + + beforeEach(async () => { + module = await learningMaterialsTasksTestModule(); + }); + + afterEach(async () => { + await module.close(); + }); +}); 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 new file mode 100644 index 00000000..acdd9c64 --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/learning-materials-tasks.write-module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +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], + 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 new file mode 100644 index 00000000..14502097 --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/presentation/rest/process-st-events.rest-controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { CompleteTaskApplicationCommand } from '@/module/commands/complete-task.application-command'; +import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory'; + +import { TaskCompletedRequestBody } from '../../types/taskCompletedRequestBody'; + +@Controller('process-st/events') +export class LearningMaterialsTaskRestController { + constructor(private readonly commandBus: CommandBus, private readonly commandFactory: ApplicationCommandFactory) {} + + @Post('task-checked-unchecked') + @HttpCode(200) + async taskCheckedUnchecked( + @Body() { id: learningMaterialsId, data: { id: taskId, status } }: TaskCompletedRequestBody, + ): Promise { + if (status === 'Completed') { + const command = this.commandFactory.applicationCommand(() => ({ + class: CompleteTaskApplicationCommand, + type: 'CompleteTask', + data: { learningMaterialsId, taskId }, + })); + + await this.commandBus.execute(command); + } + } +} diff --git a/packages/api/src/module/write/learning-materials-tasks/types/taskCompletedRequestBody.ts b/packages/api/src/module/write/learning-materials-tasks/types/taskCompletedRequestBody.ts new file mode 100644 index 00000000..13c4575c --- /dev/null +++ b/packages/api/src/module/write/learning-materials-tasks/types/taskCompletedRequestBody.ts @@ -0,0 +1,12 @@ +export interface TaskCompletedRequestBody { + id: string; + createdDate: string; + data: { + id: string; + status: string; + stopped: boolean; + hidden: boolean; + name: string; + }; + type: string; +}