feat: working on saving goals jounrey for juniors

This commit is contained in:
Abdalhamid Alhamad
2024-12-15 12:44:59 +03:00
parent 24d990592d
commit 4d2f6f57f4
35 changed files with 754 additions and 2 deletions

View File

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

View File

@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSavingGoalsEntities1734246386471 implements MigrationInterface {
name = 'CreateSavingGoalsEntities1734246386471';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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<void> {
await queryRunner.query(
`INSERT INTO categories (name, type) VALUES ${DEFAULT_CATEGORIES.map(
(category) => `('${category.name}', '${category.type}')`,
)}`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM categories WHERE name IN (${DEFAULT_CATEGORIES.map(
(category) => `'${category.name}'`,
)}) AND type = '${CategoryType.GLOBAL}'`,
);
}
}

View File

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

View File

@ -7,4 +7,5 @@ export const BUCKETS: Record<DocumentType, string> = {
[DocumentType.DEFAULT_TASKS_LOGO]: 'tasks-logo',
[DocumentType.CUSTOM_AVATAR]: 'avatars',
[DocumentType.CUSTOM_TASKS_LOGO]: 'tasks-logo',
[DocumentType.GOALS]: 'goals',
};

View File

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

View File

@ -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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './create-category.request.dto';
export * from './create-goal.request.dto';
export * from './fund-goal.request.dto';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from './category.entity';
export * from './saving-goal.entity';

View File

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

View File

@ -0,0 +1,4 @@
export class CategoryType {
static readonly GLOBAL = 'GLOBAL';
static readonly CUSTOM = 'CUSTOM';
}

View File

@ -0,0 +1 @@
export * from './category-type.enum';

View File

@ -0,0 +1,4 @@
export interface IGoalStats {
totalTarget: number;
totalSaved: number;
}

View File

@ -0,0 +1 @@
export * from './goal-stats.interface';

View File

@ -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<Category>) {}
createCustomCategory(juniorId: string, body: createCategoryRequestDto): Promise<Category> {
return this.categoryRepository.save(
this.categoryRepository.create({
juniorId,
name: body.name,
type: CategoryType.CUSTOM,
}),
);
}
findCategoryById(ids: string[], juniorId: string): Promise<Category[]> {
return this.categoryRepository.find({
where: [
{ id: In(ids), type: CategoryType.GLOBAL },
{ id: In(ids), juniorId, type: CategoryType.CUSTOM },
],
});
}
findCategories(juniorId: string): Promise<Category[]> {
return this.categoryRepository.find({
where: [{ type: 'GLOBAL' }, { juniorId, type: CategoryType.CUSTOM }],
});
}
}

View File

@ -0,0 +1,2 @@
export * from './category.repository';
export * from './saving-goals.repository';

View File

@ -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<SavingGoal>) {}
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();
}
}

View File

@ -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 {}

View File

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

View File

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

View File

@ -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<IGoalStats> {
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);
}
}),
);
}
}

View File

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

View File

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