mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-07-15 18:07:02 +00:00
feat: tasks jounrey
This commit is contained in:
@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { addTransactionalDataSource } from 'typeorm-transactional';
|
import { addTransactionalDataSource } from 'typeorm-transactional';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { LookupModule } from './common/modules/lookup/lookup.module';
|
||||||
import { OtpModule } from './common/modules/otp/otp.module';
|
import { OtpModule } from './common/modules/otp/otp.module';
|
||||||
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
||||||
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
|
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 { GuardianModule } from './guardian/guardian.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { JuniorModule } from './junior/junior.module';
|
import { JuniorModule } from './junior/junior.module';
|
||||||
import { LookupModule } from './common/modules/lookup/lookup.module';
|
import { TaskModule } from './task/task.module';
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [],
|
controllers: [],
|
||||||
imports: [
|
imports: [
|
||||||
@ -48,11 +49,13 @@ import { LookupModule } from './common/modules/lookup/lookup.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
CustomerModule,
|
CustomerModule,
|
||||||
JuniorModule,
|
JuniorModule,
|
||||||
|
TaskModule,
|
||||||
GuardianModule,
|
GuardianModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
DocumentModule,
|
DocumentModule,
|
||||||
HealthModule,
|
|
||||||
LookupModule,
|
LookupModule,
|
||||||
|
|
||||||
|
HealthModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Pipes
|
// Global Pipes
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { Roles } from '../enums';
|
||||||
|
|
||||||
export interface IJwtPayload {
|
export interface IJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
roles: string[];
|
roles: Roles[];
|
||||||
}
|
}
|
||||||
|
58
src/db/migrations/1733833824321-create-task-entities.ts
Normal file
58
src/db/migrations/1733833824321-create-task-entities.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateTaskEntities1733833824321 implements MigrationInterface {
|
||||||
|
name = 'CreateTaskEntities1733833824321';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
@ -8,3 +8,4 @@ export * from './1733731507261-create-junior-entity';
|
|||||||
export * from './1733732021622-create-guardian-entity';
|
export * from './1733732021622-create-guardian-entity';
|
||||||
export * from './1733748083604-create-theme-entity';
|
export * from './1733748083604-create-theme-entity';
|
||||||
export * from './1733750228289-seed-default-avatar';
|
export * from './1733750228289-seed-default-avatar';
|
||||||
|
export * from './1733833824321-create-task-entities';
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
import { User } from '~/auth/entities';
|
import { User } from '~/auth/entities';
|
||||||
import { Junior, Theme } from '~/junior/entities';
|
import { Junior, Theme } from '~/junior/entities';
|
||||||
|
import { Task } from '~/task/entities';
|
||||||
|
import { TaskSubmission } from '~/task/entities/task-submissions.entity';
|
||||||
import { DocumentType } from '../enums';
|
import { DocumentType } from '../enums';
|
||||||
|
|
||||||
@Entity('documents')
|
@Entity('documents')
|
||||||
@ -29,6 +31,12 @@ export class Document {
|
|||||||
@OneToMany(() => Theme, (theme) => theme.avatar)
|
@OneToMany(() => Theme, (theme) => theme.avatar)
|
||||||
themes?: Theme[];
|
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' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Customer } from '~/customer/entities';
|
import { Customer } from '~/customer/entities';
|
||||||
import { Junior } from '~/junior/entities';
|
import { Junior } from '~/junior/entities';
|
||||||
|
import { Task } from '~/task/entities';
|
||||||
|
|
||||||
@Entity('guardians')
|
@Entity('guardians')
|
||||||
export class Guardian extends BaseEntity {
|
export class Guardian extends BaseEntity {
|
||||||
@ -27,6 +28,9 @@ export class Guardian extends BaseEntity {
|
|||||||
@OneToMany(() => Junior, (junior) => junior.guardian)
|
@OneToMany(() => Junior, (junior) => junior.guardian)
|
||||||
juniors!: Junior[];
|
juniors!: Junior[];
|
||||||
|
|
||||||
|
@OneToMany(() => Task, (task) => task.assignedBy)
|
||||||
|
tasks?: Task[];
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
import { Customer } from '~/customer/entities';
|
import { Customer } from '~/customer/entities';
|
||||||
import { Document } from '~/document/entities';
|
import { Document } from '~/document/entities';
|
||||||
import { Guardian } from '~/guardian/entities/guradian.entity';
|
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||||
|
import { Task } from '~/task/entities';
|
||||||
import { Relationship } from '../enums';
|
import { Relationship } from '../enums';
|
||||||
import { Theme } from './theme.entity';
|
import { Theme } from './theme.entity';
|
||||||
|
|
||||||
@ -54,6 +56,9 @@ export class Junior extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'guardian_id' })
|
@JoinColumn({ name: 'guardian_id' })
|
||||||
guardian!: Guardian;
|
guardian!: Guardian;
|
||||||
|
|
||||||
|
@OneToMany(() => Task, (task) => task.assignedTo)
|
||||||
|
tasks?: Task[];
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
1
src/task/controllers/index.ts
Normal file
1
src/task/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './task.controller';
|
75
src/task/controllers/task.controller.ts
Normal file
75
src/task/controllers/task.controller.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
67
src/task/dtos/request/create-task.request.dto.ts
Normal file
67
src/task/dtos/request/create-task.request.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
3
src/task/dtos/request/index.ts
Normal file
3
src/task/dtos/request/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './create-task.request.dto';
|
||||||
|
export * from './task-submission.request.dto';
|
||||||
|
export * from './tasks-filter-options.request.dto';
|
9
src/task/dtos/request/task-submission.request.dto.ts
Normal file
9
src/task/dtos/request/task-submission.request.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
17
src/task/dtos/request/tasks-filter-options.request.dto.ts
Normal file
17
src/task/dtos/request/tasks-filter-options.request.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
1
src/task/dtos/response/index.ts
Normal file
1
src/task/dtos/response/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './task.response.dto';
|
49
src/task/dtos/response/task.response.dto.ts
Normal file
49
src/task/dtos/response/task.response.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
1
src/task/entities/index.ts
Normal file
1
src/task/entities/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './task.entity';
|
46
src/task/entities/task-submissions.entity.ts
Normal file
46
src/task/entities/task-submissions.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
89
src/task/entities/task.entity.ts
Normal file
89
src/task/entities/task.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
2
src/task/enums/index.ts
Normal file
2
src/task/enums/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './submission-status.enum';
|
||||||
|
export * from './task-status.enum';
|
5
src/task/enums/submission-status.enum.ts
Normal file
5
src/task/enums/submission-status.enum.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum SubmissionStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
}
|
5
src/task/enums/task-status.enum.ts
Normal file
5
src/task/enums/task-status.enum.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum TaskStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
6
src/task/enums/task.frequency.enum.ts
Normal file
6
src/task/enums/task.frequency.enum.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum TaskFrequency {
|
||||||
|
ONE_TIME = 'ONE_TIME',
|
||||||
|
DAILY = 'DAILY',
|
||||||
|
WEEKLY = 'WEEKLY',
|
||||||
|
MONTHLY = 'MONTHLY',
|
||||||
|
}
|
1
src/task/repositories/index.ts
Normal file
1
src/task/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './task.repository';
|
103
src/task/repositories/task.repository.ts
Normal file
103
src/task/repositories/task.repository.ts
Normal file
@ -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<Task>) {}
|
||||||
|
|
||||||
|
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<Task>) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
1
src/task/services/index.ts
Normal file
1
src/task/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './task.service';
|
75
src/task/services/task.service.ts
Normal file
75
src/task/services/task.service.ts
Normal file
@ -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<Task>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
14
src/task/task.module.ts
Normal file
14
src/task/task.module.ts
Normal file
@ -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 {}
|
Reference in New Issue
Block a user