Compare commits

..

7 Commits

Author SHA1 Message Date
35b434bc3d fix: fix multiple submissions 2024-12-11 11:09:55 +03:00
749ee5457f feat: tasks jounrey 2024-12-11 10:27:51 +03:00
d539073f29 Merge pull request #7 from HamzaSha1/feat/roles-guard
feat: protecting endpoints by roles
2024-12-10 10:19:32 +03:00
66e1bb0f28 feat: protecting endpoint by roles 2024-12-10 10:11:47 +03:00
577f91b796 Merge pull request #6 from HamzaSha1/feat/junior-theme
feat: set theme for junior users
2024-12-10 09:30:42 +03:00
7ed37c30e1 feat: set theme for junior users 2024-12-10 09:23:30 +03:00
c2f63ccc72 Merge pull request #5 from HamzaSha1/feat/create-juniors
feat: create junior
2024-12-09 13:18:03 +03:00
59 changed files with 982 additions and 24 deletions

8
package-lock.json generated
View File

@ -65,6 +65,7 @@
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",
@ -3121,6 +3122,13 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.12.2",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz",

View File

@ -82,6 +82,7 @@
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",

View File

@ -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,6 +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 { TaskModule } from './task/task.module';
@Module({
controllers: [],
imports: [
@ -47,9 +49,12 @@ import { JuniorModule } from './junior/junior.module';
AuthModule,
CustomerModule,
JuniorModule,
TaskModule,
GuardianModule,
OtpModule,
DocumentModule,
LookupModule,
HealthModule,
],
providers: [

View File

@ -1,4 +1,6 @@
import { Roles } from '../enums';
export interface IJwtPayload {
sub: string;
roles: string[];
roles: Roles[];
}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Roles } from '~/auth/enums';
export const ROLE_METADATA_KEY = 'roles';
export const AllowedRoles = (...roles: Roles[]) => SetMetadata(ROLE_METADATA_KEY, roles);

View File

@ -1,2 +1,3 @@
export * from './allowed-roles.decorator';
export * from './public.decorator';
export * from './user.decorator';

View File

@ -6,7 +6,7 @@ import { IS_PUBLIC_KEY } from '../decorators';
@Injectable()
export class AccessTokenGuard extends AuthGuard('access-token') {
constructor(private reflector: Reflector) {
constructor(protected reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {

View File

@ -1 +1,2 @@
export * from './access-token.guard';
export * from './roles-guard';

View File

@ -0,0 +1,28 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Roles } from '~/auth/enums';
import { ROLE_METADATA_KEY } from '../decorators';
import { AccessTokenGuard } from './access-token.guard';
@Injectable()
export class RolesGuard extends AccessTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
const allowedRoles = this.reflector.getAllAndOverride<Roles[]>(ROLE_METADATA_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!allowedRoles) {
return true;
}
return allowedRoles.some((role) => user.roles.includes(role));
}
}

View File

@ -0,0 +1 @@
export * from './lookup.controller';

View File

@ -0,0 +1,23 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataArrayResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { LookupService } from '../services';
@Controller('lookup')
@ApiTags('Lookups')
@ApiBearerAuth()
export class LookupController {
constructor(private readonly lookupService: LookupService) {}
@UseGuards(AccessTokenGuard)
@Get('default-avatars')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultAvatars() {
const avatars = await this.lookupService.findDefaultAvatar();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DocumentModule } from '~/document/document.module';
import { LookupController } from './controllers';
import { LookupService } from './services';
@Module({
controllers: [LookupController],
providers: [LookupService],
imports: [DocumentModule],
})
export class LookupModule {}

View File

@ -0,0 +1 @@
export * from './lookup.service';

View File

@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { DocumentType } from '~/document/enums';
import { DocumentService } from '~/document/services';
@Injectable()
export class LookupService {
constructor(private readonly documentService: DocumentService) {}
findDefaultAvatar() {
return this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
}
}

View File

@ -1,8 +1,9 @@
import { Body, Controller, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ResponseFactory } from '~/core/utils';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response';
@ -11,11 +12,12 @@ import { CustomerService } from '../services';
@Controller('customers')
@ApiTags('Customers')
@ApiBearerAuth()
@UseGuards(AccessTokenGuard)
export class CustomerController {
constructor(private readonly customerService: CustomerService) {}
@Patch('')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
const customer = await this.customerService.updateCustomer(sub, body);
@ -23,6 +25,7 @@ export class CustomerController {
}
@Patch('settings/notifications')
@UseGuards(AccessTokenGuard)
async updateNotificationSettings(
@AuthenticatedUser() { sub }: IJwtPayload,
@Body() body: UpdateNotificationsSettingsRequestDto,

View File

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateThemeEntity1733748083604 implements MigrationInterface {
name = 'CreateThemeEntity1733748083604';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "themes"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"color" character varying(255) NOT NULL,
"avatar_id" uuid, "junior_id" uuid NOT NULL,
CONSTRAINT "REL_73fcb76399a308cdd2d431a8f2" UNIQUE ("junior_id"),
CONSTRAINT "PK_ddbeaab913c18682e5c88155592" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "themes" ADD CONSTRAINT "FK_169b672cc28cc757e1f4464864d" FOREIGN KEY ("avatar_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "themes" ADD CONSTRAINT "FK_73fcb76399a308cdd2d431a8f2e" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "themes" DROP CONSTRAINT "FK_73fcb76399a308cdd2d431a8f2e"`);
await queryRunner.query(`ALTER TABLE "themes" DROP CONSTRAINT "FK_169b672cc28cc757e1f4464864d"`);
await queryRunner.query(`DROP TABLE "themes"`);
}
}

View File

@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { Document } from '../../document/entities';
import { DocumentType } from '../../document/enums';
const DEFAULT_AVATARS = [
{
id: uuid(),
name: 'vacation',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'colors',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'astronaut',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'pet',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'disney',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'clothes',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'playstation',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'football',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'cars',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
];
export class SeedDefaultAvatar1733750228289 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.manager.getRepository(Document).save(DEFAULT_AVATARS);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await DEFAULT_AVATARS.forEach(async (avatar) => {
await queryRunner.manager
.getRepository(Document)
.delete({ name: avatar.name, documentType: avatar.documentType });
});
}
}

View File

@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTaskEntities1733904556416 implements MigrationInterface {
name = 'CreateTaskEntities1733904556416';
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 CASCADE 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"`);
}
}

View File

@ -6,3 +6,5 @@ export * from './1733298524771-create-customer-entity';
export * from './1733314952318-create-device-entity';
export * from './1733731507261-create-junior-entity';
export * from './1733732021622-create-guardian-entity';
export * from './1733748083604-create-theme-entity';
export * from './1733750228289-seed-default-avatar';

View File

@ -3,4 +3,5 @@ import { DocumentType } from '../enums';
export const BUCKETS: Record<DocumentType, string> = {
[DocumentType.PROFILE_PICTURE]: 'profile-pictures',
[DocumentType.PASSPORT]: 'passports',
[DocumentType.DEFAULT_AVATAR]: 'avatars',
};

View File

@ -9,5 +9,6 @@ import { DocumentService, OciService } from './services';
imports: [TypeOrmModule.forFeature([Document])],
controllers: [DocumentController],
providers: [DocumentService, OciService, DocumentRepository],
exports: [DocumentService],
})
export class DocumentModule {}

View File

@ -1,6 +1,8 @@
import { Column, Entity, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { User } from '~/auth/entities';
import { Junior } 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';
@Entity('documents')
@ -18,13 +20,22 @@ export class Document {
documentType!: DocumentType;
@OneToOne(() => User, (user) => user.profilePicture, { onDelete: 'CASCADE' })
user!: User;
user?: User;
@OneToOne(() => Junior, (junior) => junior.civilIdFront, { onDelete: 'CASCADE' })
juniorCivilIdFront!: User;
juniorCivilIdFront?: User;
@OneToOne(() => Junior, (junior) => junior.civilIdBack, { onDelete: 'CASCADE' })
juniorCivilIdBack!: User;
juniorCivilIdBack?: User;
@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;

View File

@ -1,4 +1,5 @@
export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT',
DEFAULT_AVATAR = 'DEFAULT_AVATAR',
}

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { UploadResponseDto } from '../dtos/response';
import { Document } from '../entities';
@ -17,4 +17,8 @@ export class DocumentRepository {
}),
);
}
findDocuments(where: FindOptionsWhere<Document>) {
return this.documentRepository.find({ where });
}
}

