From 749ee5457fadfa57b56a85f695ab41909dd7f52b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 11 Dec 2024 10:27:51 +0300 Subject: [PATCH] feat: tasks jounrey --- src/app.module.ts | 7 +- src/auth/interfaces/jwt-payload.interface.ts | 4 +- .../1733833824321-create-task-entities.ts | 58 ++++++++++ src/db/migrations/index.ts | 1 + src/document/entities/document.entity.ts | 8 ++ src/guardian/entities/guradian.entity.ts | 4 + src/junior/entities/junior.entity.ts | 5 + src/task/controllers/index.ts | 1 + src/task/controllers/task.controller.ts | 75 +++++++++++++ .../dtos/request/create-task.request.dto.ts | 67 ++++++++++++ src/task/dtos/request/index.ts | 3 + .../request/task-submission.request.dto.ts | 9 ++ .../tasks-filter-options.request.dto.ts | 17 +++ src/task/dtos/response/index.ts | 1 + src/task/dtos/response/task.response.dto.ts | 49 +++++++++ src/task/entities/index.ts | 1 + src/task/entities/task-submissions.entity.ts | 46 ++++++++ src/task/entities/task.entity.ts | 89 +++++++++++++++ src/task/enums/index.ts | 2 + src/task/enums/submission-status.enum.ts | 5 + src/task/enums/task-status.enum.ts | 5 + src/task/enums/task.frequency.enum.ts | 6 + src/task/repositories/index.ts | 1 + src/task/repositories/task.repository.ts | 103 ++++++++++++++++++ src/task/services/index.ts | 1 + src/task/services/task.service.ts | 75 +++++++++++++ src/task/task.module.ts | 14 +++ 27 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 src/db/migrations/1733833824321-create-task-entities.ts create mode 100644 src/task/controllers/index.ts create mode 100644 src/task/controllers/task.controller.ts create mode 100644 src/task/dtos/request/create-task.request.dto.ts create mode 100644 src/task/dtos/request/index.ts create mode 100644 src/task/dtos/request/task-submission.request.dto.ts create mode 100644 src/task/dtos/request/tasks-filter-options.request.dto.ts create mode 100644 src/task/dtos/response/index.ts create mode 100644 src/task/dtos/response/task.response.dto.ts create mode 100644 src/task/entities/index.ts create mode 100644 src/task/entities/task-submissions.entity.ts create mode 100644 src/task/entities/task.entity.ts create mode 100644 src/task/enums/index.ts create mode 100644 src/task/enums/submission-status.enum.ts create mode 100644 src/task/enums/task-status.enum.ts create mode 100644 src/task/enums/task.frequency.enum.ts create mode 100644 src/task/repositories/index.ts create mode 100644 src/task/repositories/task.repository.ts create mode 100644 src/task/services/index.ts create mode 100644 src/task/services/task.service.ts create mode 100644 src/task/task.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index d459e4d..8ed20d5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino'; import { DataSource } from 'typeorm'; import { addTransactionalDataSource } from 'typeorm-transactional'; import { AuthModule } from './auth/auth.module'; +import { LookupModule } from './common/modules/lookup/lookup.module'; import { OtpModule } from './common/modules/otp/otp.module'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; @@ -18,7 +19,7 @@ import { DocumentModule } from './document/document.module'; import { GuardianModule } from './guardian/guardian.module'; import { HealthModule } from './health/health.module'; import { JuniorModule } from './junior/junior.module'; -import { LookupModule } from './common/modules/lookup/lookup.module'; +import { TaskModule } from './task/task.module'; @Module({ controllers: [], imports: [ @@ -48,11 +49,13 @@ import { LookupModule } from './common/modules/lookup/lookup.module'; AuthModule, CustomerModule, JuniorModule, + TaskModule, GuardianModule, OtpModule, DocumentModule, - HealthModule, LookupModule, + + HealthModule, ], providers: [ // Global Pipes diff --git a/src/auth/interfaces/jwt-payload.interface.ts b/src/auth/interfaces/jwt-payload.interface.ts index 59429f7..594e369 100644 --- a/src/auth/interfaces/jwt-payload.interface.ts +++ b/src/auth/interfaces/jwt-payload.interface.ts @@ -1,4 +1,6 @@ +import { Roles } from '../enums'; + export interface IJwtPayload { sub: string; - roles: string[]; + roles: Roles[]; } diff --git a/src/db/migrations/1733833824321-create-task-entities.ts b/src/db/migrations/1733833824321-create-task-entities.ts new file mode 100644 index 0000000..26d6e10 --- /dev/null +++ b/src/db/migrations/1733833824321-create-task-entities.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTaskEntities1733833824321 implements MigrationInterface { + name = 'CreateTaskEntities1733833824321'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "task_submissions" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "status" character varying NOT NULL, "submitted_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "task_id" uuid NOT NULL, "proof_of_completion_id" uuid, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "REL_d6cfaee118a0300d652e28ee16" UNIQUE ("task_id"), + CONSTRAINT "PK_8d19d6b5dd776e373113de50018" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "title" character varying(255) NOT NULL, + "description" character varying(255) NOT NULL, + "reward_amount" numeric(12,3) NOT NULL, + "image_id" uuid NOT NULL, + "task_frequency" character varying NOT NULL, + "start_date" date NOT NULL, "due_date" date NOT NULL, + "is_proof_required" boolean NOT NULL, + "assigned_to_id" uuid NOT NULL, + "assigned_by_id" uuid NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "task_submissions" ADD CONSTRAINT "FK_d6cfaee118a0300d652e28ee166" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "task_submissions" ADD CONSTRAINT "FK_87876dfe440de7aafce216e9f58" FOREIGN KEY ("proof_of_completion_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "tasks" ADD CONSTRAINT "FK_f1f00d41b1e95d0bbda2710a62b" FOREIGN KEY ("image_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "tasks" ADD CONSTRAINT "FK_9430f12c5a1604833f64595a57f" FOREIGN KEY ("assigned_to_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "tasks" ADD CONSTRAINT "FK_3e08a7ca125a175cf899b09f71a" FOREIGN KEY ("assigned_by_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_3e08a7ca125a175cf899b09f71a"`); + await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9430f12c5a1604833f64595a57f"`); + await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_f1f00d41b1e95d0bbda2710a62b"`); + await queryRunner.query(`ALTER TABLE "task_submissions" DROP CONSTRAINT "FK_87876dfe440de7aafce216e9f58"`); + await queryRunner.query(`ALTER TABLE "task_submissions" DROP CONSTRAINT "FK_d6cfaee118a0300d652e28ee166"`); + await queryRunner.query(`DROP TABLE "tasks"`); + await queryRunner.query(`DROP TABLE "task_submissions"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9eb83d9..08e1358 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -8,3 +8,4 @@ export * from './1733731507261-create-junior-entity'; export * from './1733732021622-create-guardian-entity'; export * from './1733748083604-create-theme-entity'; export * from './1733750228289-seed-default-avatar'; +export * from './1733833824321-create-task-entities'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index da8793d..a38df5c 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -1,6 +1,8 @@ import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { User } from '~/auth/entities'; import { Junior, Theme } from '~/junior/entities'; +import { Task } from '~/task/entities'; +import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { DocumentType } from '../enums'; @Entity('documents') @@ -29,6 +31,12 @@ export class Document { @OneToMany(() => Theme, (theme) => theme.avatar) themes?: Theme[]; + @OneToMany(() => Task, (task) => task.image) + tasks?: Task[]; + + @OneToMany(() => TaskSubmission, (taskSubmission) => taskSubmission.proofOfCompletion) + submissions?: TaskSubmission[]; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date; diff --git a/src/guardian/entities/guradian.entity.ts b/src/guardian/entities/guradian.entity.ts index f4ed01e..8b0cadf 100644 --- a/src/guardian/entities/guradian.entity.ts +++ b/src/guardian/entities/guradian.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { Customer } from '~/customer/entities'; import { Junior } from '~/junior/entities'; +import { Task } from '~/task/entities'; @Entity('guardians') export class Guardian extends BaseEntity { @@ -27,6 +28,9 @@ export class Guardian extends BaseEntity { @OneToMany(() => Junior, (junior) => junior.guardian) juniors!: Junior[]; + @OneToMany(() => Task, (task) => task.assignedBy) + tasks?: Task[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index db98b1f..da68626 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -5,6 +5,7 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, OneToOne, PrimaryColumn, UpdateDateColumn, @@ -12,6 +13,7 @@ import { import { Customer } from '~/customer/entities'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Task } from '~/task/entities'; import { Relationship } from '../enums'; import { Theme } from './theme.entity'; @@ -54,6 +56,9 @@ export class Junior extends BaseEntity { @JoinColumn({ name: 'guardian_id' }) guardian!: Guardian; + @OneToMany(() => Task, (task) => task.assignedTo) + tasks?: Task[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/task/controllers/index.ts b/src/task/controllers/index.ts new file mode 100644 index 0000000..f1546d0 --- /dev/null +++ b/src/task/controllers/index.ts @@ -0,0 +1 @@ +export * from './task.controller'; diff --git a/src/task/controllers/task.controller.ts b/src/task/controllers/task.controller.ts new file mode 100644 index 0000000..86dba3c --- /dev/null +++ b/src/task/controllers/task.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard, RolesGuard } from '~/common/guards'; +import { CustomParseUUIDPipe } from '~/core/pipes'; +import { ResponseFactory } from '~/core/utils'; +import { CreateTaskRequestDto, TaskSubmissionRequestDto } from '../dtos/request'; +import { TasksFilterOptions } from '../dtos/request/tasks-filter-options.request.dto'; +import { TaskResponseDto } from '../dtos/response'; +import { TaskService } from '../services'; + +@Controller('tasks') +@ApiTags('Tasks') +@ApiBearerAuth() +export class TaskController { + constructor(private readonly taskService: TaskService) {} + + @Post() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + async createTask(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateTaskRequestDto) { + const task = await this.taskService.createTask(sub, body); + return ResponseFactory.data(new TaskResponseDto(task)); + } + + @Get() + @UseGuards(AccessTokenGuard) + async findTasks(@Query() query: TasksFilterOptions, @AuthenticatedUser() user: IJwtPayload) { + const [tasks, itemCount] = await this.taskService.findTasks(user, query); + return ResponseFactory.dataPage( + tasks.map((task) => new TaskResponseDto(task)), + { + page: query.page, + size: query.size, + itemCount, + }, + ); + } + + @Patch(':taskId/submit') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR) + @HttpCode(HttpStatus.NO_CONTENT) + async submitTask( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('taskId', CustomParseUUIDPipe) taskId: string, + @Body() body: TaskSubmissionRequestDto, + ) { + await this.taskService.submitTask(sub, taskId, body); + } + + @Patch(':taskId/approve') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @HttpCode(HttpStatus.NO_CONTENT) + async approveTaskSubmission( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('taskId', CustomParseUUIDPipe) taskId: string, + ) { + await this.taskService.approveTaskSubmission(sub, taskId); + } + + @Patch(':taskId/reject') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @HttpCode(HttpStatus.NO_CONTENT) + async rejectTaskSubmission( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('taskId', CustomParseUUIDPipe) taskId: string, + ) { + await this.taskService.rejectTaskSubmission(sub, taskId); + } +} diff --git a/src/task/dtos/request/create-task.request.dto.ts b/src/task/dtos/request/create-task.request.dto.ts new file mode 100644 index 0000000..da9db47 --- /dev/null +++ b/src/task/dtos/request/create-task.request.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { TaskFrequency } from '~/task/enums/task.frequency.enum'; +const TEXT_LENGTH = 255; +const MAX_DECIMAL_PLACES = 3; +export class CreateTaskRequestDto { + @ApiProperty() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'task.title' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'task.title' }) }) + @MaxLength(TEXT_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'task.title', length: TEXT_LENGTH }), + }) + title!: string; + + @ApiProperty({ required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'task.description' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'task.description' }) }) + @MaxLength(TEXT_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'task.description', length: TEXT_LENGTH }), + }) + @IsOptional() + description!: string; + + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'task.startDate' }) }) + @IsOptional() + startDate: string = new Date().toISOString(); + + @ApiProperty({ example: '2024-01-15' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'task.dueDate' }) }) + dueDate!: string; + + @ApiProperty({ example: 100 }) + @IsNumber( + { maxDecimalPlaces: MAX_DECIMAL_PLACES }, + { message: i18n('validation.IsNumber', { path: 'general', property: 'task.rewardAmount' }) }, + ) + @IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'task.rewardAmount' }) }) + rewardAmount!: number; + + @IsEnum(TaskFrequency, { message: i18n('validation.IsEnum', { path: 'general', property: 'task.frequency' }) }) + @IsOptional() + frequency: TaskFrequency = TaskFrequency.ONE_TIME; + + @ApiProperty({ example: false }) + @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'task.isProofRequired' }) }) + isProofRequired!: boolean; + + @ApiProperty({ example: 'e7b1b3b1-4b3b-4b3b-4b3b-4b3b4b3b4b3b' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'task.imageId' }) }) + imageId!: string; + + @ApiProperty({ example: 'e7b1b3b1-4b3b-4b3b-4b3b-4b3b4b3b4b3b' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'task.juniorId' }) }) + juniorId!: string; +} diff --git a/src/task/dtos/request/index.ts b/src/task/dtos/request/index.ts new file mode 100644 index 0000000..790564d --- /dev/null +++ b/src/task/dtos/request/index.ts @@ -0,0 +1,3 @@ +export * from './create-task.request.dto'; +export * from './task-submission.request.dto'; +export * from './tasks-filter-options.request.dto'; diff --git a/src/task/dtos/request/task-submission.request.dto.ts b/src/task/dtos/request/task-submission.request.dto.ts new file mode 100644 index 0000000..83f392c --- /dev/null +++ b/src/task/dtos/request/task-submission.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class TaskSubmissionRequestDto { + @ApiPropertyOptional({ example: '4dsf3-4dsf3-4dsf3-4dsf3', description: 'The Proof id of the task submission' }) + @IsUUID('4', { message: i18n('validation.isUUID', { path: 'general', property: 'task.imageId' }) }) + @IsOptional() + imageId!: string; +} diff --git a/src/task/dtos/request/tasks-filter-options.request.dto.ts b/src/task/dtos/request/tasks-filter-options.request.dto.ts new file mode 100644 index 0000000..36c664d --- /dev/null +++ b/src/task/dtos/request/tasks-filter-options.request.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { TaskStatus } from '~/task/enums'; +export class TasksFilterOptions extends PageOptionsRequestDto { + @ApiProperty({ enum: TaskStatus, required: true }) + @IsEnum(TaskStatus, { + message: i18n('validation.IsEnum', { path: 'general', property: 'task.status' }), + }) + status?: TaskStatus; + + @ApiPropertyOptional() + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'task.juniorId' }) }) + @IsOptional() + juniorId?: string; +} diff --git a/src/task/dtos/response/index.ts b/src/task/dtos/response/index.ts new file mode 100644 index 0000000..9eedc94 --- /dev/null +++ b/src/task/dtos/response/index.ts @@ -0,0 +1 @@ +export * from './task.response.dto'; diff --git a/src/task/dtos/response/task.response.dto.ts b/src/task/dtos/response/task.response.dto.ts new file mode 100644 index 0000000..2a60fb7 --- /dev/null +++ b/src/task/dtos/response/task.response.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { JuniorResponseDto } from '~/junior/dtos/response'; +import { Task } from '~/task/entities'; +import { TaskSubmission } from '~/task/entities/task-submissions.entity'; + +export class TaskResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + title!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + dueDate!: Date; + + @ApiProperty() + rewardAmount!: number; + + @ApiProperty() + submission?: TaskSubmission; + + @ApiProperty() + junior!: JuniorResponseDto; + + @ApiProperty() + createdAt!: Date; + + @ApiProperty() + updatedAt!: Date; + + constructor(task: Task) { + this.id = task.id; + this.title = task.title; + this.description = task.description; + this.status = task.status; + this.dueDate = task.dueDate; + this.rewardAmount = task.rewardAmount; + this.submission = task.submission; + this.junior = new JuniorResponseDto(task.assignedTo); + this.createdAt = task.createdAt; + this.updatedAt = task.updatedAt; + } +} diff --git a/src/task/entities/index.ts b/src/task/entities/index.ts new file mode 100644 index 0000000..1ab88d2 --- /dev/null +++ b/src/task/entities/index.ts @@ -0,0 +1 @@ +export * from './task.entity'; diff --git a/src/task/entities/task-submissions.entity.ts b/src/task/entities/task-submissions.entity.ts new file mode 100644 index 0000000..ec99fef --- /dev/null +++ b/src/task/entities/task-submissions.entity.ts @@ -0,0 +1,46 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Document } from '~/document/entities'; +import { SubmissionStatus } from '../enums'; +import { Task } from './task.entity'; + +@Entity('task_submissions') +export class TaskSubmission extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', name: 'status' }) + status!: SubmissionStatus; + + @Column({ type: 'timestamp with time zone', name: 'submitted_at' }) + submittedAt!: Date; + + @Column({ type: 'uuid', name: 'task_id' }) + taskId!: string; + + @Column({ type: 'uuid', name: 'proof_of_completion_id', nullable: true }) + proofOfCompletionId!: string; + + @OneToOne(() => Task, (task) => task.submission) + @JoinColumn({ name: 'task_id' }) + task!: Task; + + @ManyToOne(() => Document, (document) => document.submissions, { nullable: true }) + @JoinColumn({ name: 'proof_of_completion_id' }) + proofOfCompletion!: Document; + + @CreateDateColumn({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/task/entities/task.entity.ts b/src/task/entities/task.entity.ts new file mode 100644 index 0000000..6405fed --- /dev/null +++ b/src/task/entities/task.entity.ts @@ -0,0 +1,89 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Document } from '~/document/entities'; +import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Junior } from '~/junior/entities'; +import { SubmissionStatus, TaskStatus } from '../enums'; +import { TaskFrequency } from '../enums/task.frequency.enum'; +import { TaskSubmission } from './task-submissions.entity'; + +@Entity('tasks') +export class Task { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, name: 'title' }) + title!: string; + + @Column({ type: 'varchar', length: 255, name: 'description' }) + description!: string; + + @Column({ type: 'decimal', name: 'reward_amount', precision: 12, scale: 3 }) + rewardAmount!: number; + + @Column({ type: 'uuid', name: 'image_id' }) + imageId!: string; + + @Column({ type: 'varchar', name: 'task_frequency' }) + taskFrequency!: TaskFrequency; + + @Column({ type: 'date', name: 'start_date' }) + startDate!: Date; + + @Column({ type: 'date', name: 'due_date' }) + dueDate!: Date; + + @Column({ type: 'boolean', name: 'is_proof_required' }) + isProofRequired!: boolean; + + @Column({ type: 'uuid', name: 'assigned_to_id' }) + assignedToId!: string; + + @Column({ type: 'uuid', name: 'assigned_by_id' }) + assignedById!: string; + + @ManyToOne(() => Document, (document) => document.tasks) + @JoinColumn({ name: 'image_id' }) + image!: Document; + + @ManyToOne(() => Junior, (junior) => junior.tasks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assigned_to_id' }) + assignedTo!: Junior; + + @ManyToOne(() => Guardian, (guardian) => guardian.tasks) + @JoinColumn({ name: 'assigned_by_id' }) + assignedBy!: Guardian; + + @OneToOne(() => TaskSubmission, (submission) => submission.task, { onDelete: 'CASCADE' }) + submission?: TaskSubmission; + + @CreateDateColumn({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; + + get status(): string { + if (new Date(this.dueDate) < new Date()) { + return TaskStatus.COMPLETED; + } + + if (this.submission && this.submission.status === SubmissionStatus.APPROVED) { + return TaskStatus.COMPLETED; + } + + if (this.submission && this.submission.status !== SubmissionStatus.APPROVED) { + return TaskStatus.IN_PROGRESS; + } + + return TaskStatus.PENDING; + } +} diff --git a/src/task/enums/index.ts b/src/task/enums/index.ts new file mode 100644 index 0000000..158b5c7 --- /dev/null +++ b/src/task/enums/index.ts @@ -0,0 +1,2 @@ +export * from './submission-status.enum'; +export * from './task-status.enum'; diff --git a/src/task/enums/submission-status.enum.ts b/src/task/enums/submission-status.enum.ts new file mode 100644 index 0000000..f8888df --- /dev/null +++ b/src/task/enums/submission-status.enum.ts @@ -0,0 +1,5 @@ +export enum SubmissionStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} diff --git a/src/task/enums/task-status.enum.ts b/src/task/enums/task-status.enum.ts new file mode 100644 index 0000000..c681469 --- /dev/null +++ b/src/task/enums/task-status.enum.ts @@ -0,0 +1,5 @@ +export enum TaskStatus { + PENDING = 'PENDING', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', +} diff --git a/src/task/enums/task.frequency.enum.ts b/src/task/enums/task.frequency.enum.ts new file mode 100644 index 0000000..6fbc68b --- /dev/null +++ b/src/task/enums/task.frequency.enum.ts @@ -0,0 +1,6 @@ +export enum TaskFrequency { + ONE_TIME = 'ONE_TIME', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} diff --git a/src/task/repositories/index.ts b/src/task/repositories/index.ts new file mode 100644 index 0000000..eb065ae --- /dev/null +++ b/src/task/repositories/index.ts @@ -0,0 +1 @@ +export * from './task.repository'; diff --git a/src/task/repositories/task.repository.ts b/src/task/repositories/task.repository.ts new file mode 100644 index 0000000..f7318cc --- /dev/null +++ b/src/task/repositories/task.repository.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request'; +import { Task } from '../entities'; +import { TaskSubmission } from '../entities/task-submissions.entity'; +import { SubmissionStatus, TaskStatus } from '../enums'; +const ONE = 1; +@Injectable() +export class TaskRepository { + constructor(@InjectRepository(Task) private readonly taskRepository: Repository) {} + + createTask(userId: string, body: CreateTaskRequestDto) { + return this.taskRepository.save( + this.taskRepository.create({ + title: body.title, + description: body.description, + rewardAmount: body.rewardAmount, + taskFrequency: body.frequency, + startDate: body.startDate, + dueDate: body.dueDate, + assignedById: userId, + assignedToId: body.juniorId, + imageId: body.imageId, + isProofRequired: body.isProofRequired, + }), + ); + } + + findTask(where: FindOptionsWhere) { + return this.taskRepository.findOne({ + where, + relations: ['image', 'assignedTo', 'assignedTo.customer', 'assignedTo.customer.user', 'submission'], + }); + } + + findTasks({ roles, sub: userId }: IJwtPayload, query: TasksFilterOptions) { + const queryBuilder = this.taskRepository.createQueryBuilder('task'); + + queryBuilder + .leftJoinAndSelect('task.image', 'image') + .leftJoinAndSelect('task.assignedTo', 'assignedTo') + .leftJoinAndSelect('assignedTo.customer', 'customer') + .leftJoinAndSelect('customer.user', 'user') + .leftJoinAndSelect('task.submission', 'submission'); + + if (roles.includes(Roles.GUARDIAN)) { + queryBuilder.where('task.assignedById = :userId', { userId }); + + // Add a condition for juniorId if it exists + if (query.juniorId) { + queryBuilder.andWhere('task.assignedToId = :juniorId', { juniorId: query.juniorId }); + } + } else { + queryBuilder.where('task.assignedToId = :userId', { userId }); + } + + if (query.status === TaskStatus.PENDING) { + queryBuilder.andWhere('task.dueDate >= :today', { today: new Date() }); + queryBuilder.andWhere('submission IS NULL'); + } + + if (query.status === TaskStatus.IN_PROGRESS) { + queryBuilder.andWhere('submission IS NOT NULL'); + queryBuilder.andWhere('submission.status != :status', { status: SubmissionStatus.APPROVED }); + queryBuilder.andWhere('task.dueDate >= :today', { today: new Date() }); + } + + if (query.status === TaskStatus.COMPLETED) { + queryBuilder.andWhere('task.dueDate < :today', { today: new Date() }); + queryBuilder.orWhere('submission.status = :status', { status: SubmissionStatus.APPROVED }); + } + + queryBuilder.orderBy('task.createdAt', 'DESC'); + queryBuilder.skip((query.page - ONE) * query.size); + queryBuilder.take(query.size); + + return queryBuilder.getManyAndCount(); + } + + createSubmission(task: Task, body: TaskSubmissionRequestDto) { + task.submission = TaskSubmission.create({ + status: task.isProofRequired ? SubmissionStatus.PENDING : SubmissionStatus.APPROVED, + submittedAt: new Date(), + taskId: task.id, + proofOfCompletionId: body.imageId, + }); + + return task.submission.save(); + } + + approveSubmission(submission: TaskSubmission) { + submission.status = SubmissionStatus.APPROVED; + return submission.save(); + } + + rejectSubmission(submission: TaskSubmission) { + submission.status = SubmissionStatus.REJECTED; + return submission.save(); + } +} diff --git a/src/task/services/index.ts b/src/task/services/index.ts new file mode 100644 index 0000000..a9c6287 --- /dev/null +++ b/src/task/services/index.ts @@ -0,0 +1 @@ +export * from './task.service'; diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts new file mode 100644 index 0000000..b1dc825 --- /dev/null +++ b/src/task/services/task.service.ts @@ -0,0 +1,75 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +import { IJwtPayload } from '~/auth/interfaces'; +import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request'; +import { Task } from '../entities'; +import { SubmissionStatus, TaskStatus } from '../enums'; +import { TaskRepository } from '../repositories'; + +@Injectable() +export class TaskService { + constructor(private readonly taskRepository: TaskRepository) {} + async createTask(userId: string, body: CreateTaskRequestDto) { + const task = await this.taskRepository.createTask(userId, body); + return this.findTask({ id: task.id }); + } + + async findTask(where: FindOptionsWhere) { + const task = await this.taskRepository.findTask(where); + + if (!task) { + throw new BadRequestException('TASK.NOT_FOUND'); + } + + return task; + } + + findTasks(user: IJwtPayload, query: TasksFilterOptions) { + return this.taskRepository.findTasks(user, query); + } + async submitTask(userId: string, taskId: string, body: TaskSubmissionRequestDto) { + const task = await this.findTask({ id: taskId, assignedToId: userId }); + + if (task.status == TaskStatus.COMPLETED) { + throw new BadRequestException('TASK.ALREADY_COMPLETED'); + } + + if (task.submission && task.submission.status !== SubmissionStatus.REJECTED) { + throw new BadRequestException('TASK.ALREADY_SUBMITTED'); + } + + if (task.isProofRequired && !body.imageId) { + throw new BadRequestException('TASK.PROOF_REQUIRED'); + } + + await this.taskRepository.createSubmission(task, body); + } + + async approveTaskSubmission(userId: string, taskId: string) { + const task = await this.findTask({ id: taskId, assignedById: userId }); + + if (!task.submission) { + throw new BadRequestException('TASK.NO_SUBMISSION'); + } + + if (task.submission.status !== SubmissionStatus.PENDING) { + throw new BadRequestException('TASK.SUBMISSION_ALREADY_REVIEWED'); + } + + await this.taskRepository.approveSubmission(task.submission); + } + + async rejectTaskSubmission(userId: string, taskId: string) { + const task = await this.findTask({ id: taskId, assignedById: userId }); + + if (!task.submission) { + throw new BadRequestException('TASK.NO_SUBMISSION'); + } + + if (task.submission.status !== SubmissionStatus.PENDING) { + throw new BadRequestException('TASK.SUBMISSION_ALREADY_REVIEWED'); + } + + await this.taskRepository.rejectSubmission(task.submission); + } +} diff --git a/src/task/task.module.ts b/src/task/task.module.ts new file mode 100644 index 0000000..18b1847 --- /dev/null +++ b/src/task/task.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TaskController } from './controllers'; +import { Task } from './entities'; +import { TaskSubmission } from './entities/task-submissions.entity'; +import { TaskRepository } from './repositories'; +import { TaskService } from './services'; + +@Module({ + imports: [TypeOrmModule.forFeature([Task, TaskSubmission])], + controllers: [TaskController], + providers: [TaskService, TaskRepository], +}) +export class TaskModule {}