mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-26 06:09:41 +00:00
feat: working on saving goals jounrey for juniors
This commit is contained in:
@ -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,
|
||||
|
@ -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"`);
|
||||
}
|
||||
}
|
57
src/db/migrations/1734247702310-seeds-goals-categories.ts
Normal file
57
src/db/migrations/1734247702310-seeds-goals-categories.ts
Normal 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}'`,
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
1
src/saving-goals/controllers/index.ts
Normal file
1
src/saving-goals/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './saving-goals.controller';
|
95
src/saving-goals/controllers/saving-goals.controller.ts
Normal file
95
src/saving-goals/controllers/saving-goals.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
35
src/saving-goals/dtos/request/create-goal.request.dto.ts
Normal file
35
src/saving-goals/dtos/request/create-goal.request.dto.ts
Normal 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;
|
||||
}
|
10
src/saving-goals/dtos/request/fund-goal.request.dto.ts
Normal file
10
src/saving-goals/dtos/request/fund-goal.request.dto.ts
Normal 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;
|
||||
}
|
3
src/saving-goals/dtos/request/index.ts
Normal file
3
src/saving-goals/dtos/request/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './create-category.request.dto';
|
||||
export * from './create-goal.request.dto';
|
||||
export * from './fund-goal.request.dto';
|
@ -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));
|
||||
}
|
||||
}
|
19
src/saving-goals/dtos/response/category-response.dto.ts
Normal file
19
src/saving-goals/dtos/response/category-response.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
15
src/saving-goals/dtos/response/goals-stats.response.dto.ts
Normal file
15
src/saving-goals/dtos/response/goals-stats.response.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
4
src/saving-goals/dtos/response/index.ts
Normal file
4
src/saving-goals/dtos/response/index.ts
Normal 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';
|
@ -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;
|
||||
}
|
||||
}
|
41
src/saving-goals/entities/category.entity.ts
Normal file
41
src/saving-goals/entities/category.entity.ts
Normal 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;
|
||||
}
|
2
src/saving-goals/entities/index.ts
Normal file
2
src/saving-goals/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './category.entity';
|
||||
export * from './saving-goal.entity';
|
76
src/saving-goals/entities/saving-goal.entity.ts
Normal file
76
src/saving-goals/entities/saving-goal.entity.ts
Normal 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;
|
||||
}
|
4
src/saving-goals/enums/category-type.enum.ts
Normal file
4
src/saving-goals/enums/category-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export class CategoryType {
|
||||
static readonly GLOBAL = 'GLOBAL';
|
||||
static readonly CUSTOM = 'CUSTOM';
|
||||
}
|
1
src/saving-goals/enums/index.ts
Normal file
1
src/saving-goals/enums/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './category-type.enum';
|
4
src/saving-goals/interfaces/goal-stats.interface.ts
Normal file
4
src/saving-goals/interfaces/goal-stats.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface IGoalStats {
|
||||
totalTarget: number;
|
||||
totalSaved: number;
|
||||
}
|
1
src/saving-goals/interfaces/index.ts
Normal file
1
src/saving-goals/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './goal-stats.interface';
|
36
src/saving-goals/repositories/category.repository.ts
Normal file
36
src/saving-goals/repositories/category.repository.ts
Normal 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 }],
|
||||
});
|
||||
}
|
||||
}
|
2
src/saving-goals/repositories/index.ts
Normal file
2
src/saving-goals/repositories/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './category.repository';
|
||||
export * from './saving-goals.repository';
|
55
src/saving-goals/repositories/saving-goals.repository.ts
Normal file
55
src/saving-goals/repositories/saving-goals.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
14
src/saving-goals/saving-goals.module.ts
Normal file
14
src/saving-goals/saving-goals.module.ts
Normal 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 {}
|
35
src/saving-goals/services/category.service.ts
Normal file
35
src/saving-goals/services/category.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
1
src/saving-goals/services/index.ts
Normal file
1
src/saving-goals/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './saving-goals.service';
|
81
src/saving-goals/services/saving-goals.service.ts
Normal file
81
src/saving-goals/services/saving-goals.service.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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];
|
||||
|
Reference in New Issue
Block a user