View File

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { UploadDocumentRequestDto } from '../dtos/request';
import { Document } from '../entities';
import { DocumentRepository } from '../repositories';
import { OciService } from './oci.service';
@ -10,4 +12,8 @@ export class DocumentService {
const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest);
return this.documentRepository.createDocument(uploadedFile);
}
findDocuments(where: FindOptionsWhere<Document>) {
return this.documentRepository.findDocuments(where);
}
}

View File

@ -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;

View File

@ -1,14 +1,15 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateJuniorRequestDto } from '../dtos/request';
import { JuniorResponseDto } from '../dtos/response';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { JuniorResponseDto, ThemeResponseDto } from '../dtos/response';
import { JuniorService } from '../services';
@Controller('juniors')
@ -18,7 +19,8 @@ export class JuniorController {
constructor(private readonly juniorService: JuniorService) {}
@Post()
@UseGuards(AccessTokenGuard)
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(JuniorResponseDto)
async createJunior(@Body() body: CreateJuniorRequestDto, @AuthenticatedUser() user: IJwtPayload) {
const junior = await this.juniorService.createJuniors(body, user.sub);
@ -27,7 +29,8 @@ export class JuniorController {
}
@Get()
@UseGuards(AccessTokenGuard)
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(JuniorResponseDto)
async findJuniors(@AuthenticatedUser() user: IJwtPayload, @Query() pageOptions: PageOptionsRequestDto) {
const [juniors, count] = await this.juniorService.findJuniorsByGuardianId(user.sub, pageOptions);
@ -43,7 +46,8 @@ export class JuniorController {
}
@Get(':juniorId')
@UseGuards(AccessTokenGuard)
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(JuniorResponseDto)
async findJuniorById(
@AuthenticatedUser() user: IJwtPayload,
@ -53,4 +57,13 @@ export class JuniorController {
return ResponseFactory.data(new JuniorResponseDto(junior));
}
@Post('set-theme')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@ApiDataResponse(JuniorResponseDto)
async setTheme(@Body() body: SetThemeRequestDto, @AuthenticatedUser() user: IJwtPayload) {
const theme = await this.juniorService.setTheme(body, user.sub);
return ResponseFactory.data(new ThemeResponseDto(theme));
}
}

View File

@ -1,2 +1,3 @@
export * from './create-junior-user.request.dto';
export * from './create-junior.request.dto';
export * from './set-theme.request.dto';

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUUID } from 'class-validator';
import { ThemeColor } from '~/junior/enums';
export class SetThemeRequestDto {
@ApiProperty({ example: ThemeColor.VIOLET })
@IsEnum(ThemeColor)
color!: ThemeColor;
@ApiProperty({ example: 'fbfre-4f4f-4f4f-4f4f' })
@IsUUID()
avatarId!: string;
}

View File

@ -1 +1,2 @@
export * from './junior.response.dto';
export * from './theme.response.dto';

View File

@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { Theme } from '~/junior/entities';
import { ThemeColor } from '~/junior/enums';
export class ThemeResponseDto {
@ApiProperty({ example: '645fds-4f5s4f-4f5s4f-4f5s4f' })
id!: string;
@ApiProperty({ example: ThemeColor.BLUE })
color!: string;
@ApiProperty({ example: '645fds-4f5s4f-4f5s4f-4f5s4f' })
avatarId!: string;
@ApiProperty({ example: '645fds-4f5s4f-4f5s4f-4f5s4f' })
juniorId!: string;
constructor(theme: Theme | null) {
if (theme) {
this.id = theme.id;
this.color = theme.color;
this.avatarId = theme.avatarId;
this.juniorId = theme.juniorId;
}
}
}

View File

@ -1 +1,2 @@
export * from './junior.entity';
export * from './theme.entity';

View File

@ -5,6 +5,7 @@ import {
Entity,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
@ -12,7 +13,9 @@ 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';
@Entity('juniors')
export class Junior extends BaseEntity {
@ -46,10 +49,16 @@ export class Junior extends BaseEntity {
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@OneToOne(() => Theme, (theme) => theme.junior, { cascade: true, nullable: true })
theme!: Theme;
@ManyToOne(() => Guardian, (guardian) => guardian.juniors)
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@OneToMany(() => Task, (task) => task.assignedTo)
tasks?: Task[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;

View File

@ -0,0 +1,26 @@
import { BaseEntity, Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Document } from '~/document/entities';
import { Junior } from './junior.entity';
@Entity('themes')
export class Theme extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { length: 255, name: 'color' })
color!: string;
@Column('uuid', { name: 'avatar_id', nullable: true })
avatarId!: string;
@ManyToOne(() => Document, (document) => document.themes, { cascade: true, nullable: true })
@JoinColumn({ name: 'avatar_id' })
avatar!: Document;
@Column('uuid', { name: 'junior_id' })
juniorId!: string;
@OneToOne(() => Junior, (junior) => junior.theme, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
}

View File

@ -1 +1,2 @@
export * from './relationship.enum';
export * from './theme-color.enum';

View File

@ -0,0 +1,5 @@
export enum ThemeColor {
BLUE = 'BLUE',
GREEN = 'GREEN',
VIOLET = 'VIOLET',
}

View File

@ -3,13 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '~/auth/auth.module';
import { CustomerModule } from '~/customer/customer.module';
import { JuniorController } from './controllers';
import { Junior } from './entities';
import { Junior, Theme } from './entities';
import { JuniorRepository } from './repositories';
import { JuniorService } from './services';
@Module({
controllers: [JuniorController],
providers: [JuniorService, JuniorRepository],
imports: [TypeOrmModule.forFeature([Junior]), AuthModule, CustomerModule],
imports: [TypeOrmModule.forFeature([Junior, Theme]), AuthModule, CustomerModule],
})
export class JuniorModule {}

View File

@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { SetThemeRequestDto } from '../dtos/request';
import { Theme } from '../entities';
import { Junior } from '../entities/junior.entity';
const FIRST_PAGE = 1;
@Injectable()
@ -17,10 +19,23 @@ export class JuniorRepository {
});
}
findJuniorById(juniorId: string, guardianId: string) {
findJuniorById(juniorId: string, guardianId?: string) {
return this.juniorRepository.findOne({
where: { id: juniorId, guardianId },
relations: ['customer', 'customer.user'],
relations: ['customer', 'customer.user', 'theme'],
});
}
setTheme(body: SetThemeRequestDto, junior: Junior) {
junior.theme = Theme.create({ ...body, junior });
return this.juniorRepository.save(junior);
}
removeTheme(theme: Theme) {
return this.juniorRepository.manager.remove(theme);
}
findThemeForJunior(juniorId: string) {
return this.juniorRepository.manager.findOne(Theme, { where: { juniorId }, relations: ['avatar'] });
}
}

View File

@ -4,7 +4,7 @@ import { Roles } from '~/auth/enums';
import { UserService } from '~/auth/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomerService } from '~/customer/services';
import { CreateJuniorRequestDto } from '../dtos/request';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { Junior } from '../entities';
import { JuniorRepository } from '../repositories';
@ -50,7 +50,7 @@ export class JuniorService {
return this.findJuniorById(user.id, guardianId);
}
async findJuniorById(juniorId: string, guardianId: string) {
async findJuniorById(juniorId: string, guardianId?: string) {
const junior = await this.juniorRepository.findJuniorById(juniorId, guardianId);
if (!junior) {
@ -59,6 +59,16 @@ export class JuniorService {
return junior;
}
@Transactional()
async setTheme(body: SetThemeRequestDto, juniorId: string) {
const junior = await this.findJuniorById(juniorId);
if (junior.theme) {
await this.juniorRepository.removeTheme(junior.theme);
}
await this.juniorRepository.setTheme(body, junior);
return this.juniorRepository.findThemeForJunior(juniorId);
}
findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) {
return this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions);
}

View File

@ -0,0 +1 @@
export * from './task.controller';

View 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);
}
}

View 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;
}

