diff --git a/src/app.module.ts b/src/app.module.ts index edd0834..858af8e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,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 { SavingGoalsModule } from './saving-goals/saving-goals.module'; import { TaskModule } from './task/task.module'; @Module({ controllers: [], @@ -54,6 +55,8 @@ import { TaskModule } from './task/task.module'; JuniorModule, TaskModule, GuardianModule, + SavingGoalsModule, + OtpModule, DocumentModule, LookupModule, diff --git a/src/db/migrations/1734246386471-create-saving-goals-entities.ts b/src/db/migrations/1734246386471-create-saving-goals-entities.ts new file mode 100644 index 0000000..6869e66 --- /dev/null +++ b/src/db/migrations/1734246386471-create-saving-goals-entities.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSavingGoalsEntities1734246386471 implements MigrationInterface { + name = 'CreateSavingGoalsEntities1734246386471'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "saving_goals" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying(255) NOT NULL, + "description" character varying(255), + "due_date" date NOT NULL, + "target_amount" numeric(12,3) NOT NULL, + "current_amount" numeric(12,3) NOT NULL DEFAULT '0', + "image_id" uuid, "junior_id" uuid NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_5193f14c1c3a38e6657a159795e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "categories" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying(255) NOT NULL, + "type" character varying(255) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "junior_id" uuid, CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "saving_goals_categories" + ("saving_goal_id" uuid NOT NULL, + "category_id" uuid NOT NULL, + CONSTRAINT "PK_a49d4f57d06d0a36a8385b6c28f" PRIMARY KEY ("saving_goal_id", "category_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d421de423f21c01672ea7c2e98" ON "saving_goals_categories" ("saving_goal_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a721a8f7f5b6fe93f3603ebc" ON "saving_goals_categories" ("category_id") `, + ); + await queryRunner.query( + `ALTER TABLE "saving_goals" ADD CONSTRAINT "FK_dad35932272342c1a247a2cee1c" FOREIGN KEY ("image_id") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "saving_goals" ADD CONSTRAINT "FK_f494ba0a361b2f9cbe5f6e56e38" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "categories" ADD CONSTRAINT "FK_4f98e0b010d5e90cae0a2007748" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "saving_goals_categories" ADD CONSTRAINT "FK_d421de423f21c01672ea7c2e98f" FOREIGN KEY ("saving_goal_id") REFERENCES "saving_goals"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "saving_goals_categories" ADD CONSTRAINT "FK_b0a721a8f7f5b6fe93f3603ebc8" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "saving_goals_categories" DROP CONSTRAINT "FK_b0a721a8f7f5b6fe93f3603ebc8"`); + await queryRunner.query(`ALTER TABLE "saving_goals_categories" DROP CONSTRAINT "FK_d421de423f21c01672ea7c2e98f"`); + await queryRunner.query(`ALTER TABLE "categories" DROP CONSTRAINT "FK_4f98e0b010d5e90cae0a2007748"`); + await queryRunner.query(`ALTER TABLE "saving_goals" DROP CONSTRAINT "FK_f494ba0a361b2f9cbe5f6e56e38"`); + await queryRunner.query(`ALTER TABLE "saving_goals" DROP CONSTRAINT "FK_dad35932272342c1a247a2cee1c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b0a721a8f7f5b6fe93f3603ebc"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d421de423f21c01672ea7c2e98"`); + await queryRunner.query(`DROP TABLE "saving_goals_categories"`); + await queryRunner.query(`DROP TABLE "categories"`); + await queryRunner.query(`DROP TABLE "saving_goals"`); + } +} diff --git a/src/db/migrations/1734247702310-seeds-goals-categories.ts b/src/db/migrations/1734247702310-seeds-goals-categories.ts new file mode 100644 index 0000000..ed31973 --- /dev/null +++ b/src/db/migrations/1734247702310-seeds-goals-categories.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { CategoryType } from '~/saving-goals/enums'; +const DEFAULT_CATEGORIES = [ + { + name: 'School', + type: CategoryType.GLOBAL, + }, + { + name: 'Toys', + type: CategoryType.GLOBAL, + }, + { + name: 'Games', + type: CategoryType.GLOBAL, + }, + { + name: 'Clothes', + type: CategoryType.GLOBAL, + }, + { + name: 'Hobbies', + type: CategoryType.GLOBAL, + }, + { + name: 'Party', + type: CategoryType.GLOBAL, + }, + { + name: 'Sport', + type: CategoryType.GLOBAL, + }, + { + name: 'University', + type: CategoryType.GLOBAL, + }, + { + name: 'Travel', + type: CategoryType.GLOBAL, + }, +]; +export class SeedsGoalsCategories1734247702310 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO categories (name, type) VALUES ${DEFAULT_CATEGORIES.map( + (category) => `('${category.name}', '${category.type}')`, + )}`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM categories WHERE name IN (${DEFAULT_CATEGORIES.map( + (category) => `'${category.name}'`, + )}) AND type = '${CategoryType.GLOBAL}'`, + ); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3fe36f1..ee456a9 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,3 +10,5 @@ export * from './1733750228289-seed-default-avatar'; export * from './1733904556416-create-task-entities'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1733993920226-create-customer-notifications-settings-table'; +export * from './1734246386471-create-saving-goals-entities'; +export * from './1734247702310-seeds-goals-categories'; diff --git a/src/document/constants/buckets.constant.ts b/src/document/constants/buckets.constant.ts index b331e3c..2736900 100644 --- a/src/document/constants/buckets.constant.ts +++ b/src/document/constants/buckets.constant.ts @@ -7,4 +7,5 @@ export const BUCKETS: Record = { [DocumentType.DEFAULT_TASKS_LOGO]: 'tasks-logo', [DocumentType.CUSTOM_AVATAR]: 'avatars', [DocumentType.CUSTOM_TASKS_LOGO]: 'tasks-logo', + [DocumentType.GOALS]: 'goals', }; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 1f0d4e9..989ba9b 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -2,6 +2,7 @@ import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDate import { User } from '~/auth/entities'; import { Customer } from '~/customer/entities'; import { Junior, Theme } from '~/junior/entities'; +import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { DocumentType } from '../enums'; @@ -38,6 +39,9 @@ export class Document { @OneToMany(() => TaskSubmission, (taskSubmission) => taskSubmission.proofOfCompletion) submissions?: TaskSubmission[]; + @OneToMany(() => SavingGoal, (savingGoal) => savingGoal.image) + goals?: SavingGoal[]; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date; diff --git a/src/document/enums/document-type.enum.ts b/src/document/enums/document-type.enum.ts index b3fc852..4969d0e 100644 --- a/src/document/enums/document-type.enum.ts +++ b/src/document/enums/document-type.enum.ts @@ -5,4 +5,5 @@ export enum DocumentType { DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO', CUSTOM_AVATAR = 'CUSTOM_AVATAR', CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO', + GOALS = 'GOALS', } diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index da68626..31b9a3f 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -13,6 +13,7 @@ import { import { Customer } from '~/customer/entities'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Category, SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { Relationship } from '../enums'; import { Theme } from './theme.entity'; @@ -59,6 +60,12 @@ export class Junior extends BaseEntity { @OneToMany(() => Task, (task) => task.assignedTo) tasks?: Task[]; + @OneToMany(() => SavingGoal, (savingGoal) => savingGoal.junior) + goals?: SavingGoal[]; + + @OneToMany(() => Category, (category) => category.junior) + categories?: Category[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/saving-goals/controllers/index.ts b/src/saving-goals/controllers/index.ts new file mode 100644 index 0000000..dd26911 --- /dev/null +++ b/src/saving-goals/controllers/index.ts @@ -0,0 +1 @@ +export * from './saving-goals.controller'; diff --git a/src/saving-goals/controllers/saving-goals.controller.ts b/src/saving-goals/controllers/saving-goals.controller.ts new file mode 100644 index 0000000..00c0992 --- /dev/null +++ b/src/saving-goals/controllers/saving-goals.controller.ts @@ -0,0 +1,95 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Param, 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 { 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 { createCategoryRequestDto, CreateGoalRequestDto, FundGoalRequestDto } from '../dtos/request'; +import { + CategoriesListResponseDto, + CategoryResponseDto, + GoalsStatsResponseDto, + SavingGoalDetailsResponseDto, +} from '../dtos/response'; +import { SavingGoalsService } from '../services'; +import { CategoryService } from '../services/category.service'; + +@Controller('saving-goals') +@ApiTags('Saving Goals') +@UseGuards(RolesGuard) +@AllowedRoles(Roles.JUNIOR) +@ApiBearerAuth() +export class SavingGoalsController { + constructor( + private readonly savingGoalsService: SavingGoalsService, + private readonly categoryService: CategoryService, + ) {} + + @Post() + @ApiDataResponse(SavingGoalDetailsResponseDto) + async createGoal(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateGoalRequestDto) { + const goal = await this.savingGoalsService.createGoal(sub, body); + + return ResponseFactory.data(new SavingGoalDetailsResponseDto(goal)); + } + + @Get() + @ApiDataPageResponse(SavingGoalDetailsResponseDto) + async getGoals(@AuthenticatedUser() { sub }: IJwtPayload, @Query() pageOptions: PageOptionsRequestDto) { + const [goals, itemCount] = await this.savingGoalsService.findGoals(sub, pageOptions); + + return ResponseFactory.dataPage( + goals.map((goal) => new SavingGoalDetailsResponseDto(goal)), + { + page: pageOptions.page, + size: pageOptions.size, + itemCount, + }, + ); + } + + @Get('stats') + @ApiDataResponse(GoalsStatsResponseDto) + async getStats(@AuthenticatedUser() { sub }: IJwtPayload) { + const stats = await this.savingGoalsService.getStats(sub); + + return ResponseFactory.data(new GoalsStatsResponseDto(stats)); + } + + @Get('categories') + @ApiDataResponse(CategoriesListResponseDto) + async getCategories(@AuthenticatedUser() { sub }: IJwtPayload) { + const categories = await this.categoryService.findCategories(sub); + + return ResponseFactory.data(new CategoriesListResponseDto(categories)); + } + + @Get(':goalId') + @ApiDataResponse(SavingGoalDetailsResponseDto) + async getGoal(@AuthenticatedUser() { sub }: IJwtPayload, @Param('goalId', CustomParseUUIDPipe) goalId: string) { + const goal = await this.savingGoalsService.findGoalById(sub, goalId); + + return ResponseFactory.data(new SavingGoalDetailsResponseDto(goal)); + } + + @Post('categories') + @ApiDataResponse(CategoryResponseDto) + async createCategory(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: createCategoryRequestDto) { + const category = await this.categoryService.createCustomCategory(sub, body); + return ResponseFactory.data(new CategoryResponseDto(category)); + } + + @Post(':goalId/fund') + @HttpCode(HttpStatus.NO_CONTENT) + async fundGoal( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('goalId', CustomParseUUIDPipe) goalId: string, + @Body() body: FundGoalRequestDto, + ) { + await this.savingGoalsService.fundGoal(sub, goalId, body); + } +} diff --git a/src/saving-goals/dtos/request/create-category.request.dto.ts b/src/saving-goals/dtos/request/create-category.request.dto.ts new file mode 100644 index 0000000..3de4852 --- /dev/null +++ b/src/saving-goals/dtos/request/create-category.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class createCategoryRequestDto { + @ApiProperty() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'category.name' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'category.name' }) }) + name!: string; +} diff --git a/src/saving-goals/dtos/request/create-goal.request.dto.ts b/src/saving-goals/dtos/request/create-goal.request.dto.ts new file mode 100644 index 0000000..998a051 --- /dev/null +++ b/src/saving-goals/dtos/request/create-goal.request.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class CreateGoalRequestDto { + @ApiProperty() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'goal.name' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'goal.name' }) }) + name!: string; + + @ApiPropertyOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'goal.description' }) }) + @IsOptional() + description?: string; + + @ApiProperty({ example: '2021-12-31' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'goal.dueDate' }) }) + dueDate!: string; + + @ApiProperty() + @IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'goal.targetAmount' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'goal.targetAmount' }) }) + targetAmount!: number; + + @ApiProperty() + @IsArray({ message: i18n('validation.IsArray', { path: 'general', property: 'goal.categoryIds' }) }) + @IsUUID('4', { each: true, message: i18n('validation.IsUUID', { path: 'general', property: 'goal.categoryIds' }) }) + @Transform(({ value }) => (typeof value === 'string' ? [value] : value)) + categoryIds!: string[]; + + @ApiPropertyOptional() + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'goal.imageId' }) }) + @IsOptional() + imageId?: string; +} diff --git a/src/saving-goals/dtos/request/fund-goal.request.dto.ts b/src/saving-goals/dtos/request/fund-goal.request.dto.ts new file mode 100644 index 0000000..a8acd92 --- /dev/null +++ b/src/saving-goals/dtos/request/fund-goal.request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, Min } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +const MIN_FUND = 1; +export class FundGoalRequestDto { + @ApiProperty({ example: '200' }) + @IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'goal.fundAmount' }) }) + @Min(MIN_FUND) + fundAmount!: number; +} diff --git a/src/saving-goals/dtos/request/index.ts b/src/saving-goals/dtos/request/index.ts new file mode 100644 index 0000000..56fe67d --- /dev/null +++ b/src/saving-goals/dtos/request/index.ts @@ -0,0 +1,3 @@ +export * from './create-category.request.dto'; +export * from './create-goal.request.dto'; +export * from './fund-goal.request.dto'; diff --git a/src/saving-goals/dtos/response/categories-list.response.dto.ts b/src/saving-goals/dtos/response/categories-list.response.dto.ts new file mode 100644 index 0000000..b95dbc1 --- /dev/null +++ b/src/saving-goals/dtos/response/categories-list.response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Category } from '~/saving-goals/entities'; +import { CategoryResponseDto } from './category-response.dto'; + +export class CategoriesListResponseDto { + @ApiProperty({ type: CategoryResponseDto, isArray: true }) + globalCategories!: CategoryResponseDto[]; + + @ApiProperty({ type: CategoryResponseDto, isArray: true }) + customCategories!: CategoryResponseDto[]; + + constructor(data: { globalCategories: Category[]; customCategories: Category[] }) { + this.globalCategories = data.globalCategories.map((category) => new CategoryResponseDto(category)); + this.customCategories = data.customCategories.map((category) => new CategoryResponseDto(category)); + } +} diff --git a/src/saving-goals/dtos/response/category-response.dto.ts b/src/saving-goals/dtos/response/category-response.dto.ts new file mode 100644 index 0000000..31e602f --- /dev/null +++ b/src/saving-goals/dtos/response/category-response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CategoryType } from '~/saving-goals/enums'; + +export class CategoryResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ example: CategoryType.CUSTOM }) + type!: CategoryType; + + constructor(data: CategoryResponseDto) { + this.id = data.id; + this.name = data.name; + this.type = data.type; + } +} diff --git a/src/saving-goals/dtos/response/goals-stats.response.dto.ts b/src/saving-goals/dtos/response/goals-stats.response.dto.ts new file mode 100644 index 0000000..88e6df8 --- /dev/null +++ b/src/saving-goals/dtos/response/goals-stats.response.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IGoalStats } from '~/saving-goals/interfaces'; +const ZERO = 0; +export class GoalsStatsResponseDto { + @ApiProperty() + totalTarget: number; + + @ApiProperty() + totalSaved: number; + + constructor(stats: IGoalStats) { + this.totalTarget = stats.totalTarget || ZERO; + this.totalSaved = stats.totalSaved || ZERO; + } +} diff --git a/src/saving-goals/dtos/response/index.ts b/src/saving-goals/dtos/response/index.ts new file mode 100644 index 0000000..74359dd --- /dev/null +++ b/src/saving-goals/dtos/response/index.ts @@ -0,0 +1,4 @@ +export * from './categories-list.response.dto'; +export * from './category-response.dto'; +export * from './goals-stats.response.dto'; +export * from './saving-goal-details.response.dto'; diff --git a/src/saving-goals/dtos/response/saving-goal-details.response.dto.ts b/src/saving-goals/dtos/response/saving-goal-details.response.dto.ts new file mode 100644 index 0000000..0311e0a --- /dev/null +++ b/src/saving-goals/dtos/response/saving-goal-details.response.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DocumentMetaResponseDto } from '~/document/dtos/response'; +import { SavingGoal } from '~/saving-goals/entities'; +import { CategoryResponseDto } from './category-response.dto'; + +export class SavingGoalDetailsResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + name!: string; + + @ApiPropertyOptional() + description?: string; + + @ApiProperty() + dueDate!: Date; + + @ApiProperty() + targetAmount!: number; + + @ApiProperty() + currentAmount!: number; + + @ApiPropertyOptional({ type: CategoryResponseDto, isArray: true }) + categories?: CategoryResponseDto[]; + + @ApiPropertyOptional({ type: DocumentMetaResponseDto, isArray: true }) + image: DocumentMetaResponseDto | null; + + @ApiProperty() + createdAt!: Date; + + @ApiProperty() + updatedAt!: Date; + + constructor(data: SavingGoal) { + this.id = data.id; + this.name = data.name; + this.description = data.description; + this.dueDate = data.dueDate; + this.targetAmount = data.targetAmount; + this.currentAmount = data.currentAmount; + this.categories = data.categories ? data.categories.map((category) => new CategoryResponseDto(category)) : []; + this.image = data.image ? new DocumentMetaResponseDto(data.image) : null; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + } +} diff --git a/src/saving-goals/entities/category.entity.ts b/src/saving-goals/entities/category.entity.ts new file mode 100644 index 0000000..42129f3 --- /dev/null +++ b/src/saving-goals/entities/category.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Junior } from '~/junior/entities'; +import { CategoryType } from '../enums'; +import { SavingGoal } from './saving-goal.entity'; + +@Entity('categories') +export class Category { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Column({ type: 'varchar', length: 255, name: 'type' }) + type!: CategoryType; + + @Column({ type: 'uuid', name: 'junior_id', nullable: true }) + juniorId!: string; + + @ManyToOne(() => Junior, (junior) => junior.categories, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'junior_id' }) + junior!: Junior; + + @ManyToMany(() => SavingGoal, (savingGoal) => savingGoal.categories) + goals!: SavingGoal[]; + + @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/saving-goals/entities/index.ts b/src/saving-goals/entities/index.ts new file mode 100644 index 0000000..754584e --- /dev/null +++ b/src/saving-goals/entities/index.ts @@ -0,0 +1,2 @@ +export * from './category.entity'; +export * from './saving-goal.entity'; diff --git a/src/saving-goals/entities/saving-goal.entity.ts b/src/saving-goals/entities/saving-goal.entity.ts new file mode 100644 index 0000000..def155a --- /dev/null +++ b/src/saving-goals/entities/saving-goal.entity.ts @@ -0,0 +1,76 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Document } from '~/document/entities'; +import { Junior } from '~/junior/entities'; +import { Category } from './category.entity'; +const ZERO = 0; +@Entity('saving_goals') +export class SavingGoal extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Column({ type: 'varchar', length: 255, name: 'description', nullable: true }) + description!: string; + + @Column({ type: 'date', name: 'due_date' }) + dueDate!: Date; + + @Column({ + type: 'decimal', + name: 'target_amount', + precision: 12, + scale: 3, + transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) }, + }) + targetAmount!: number; + + @Column({ + type: 'decimal', + name: 'current_amount', + precision: 12, + scale: 3, + default: ZERO, + transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) }, + }) + currentAmount!: number; + + @Column({ type: 'uuid', name: 'image_id', nullable: true }) + imageId!: string; + + @Column({ type: 'uuid', name: 'junior_id' }) + juniorId!: string; + + @ManyToOne(() => Document, (document) => document.goals, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'image_id' }) + image!: Document; + + @ManyToOne(() => Junior, (junior) => junior.goals, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'junior_id' }) + junior!: Junior; + + @ManyToMany(() => Category, (category) => category.goals) + @JoinTable({ + name: 'saving_goals_categories', + joinColumn: { name: 'saving_goal_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'category_id', referencedColumnName: 'id' }, + }) + categories!: Category[]; + + @CreateDateColumn({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @CreateDateColumn({ type: 'timestamp', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/saving-goals/enums/category-type.enum.ts b/src/saving-goals/enums/category-type.enum.ts new file mode 100644 index 0000000..8fdba80 --- /dev/null +++ b/src/saving-goals/enums/category-type.enum.ts @@ -0,0 +1,4 @@ +export class CategoryType { + static readonly GLOBAL = 'GLOBAL'; + static readonly CUSTOM = 'CUSTOM'; +} diff --git a/src/saving-goals/enums/index.ts b/src/saving-goals/enums/index.ts new file mode 100644 index 0000000..1ecf223 --- /dev/null +++ b/src/saving-goals/enums/index.ts @@ -0,0 +1 @@ +export * from './category-type.enum'; diff --git a/src/saving-goals/interfaces/goal-stats.interface.ts b/src/saving-goals/interfaces/goal-stats.interface.ts new file mode 100644 index 0000000..3523915 --- /dev/null +++ b/src/saving-goals/interfaces/goal-stats.interface.ts @@ -0,0 +1,4 @@ +export interface IGoalStats { + totalTarget: number; + totalSaved: number; +} diff --git a/src/saving-goals/interfaces/index.ts b/src/saving-goals/interfaces/index.ts new file mode 100644 index 0000000..1ba52ba --- /dev/null +++ b/src/saving-goals/interfaces/index.ts @@ -0,0 +1 @@ +export * from './goal-stats.interface'; diff --git a/src/saving-goals/repositories/category.repository.ts b/src/saving-goals/repositories/category.repository.ts new file mode 100644 index 0000000..20a03df --- /dev/null +++ b/src/saving-goals/repositories/category.repository.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { createCategoryRequestDto } from '../dtos/request'; +import { Category } from '../entities'; +import { CategoryType } from '../enums'; + +@Injectable() +export class CategoryRepository { + constructor(@InjectRepository(Category) private categoryRepository: Repository) {} + + createCustomCategory(juniorId: string, body: createCategoryRequestDto): Promise { + return this.categoryRepository.save( + this.categoryRepository.create({ + juniorId, + name: body.name, + type: CategoryType.CUSTOM, + }), + ); + } + + findCategoryById(ids: string[], juniorId: string): Promise { + return this.categoryRepository.find({ + where: [ + { id: In(ids), type: CategoryType.GLOBAL }, + { id: In(ids), juniorId, type: CategoryType.CUSTOM }, + ], + }); + } + + findCategories(juniorId: string): Promise { + return this.categoryRepository.find({ + where: [{ type: 'GLOBAL' }, { juniorId, type: CategoryType.CUSTOM }], + }); + } +} diff --git a/src/saving-goals/repositories/index.ts b/src/saving-goals/repositories/index.ts new file mode 100644 index 0000000..46ef0e0 --- /dev/null +++ b/src/saving-goals/repositories/index.ts @@ -0,0 +1,2 @@ +export * from './category.repository'; +export * from './saving-goals.repository'; diff --git a/src/saving-goals/repositories/saving-goals.repository.ts b/src/saving-goals/repositories/saving-goals.repository.ts new file mode 100644 index 0000000..a4d7ca2 --- /dev/null +++ b/src/saving-goals/repositories/saving-goals.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { CreateGoalRequestDto } from '../dtos/request'; +import { Category, SavingGoal } from '../entities'; +const ZERO = 0; +const ONE = 1; +@Injectable() +export class SavingGoalsRepository { + constructor(@InjectRepository(SavingGoal) private readonly savingGoalRepository: Repository) {} + + createGoal(juniorId: string, body: CreateGoalRequestDto, categories: Category[]) { + return this.savingGoalRepository.save( + this.savingGoalRepository.create({ + name: body.name, + description: body.description, + dueDate: body.dueDate, + targetAmount: body.targetAmount, + juniorId, + imageId: body.imageId, + categories, + }), + ); + } + + findGoals(juniorId: string, pageOptions: PageOptionsRequestDto) { + return this.savingGoalRepository.findAndCount({ + where: { juniorId }, + take: pageOptions.size, + skip: pageOptions.size * (pageOptions.page - ONE), + relations: ['categories', 'image'], + }); + } + + findGoalById(juniorId: string, goalId: string) { + return this.savingGoalRepository.findOneOrFail({ + where: { juniorId, id: goalId }, + relations: ['categories', 'image'], + }); + } + + fundGoal(juniorId: string, goalId: string, amount: number) { + return this.savingGoalRepository.increment({ juniorId, id: goalId }, 'currentAmount', amount); + } + + getStats(juniorId: string) { + return this.savingGoalRepository + .createQueryBuilder('savingGoal') + .select('SUM(savingGoal.currentAmount)', 'totalSaved') + .addSelect('SUM(savingGoal.targetAmount)', 'totalTarget') + .where('savingGoal.juniorId = :juniorId', { juniorId }) + .getRawOne(); + } +} diff --git a/src/saving-goals/saving-goals.module.ts b/src/saving-goals/saving-goals.module.ts new file mode 100644 index 0000000..3677901 --- /dev/null +++ b/src/saving-goals/saving-goals.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SavingGoalsController } from './controllers'; +import { Category, SavingGoal } from './entities'; +import { CategoryRepository, SavingGoalsRepository } from './repositories'; +import { SavingGoalsService } from './services'; +import { CategoryService } from './services/category.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([SavingGoal, Category])], + providers: [SavingGoalsService, SavingGoalsRepository, CategoryService, CategoryRepository], + controllers: [SavingGoalsController], +}) +export class SavingGoalsModule {} diff --git a/src/saving-goals/services/category.service.ts b/src/saving-goals/services/category.service.ts new file mode 100644 index 0000000..2e701ef --- /dev/null +++ b/src/saving-goals/services/category.service.ts @@ -0,0 +1,35 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { createCategoryRequestDto } from '../dtos/request'; +import { Category } from '../entities'; +import { CategoryType } from '../enums'; +import { CategoryRepository } from '../repositories'; + +@Injectable() +export class CategoryService { + constructor(private readonly categoryRepository: CategoryRepository) {} + + createCustomCategory(juniorId: string, body: createCategoryRequestDto) { + return this.categoryRepository.createCustomCategory(juniorId, body); + } + + async findCategoryByIds(categoryIds: string[], juniorId: string) { + const categories = await this.categoryRepository.findCategoryById(categoryIds, juniorId); + + if (categories.length !== categoryIds.length) { + throw new BadRequestException('CATEGORY.NOT_FOUND'); + } + return categories; + } + + async findCategories(juniorId: string): Promise<{ globalCategories: Category[]; customCategories: Category[] }> { + const categories = await this.categoryRepository.findCategories(juniorId); + + const globalCategories = categories.filter((category) => category.type === CategoryType.GLOBAL); + const customCategories = categories.filter((category) => category.type === CategoryType.CUSTOM); + + return { + globalCategories, + customCategories, + }; + } +} diff --git a/src/saving-goals/services/index.ts b/src/saving-goals/services/index.ts new file mode 100644 index 0000000..3c7af7b --- /dev/null +++ b/src/saving-goals/services/index.ts @@ -0,0 +1 @@ +export * from './saving-goals.service'; diff --git a/src/saving-goals/services/saving-goals.service.ts b/src/saving-goals/services/saving-goals.service.ts new file mode 100644 index 0000000..dde53b6 --- /dev/null +++ b/src/saving-goals/services/saving-goals.service.ts @@ -0,0 +1,81 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { OciService } from '~/document/services'; +import { CreateGoalRequestDto, FundGoalRequestDto } from '../dtos/request'; +import { SavingGoal } from '../entities'; +import { IGoalStats } from '../interfaces'; +import { SavingGoalsRepository } from '../repositories'; +import { CategoryService } from './category.service'; +const ZERO = 0; +@Injectable() +export class SavingGoalsService { + constructor( + private readonly savingGoalsRepository: SavingGoalsRepository, + private readonly categoryService: CategoryService, + private readonly ociService: OciService, + ) {} + + async createGoal(juniorId: string, body: CreateGoalRequestDto) { + if (moment(body.dueDate).isBefore(moment())) { + throw new BadRequestException('GOAL.DUE_DATE_MUST_BE_IN_THE_FUTURE'); + } + + const categories = await this.categoryService.findCategoryByIds(body.categoryIds, juniorId); + + const createdGoal = await this.savingGoalsRepository.createGoal(juniorId, body, categories); + + return this.findGoalById(juniorId, createdGoal.id); + } + + async findGoals(juniorId: string, pageOptions: PageOptionsRequestDto): Promise<[SavingGoal[], number]> { + const [goals, itemCount] = await this.savingGoalsRepository.findGoals(juniorId, pageOptions); + await this.prepareGoalsImages(goals); + + return [goals, itemCount]; + } + + async findGoalById(juniorId: string, goalId: string) { + const goal = await this.savingGoalsRepository.findGoalById(juniorId, goalId); + + if (!goal) { + throw new BadRequestException('GOAL.NOT_FOUND'); + } + + await this.prepareGoalsImages([goal]); + + return goal; + } + + async fundGoal(juniorId: string, goalId: string, body: FundGoalRequestDto) { + const goal = await this.savingGoalsRepository.findGoalById(juniorId, goalId); + + if (!goal) { + throw new BadRequestException('GOAL.NOT_FOUND'); + } + + if (goal.currentAmount + body.fundAmount > goal.targetAmount) { + throw new BadRequestException('GOAL.FUND_EXCEEDS_TOTAL_AMOUNT'); + } + + await this.savingGoalsRepository.fundGoal(juniorId, goalId, body.fundAmount); + } + + async getStats(juniorId: string): Promise { + const result = await this.savingGoalsRepository.getStats(juniorId); + return { + totalTarget: result?.totalTarget, + totalSaved: result?.totalSaved, + }; + } + + private async prepareGoalsImages(goals: SavingGoal[]) { + await Promise.all( + goals.map(async (goal) => { + if (goal.imageId) { + goal.image.url = await this.ociService.generatePreSignedUrl(goal.image); + } + }), + ); + } +} diff --git a/src/task/controllers/task.controller.ts b/src/task/controllers/task.controller.ts index 5cac142..715bf3c 100644 --- a/src/task/controllers/task.controller.ts +++ b/src/task/controllers/task.controller.ts @@ -23,7 +23,6 @@ export class TaskController { @AllowedRoles(Roles.GUARDIAN) async createTask(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateTaskRequestDto) { const task = await this.taskService.createTask(sub, body); - console.log(task.dueDate); return ResponseFactory.data(new TaskResponseDto(task)); } diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts index c9d2a45..932fbf7 100644 --- a/src/task/services/task.service.ts +++ b/src/task/services/task.service.ts @@ -37,7 +37,6 @@ export class TaskService { async findTasks(user: IJwtPayload, query: TasksFilterOptions): Promise<[Task[], number]> { const [tasks, count] = await this.taskRepository.findTasks(user, query); - console.log(tasks); await this.prepareTasksPictures(tasks); return [tasks, count];