View File

@ -0,0 +1,3 @@
export * from './create-task.request.dto';
export * from './task-submission.request.dto';
export * from './tasks-filter-options.request.dto';

View 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;
}

View 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;
}

View File

@ -0,0 +1 @@
export * from './task.response.dto';

View 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;
}
}

View File

@ -0,0 +1 @@
export * from './task.entity';

View 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, { onDelete: 'CASCADE' })
@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;
}

View File

@ -0,0 +1,90 @@
import {
BaseEntity,
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 extends BaseEntity {
@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, { cascade: true })
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
View File

@ -0,0 +1,2 @@
export * from './submission-status.enum';
export * from './task-status.enum';

View File

@ -0,0 +1,5 @@
export enum SubmissionStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

@ -0,0 +1,5 @@
export enum TaskStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
}

View File

@ -0,0 +1,6 @@
export enum TaskFrequency {
ONE_TIME = 'ONE_TIME',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

View File

@ -0,0 +1 @@
export * from './task.repository';

View File

@ -0,0 +1,102 @@
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) {
const submission = task.submission || new TaskSubmission();
submission.status = SubmissionStatus.PENDING;
submission.submittedAt = new Date();
submission.taskId = task.id;
submission.proofOfCompletionId = body.imageId;
return task.save();
}
approveSubmission(submission: TaskSubmission) {
submission.status = SubmissionStatus.APPROVED;
return submission.save();
}
rejectSubmission(submission: TaskSubmission) {
submission.status = SubmissionStatus.REJECTED;
return submission.save();
}
}

View File

@ -0,0 +1 @@
export * from './task.service';

View File

@ -0,0 +1,71 @@
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.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
View 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 {}