diff --git a/src/app.module.ts b/src/app.module.ts index 4f3433b..a65c485 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { buildValidationPipe } from './core/pipes'; import { CustomerModule } from './customer/customer.module'; import { migrations } from './db'; import { DocumentModule } from './document/document.module'; +import { GiftModule } from './gift/gift.module'; import { GuardianModule } from './guardian/guardian.module'; import { HealthModule } from './health/health.module'; import { JuniorModule } from './junior/junior.module'; @@ -61,8 +62,8 @@ import { TaskModule } from './task/task.module'; GuardianModule, SavingGoalsModule, AllowanceModule, - MoneyRequestModule, + GiftModule, OtpModule, DocumentModule, diff --git a/src/db/migrations/1734861516657-create-gift-entities.ts b/src/db/migrations/1734861516657-create-gift-entities.ts new file mode 100644 index 0000000..9af15df --- /dev/null +++ b/src/db/migrations/1734861516657-create-gift-entities.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateGiftEntities1734861516657 implements MigrationInterface { + name = 'CreateGiftEntities1734861516657'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "gift_replies" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "status" character varying NOT NULL DEFAULT 'PENDING', + "color" character varying NOT NULL, + "gift_id" uuid NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "REL_8292da97f1ceb9a806b8bc812f" UNIQUE ("gift_id"), + CONSTRAINT "PK_ec6567bb5ab318bb292fa6599a2" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "gift" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying(255) NOT NULL, + "description" text NOT NULL, + "color" character varying NOT NULL, + "amount" numeric(10,3) NOT NULL, + "status" character varying NOT NULL DEFAULT 'AVAILABLE', + "redeemed_at" TIMESTAMP WITH TIME ZONE, + "giver_id" uuid NOT NULL, "image_id" uuid NOT NULL, + "recipient_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_f91217caddc01a085837ebe0606" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "gift_redemptions" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "gift_id" uuid NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_6ad7ac76169c3a224ce4a3afff4" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "gift_replies" ADD CONSTRAINT "FK_8292da97f1ceb9a806b8bc812f2" FOREIGN KEY ("gift_id") REFERENCES "gift"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gift" ADD CONSTRAINT "FK_0d317b68508819308455db9b9be" FOREIGN KEY ("giver_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gift" ADD CONSTRAINT "FK_4a46b5734fb573dc956904c18d0" FOREIGN KEY ("recipient_id") REFERENCES "juniors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gift" ADD CONSTRAINT "FK_83bb54c127d0e6ee487b90e2996" FOREIGN KEY ("image_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gift_redemptions" ADD CONSTRAINT "FK_243c4349f0c45ce5385ac316aaa" FOREIGN KEY ("gift_id") REFERENCES "gift"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "gift_redemptions" DROP CONSTRAINT "FK_243c4349f0c45ce5385ac316aaa"`); + await queryRunner.query(`ALTER TABLE "gift" DROP CONSTRAINT "FK_83bb54c127d0e6ee487b90e2996"`); + await queryRunner.query(`ALTER TABLE "gift" DROP CONSTRAINT "FK_4a46b5734fb573dc956904c18d0"`); + await queryRunner.query(`ALTER TABLE "gift" DROP CONSTRAINT "FK_0d317b68508819308455db9b9be"`); + await queryRunner.query(`ALTER TABLE "gift_replies" DROP CONSTRAINT "FK_8292da97f1ceb9a806b8bc812f2"`); + await queryRunner.query(`DROP TABLE "gift_redemptions"`); + await queryRunner.query(`DROP TABLE "gift"`); + await queryRunner.query(`DROP TABLE "gift_replies"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 0b2e6f2..e3ee284 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -15,3 +15,4 @@ export * from './1734247702310-seeds-goals-categories'; export * from './1734262619426-create-junior-registration-token-table'; export * from './1734503895302-create-money-request-entity'; export * from './1734601976591-create-allowance-entities'; +export * from './1734861516657-create-gift-entities'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 989ba9b..861bdf2 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { User } from '~/auth/entities'; import { Customer } from '~/customer/entities'; +import { Gift } from '~/gift/entities'; import { Junior, Theme } from '~/junior/entities'; import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; @@ -42,6 +43,9 @@ export class Document { @OneToMany(() => SavingGoal, (savingGoal) => savingGoal.image) goals?: SavingGoal[]; + @OneToMany(() => Gift, (gift) => gift.image) + gifts?: Gift[]; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date; diff --git a/src/gift/controllers/gifts.controller.ts b/src/gift/controllers/gifts.controller.ts new file mode 100644 index 0000000..2926a8b --- /dev/null +++ b/src/gift/controllers/gifts.controller.ts @@ -0,0 +1,81 @@ +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 { AccessTokenGuard, RolesGuard } from '~/common/guards'; +import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; +import { CreateGiftRequestDto, GiftFiltersRequestDto, GiftReplyRequestDto } from '../dtos/request'; +import { GiftDetailsResponseDto, GiftListResponseDto } from '../dtos/response'; +import { GiftsService } from '../services'; + +@Controller('gift') +@ApiTags('Gifts') +@ApiBearerAuth() +export class GiftsController { + constructor(private readonly giftsService: GiftsService) {} + + @Post() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(GiftDetailsResponseDto) + async createGift(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateGiftRequestDto) { + const gift = await this.giftsService.createGift(sub, body); + + return ResponseFactory.data(new GiftDetailsResponseDto(gift)); + } + + @Get() + @UseGuards(AccessTokenGuard) + @ApiDataPageResponse(GiftListResponseDto) + async findGifts(@AuthenticatedUser() user: IJwtPayload, @Query() filters: GiftFiltersRequestDto) { + const [gifts, itemCount] = await this.giftsService.findGifts(user, filters); + + return ResponseFactory.dataPage( + gifts.map((gift) => new GiftListResponseDto(gift)), + { + size: filters.size, + page: filters.page, + itemCount, + }, + ); + } + + @Get(':giftId') + @UseGuards(AccessTokenGuard) + @ApiDataResponse(GiftDetailsResponseDto) + async findGiftById(@AuthenticatedUser() user: IJwtPayload, @Param('giftId') giftId: string) { + const gift = await this.giftsService.findUserGiftById(user, giftId); + + return ResponseFactory.data(new GiftDetailsResponseDto(gift)); + } + + @Post(':giftId/redeem') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR) + @HttpCode(HttpStatus.NO_CONTENT) + redeemGift(@AuthenticatedUser() { sub }: IJwtPayload, @Param('giftId') giftId: string) { + return this.giftsService.redeemGift(sub, giftId); + } + + @Post(':giftId/undo-redeem') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR) + @HttpCode(HttpStatus.NO_CONTENT) + UndoGiftRedemption(@AuthenticatedUser() { sub }: IJwtPayload, @Param('giftId') giftId: string) { + return this.giftsService.UndoGiftRedemption(sub, giftId); + } + + @Post(':giftId/reply') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR) + @HttpCode(HttpStatus.NO_CONTENT) + replyToGift( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('giftId') giftId: string, + @Body() body: GiftReplyRequestDto, + ) { + return this.giftsService.replyToGift(sub, giftId, body); + } +} diff --git a/src/gift/controllers/index.ts b/src/gift/controllers/index.ts new file mode 100644 index 0000000..3d8e776 --- /dev/null +++ b/src/gift/controllers/index.ts @@ -0,0 +1 @@ +export * from './gifts.controller'; diff --git a/src/gift/dtos/request/create-gift.request.dto.ts b/src/gift/dtos/request/create-gift.request.dto.ts new file mode 100644 index 0000000..8b2aa82 --- /dev/null +++ b/src/gift/dtos/request/create-gift.request.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { GiftColor } from '~/gift/enums'; +export class CreateGiftRequestDto { + @ApiProperty({ example: 'Happy Birthday' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'gift.name' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'gift.name' }) }) + name!: string; + + @ApiProperty({ example: 'Description of the gift' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'gift.description' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'gift.description' }) }) + description!: string; + + @ApiProperty({ example: GiftColor.VIOLET }) + @IsEnum(GiftColor, { message: i18n('validation.IsEnum', { path: 'general', property: 'gift.color' }) }) + color!: GiftColor; + + @ApiProperty({ example: 100 }) + @IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'gift.amount' }) }) + @IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'gift.amount' }) }) + amount!: number; + + @ApiProperty({ example: 'f7b9b1b0-0b3b-4b3b-8b3b-0b3b4b3b8b3b' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'gift.imageId' }) }) + imageId!: string; + + @ApiProperty({ example: 'f7b9b1b0-0b3b-4b3b-8b3b-0b3b4b3b8b3b' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'gift.recipientId' }) }) + recipientId!: string; +} diff --git a/src/gift/dtos/request/gift-filters.request.dto.ts b/src/gift/dtos/request/gift-filters.request.dto.ts new file mode 100644 index 0000000..dae57e8 --- /dev/null +++ b/src/gift/dtos/request/gift-filters.request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { GiftStatus } from '~/gift/enums'; +export class GiftFiltersRequestDto extends PageOptionsRequestDto { + @ApiProperty({ enum: GiftStatus }) + @IsEnum(GiftStatus, { message: i18n('validation.IsEnum', { path: 'general', property: 'gift.status' }) }) + status!: GiftStatus; +} diff --git a/src/gift/dtos/request/gift-reply.request.dto.ts b/src/gift/dtos/request/gift-reply.request.dto.ts new file mode 100644 index 0000000..ca28387 --- /dev/null +++ b/src/gift/dtos/request/gift-reply.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { GiftColor } from '~/gift/enums'; +export class GiftReplyRequestDto { + @ApiProperty() + @IsEnum(GiftColor, { message: i18n('validation.IsEnum', { path: 'general', property: 'gift.color' }) }) + color!: GiftColor; +} diff --git a/src/gift/dtos/request/index.ts b/src/gift/dtos/request/index.ts new file mode 100644 index 0000000..ef6249c --- /dev/null +++ b/src/gift/dtos/request/index.ts @@ -0,0 +1,3 @@ +export * from './create-gift.request.dto'; +export * from './gift-filters.request.dto'; +export * from './gift-reply.request.dto'; diff --git a/src/gift/dtos/response/gift-details.response.dto.ts b/src/gift/dtos/response/gift-details.response.dto.ts new file mode 100644 index 0000000..ceaaf10 --- /dev/null +++ b/src/gift/dtos/response/gift-details.response.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DocumentMetaResponseDto } from '~/document/dtos/response'; +import { Gift } from '~/gift/entities'; +import { GiftColor, GiftStatus } from '~/gift/enums'; +import { JuniorResponseDto } from '~/junior/dtos/response'; + +export class GiftDetailsResponseDto { + @ApiProperty({ example: 'f7b9b1b0-0b3b-4b3b-8b3b-0b3b4b3b8b3b' }) + id!: string; + + @ApiProperty({ example: 'Happy Birthday' }) + name!: string; + + @ApiProperty({ example: 'Description of the gift' }) + description!: string; + + @ApiProperty({ example: '200' }) + amount!: number; + + @ApiProperty({ example: GiftStatus.AVAILABLE }) + status!: GiftStatus; + + @ApiProperty({ example: GiftColor.GREEN }) + color!: GiftColor; + + @ApiProperty({ type: JuniorResponseDto }) + recipient!: JuniorResponseDto; + + @ApiProperty({ example: 'Ahmad Khalid' }) + giverName!: string; + + @ApiProperty({ type: DocumentMetaResponseDto }) + image!: DocumentMetaResponseDto; + + @ApiProperty({ example: '2022-01-01T00:00:00.000Z' }) + createdAt!: Date; + + @ApiProperty({ example: '2022-01-01T00:00:00.000Z' }) + updatedAt!: Date; + + constructor(gift: Gift) { + this.id = gift.id; + this.name = gift.name; + this.description = gift.description; + this.amount = gift.amount; + this.status = gift.status; + this.color = gift.color; + this.giverName = gift.giver.customer.firstName + ' ' + gift.giver.customer.lastName; + this.recipient = new JuniorResponseDto(gift.recipient); + this.image = new DocumentMetaResponseDto(gift.image); + this.createdAt = gift.createdAt; + this.updatedAt = gift.updatedAt; + } +} diff --git a/src/gift/dtos/response/gift-list.response.dto.ts b/src/gift/dtos/response/gift-list.response.dto.ts new file mode 100644 index 0000000..a3a1cb8 --- /dev/null +++ b/src/gift/dtos/response/gift-list.response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Gift } from '~/gift/entities'; +import { GiftColor, GiftStatus } from '~/gift/enums'; + +export class GiftListResponseDto { + @ApiProperty({ example: 'f7b9b1b0-0b3b-4b3b-8b3b-0b3b4b3b8b3b' }) + id!: string; + + @ApiProperty({ example: 'Happy Birthday' }) + name!: string; + + @ApiProperty({ example: '242' }) + amount!: number; + + @ApiProperty({ example: GiftStatus.AVAILABLE }) + status!: GiftStatus; + + @ApiProperty({ example: GiftColor.GREEN }) + color!: GiftColor; + + @ApiProperty({ example: '2022-01-01T00:00:00.000Z' }) + createdAt!: Date; + + constructor(gift: Gift) { + this.id = gift.id; + this.name = gift.name; + this.amount = gift.amount; + this.status = gift.status; + this.color = gift.color; + this.createdAt = gift.createdAt; + } +} diff --git a/src/gift/dtos/response/index.ts b/src/gift/dtos/response/index.ts new file mode 100644 index 0000000..7fa8981 --- /dev/null +++ b/src/gift/dtos/response/index.ts @@ -0,0 +1,2 @@ +export * from './gift-details.response.dto'; +export * from './gift-list.response.dto'; diff --git a/src/gift/entities/gift-redemption.entity.ts b/src/gift/entities/gift-redemption.entity.ts new file mode 100644 index 0000000..88bec89 --- /dev/null +++ b/src/gift/entities/gift-redemption.entity.ts @@ -0,0 +1,30 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Gift } from './gift.entity'; + +@Entity('gift_redemptions') +export class GiftRedemption extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'uuid', name: 'gift_id' }) + giftId!: string; + + @ManyToOne(() => Gift, (gift) => gift.redemptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'gift_id' }) + gift!: Gift; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/gift/entities/gift-reply.entity.ts b/src/gift/entities/gift-reply.entity.ts new file mode 100644 index 0000000..8b9bdd9 --- /dev/null +++ b/src/gift/entities/gift-reply.entity.ts @@ -0,0 +1,25 @@ +import { BaseEntity, Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { GiftColor, GiftReplyStatus } from '../enums'; +import { Gift } from './gift.entity'; + +@Entity('gift_replies') +export class GiftReply extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', name: 'status', default: GiftReplyStatus.PENDING }) + status!: GiftReplyStatus; + + @Column({ type: 'varchar', name: 'color' }) + color!: GiftColor; + + @Column({ type: 'uuid', name: 'gift_id' }) + giftId!: string; + + @OneToOne(() => Gift, (gift) => gift.reply, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'gift_id' }) + gift!: Gift; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/src/gift/entities/gift.entity.ts b/src/gift/entities/gift.entity.ts new file mode 100644 index 0000000..0442cb2 --- /dev/null +++ b/src/gift/entities/gift.entity.ts @@ -0,0 +1,83 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Document } from '~/document/entities'; +import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Junior } from '~/junior/entities'; +import { GiftColor, GiftStatus } from '../enums'; +import { GiftRedemption } from './gift-redemption.entity'; +import { GiftReply } from './gift-reply.entity'; + +@Entity('gift') +export class Gift { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Column({ type: 'text', name: 'description' }) + description!: string; + + @Column({ type: 'varchar', name: 'color' }) + color!: GiftColor; + + @Column({ + type: 'decimal', + name: 'amount', + precision: 10, + scale: 3, + transformer: { + to: (value: number) => value, + from: (value: string) => parseFloat(value), + }, + }) + amount!: number; + + @Column({ type: 'varchar', name: 'status', default: GiftStatus.AVAILABLE }) + status!: GiftStatus; + + @Column({ type: 'timestamp with time zone', name: 'redeemed_at', nullable: true }) + redeemedAt!: Date | null; + + @Column({ type: 'uuid', name: 'giver_id' }) + giverId!: string; + + @Column({ type: 'uuid', name: 'image_id' }) + imageId!: string; + + @Column({ type: 'uuid', name: 'recipient_id' }) + recipientId!: string; + + @ManyToOne(() => Guardian, (guardian) => guardian.gifts) + @JoinColumn({ name: 'giver_id' }) + giver!: Guardian; + + @ManyToOne(() => Junior, (junior) => junior.gifts) + @JoinColumn({ name: 'recipient_id' }) + recipient!: Junior; + + @ManyToOne(() => Document, (document) => document.gifts) + @JoinColumn({ name: 'image_id' }) + image!: Document; + + @OneToMany(() => GiftRedemption, (giftRedemption) => giftRedemption.gift, { cascade: true }) + redemptions!: GiftRedemption[]; + + @OneToOne(() => GiftReply, (giftReply) => giftReply.gift, { cascade: true }) + reply?: GiftReply; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/gift/entities/index.ts b/src/gift/entities/index.ts new file mode 100644 index 0000000..0be791f --- /dev/null +++ b/src/gift/entities/index.ts @@ -0,0 +1,3 @@ +export * from './gift-redemption.entity'; +export * from './gift-reply.entity'; +export * from './gift.entity'; diff --git a/src/gift/enums/gift-color.enum.ts b/src/gift/enums/gift-color.enum.ts new file mode 100644 index 0000000..ee67cf6 --- /dev/null +++ b/src/gift/enums/gift-color.enum.ts @@ -0,0 +1,6 @@ +export enum GiftColor { + VIOLET = 'VIOLET', + PINK = 'PINK', + BLUE = 'BLUE', + GREEN = 'GREEN', +} diff --git a/src/gift/enums/gift-reply-status.enum.ts b/src/gift/enums/gift-reply-status.enum.ts new file mode 100644 index 0000000..12183a5 --- /dev/null +++ b/src/gift/enums/gift-reply-status.enum.ts @@ -0,0 +1,4 @@ +export enum GiftReplyStatus { + PENDING = 'PENDING', + OPENED = 'OPENED', +} diff --git a/src/gift/enums/gift-status.enum.ts b/src/gift/enums/gift-status.enum.ts new file mode 100644 index 0000000..96d84ec --- /dev/null +++ b/src/gift/enums/gift-status.enum.ts @@ -0,0 +1,4 @@ +export enum GiftStatus { + AVAILABLE = 'AVAILABLE', + REDEEMED = 'REDEEMED', +} diff --git a/src/gift/enums/index.ts b/src/gift/enums/index.ts new file mode 100644 index 0000000..e76b986 --- /dev/null +++ b/src/gift/enums/index.ts @@ -0,0 +1,3 @@ +export * from './gift-color.enum'; +export * from './gift-reply-status.enum'; +export * from './gift-status.enum'; diff --git a/src/gift/gift.module.ts b/src/gift/gift.module.ts new file mode 100644 index 0000000..080a2e0 --- /dev/null +++ b/src/gift/gift.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JuniorModule } from '~/junior/junior.module'; +import { GiftsController } from './controllers'; +import { Gift, GiftRedemption, GiftReply } from './entities'; +import { GiftsRepository } from './repositories'; +import { GiftsService } from './services'; + +@Module({ + imports: [TypeOrmModule.forFeature([Gift, GiftReply, GiftRedemption]), JuniorModule], + controllers: [GiftsController], + providers: [GiftsService, GiftsRepository], +}) +export class GiftModule {} diff --git a/src/gift/repositories/gifts.repository.ts b/src/gift/repositories/gifts.repository.ts new file mode 100644 index 0000000..0226e58 --- /dev/null +++ b/src/gift/repositories/gifts.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { CreateGiftRequestDto, GiftFiltersRequestDto, GiftReplyRequestDto } from '../dtos/request'; +import { Gift, GiftRedemption, GiftReply } from '../entities'; +import { GiftStatus } from '../enums'; +const ONE = 1; + +@Injectable() +export class GiftsRepository { + constructor(@InjectRepository(Gift) private readonly giftsRepository: Repository) {} + + create(guardianId: string, gift: CreateGiftRequestDto): Promise { + return this.giftsRepository.save( + this.giftsRepository.create({ + name: gift.name, + description: gift.description, + amount: gift.amount, + recipientId: gift.recipientId, + imageId: gift.imageId, + color: gift.color, + giverId: guardianId, + }), + ); + } + + findJuniorGiftById(juniorId: string, giftId: string): Promise { + return this.giftsRepository.findOne({ + where: { recipientId: juniorId, id: giftId }, + relations: ['recipient', 'recipient.customer', 'image', 'giver', 'giver.customer', 'reply'], + }); + } + + findGuardianGiftById(guardianId: string, giftId: string): Promise { + return this.giftsRepository.findOne({ + where: { giverId: guardianId, id: giftId }, + relations: ['recipient', 'recipient.customer', 'image', 'giver', 'giver.customer', 'reply'], + }); + } + + findGifts(user: IJwtPayload, filters: GiftFiltersRequestDto) { + const query = this.giftsRepository.createQueryBuilder('gift'); + if (user.roles.includes(Roles.GUARDIAN)) { + query.where('gift.giverId = :giverId', { giverId: user.sub }); + } else { + query.where('gift.recipientId = :recipientId', { recipientId: user.sub }); + } + query.andWhere('gift.status = :status', { status: filters.status }); + query.skip((filters.page - ONE) * filters.size); + query.take(filters.size); + query.orderBy('gift.createdAt', 'DESC'); + return query.getManyAndCount(); + } + + redeemGift(gift: Gift) { + const redemptions = gift.redemptions || []; + gift.status = GiftStatus.REDEEMED; + gift.redeemedAt = new Date(); + gift.redemptions = [...redemptions, new GiftRedemption()]; + return this.giftsRepository.save(gift); + } + + UndoGiftRedemption(gift: Gift) { + gift.status = GiftStatus.AVAILABLE; + gift.redeemedAt = null; + return this.giftsRepository.save(gift); + } + + replyToGift(gift: Gift, reply: GiftReplyRequestDto) { + gift.reply = GiftReply.create({ ...reply }); + + return this.giftsRepository.save(gift); + } +} diff --git a/src/gift/repositories/index.ts b/src/gift/repositories/index.ts new file mode 100644 index 0000000..77bbb18 --- /dev/null +++ b/src/gift/repositories/index.ts @@ -0,0 +1 @@ +export * from './gifts.repository'; diff --git a/src/gift/services/gifts.service.ts b/src/gift/services/gifts.service.ts new file mode 100644 index 0000000..f9b59cc --- /dev/null +++ b/src/gift/services/gifts.service.ts @@ -0,0 +1,101 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { OciService } from '~/document/services'; +import { JuniorService } from '~/junior/services'; +import { CreateGiftRequestDto, GiftFiltersRequestDto, GiftReplyRequestDto } from '../dtos/request'; +import { Gift } from '../entities'; +import { GiftStatus } from '../enums'; +import { GiftsRepository } from '../repositories'; + +@Injectable() +export class GiftsService { + constructor( + private readonly juniorService: JuniorService, + private readonly giftsRepository: GiftsRepository, + private readonly ociService: OciService, + ) {} + + async createGift(guardianId: string, body: CreateGiftRequestDto) { + const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian( + guardianId, + body.recipientId, + ); + + if (!doesJuniorBelongToGuardian) { + throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN'); + } + + const gift = await this.giftsRepository.create(guardianId, body); + + return this.findUserGiftById({ sub: guardianId, roles: [Roles.GUARDIAN] }, gift.id); + } + + async findUserGiftById(user: IJwtPayload, giftId: string) { + const gift = user.roles.includes(Roles.GUARDIAN) + ? await this.giftsRepository.findGuardianGiftById(user.sub, giftId) + : await this.giftsRepository.findJuniorGiftById(user.sub, giftId); + + if (!gift) { + throw new BadRequestException('GIFT.NOT_FOUND'); + } + + await this.prepareGiftImages([gift]); + + return gift; + } + + async redeemGift(juniorId: string, giftId: string) { + const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId); + + if (!gift) { + throw new BadRequestException('GIFT.NOT_FOUND'); + } + + if (gift.status === GiftStatus.REDEEMED) { + throw new BadRequestException('GIFT.ALREADY_REDEEMED'); + } + + return this.giftsRepository.redeemGift(gift); + } + + async UndoGiftRedemption(juniorId: string, giftId: string) { + const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId); + + if (!gift) { + throw new BadRequestException('GIFT.NOT_FOUND'); + } + + if (gift.status === GiftStatus.AVAILABLE) { + throw new BadRequestException('GIFT.NOT_REDEEMED'); + } + + return this.giftsRepository.UndoGiftRedemption(gift); + } + + async replyToGift(juniorId: string, giftId: string, body: GiftReplyRequestDto) { + const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId); + + if (!gift) { + throw new BadRequestException('GIFT.NOT_FOUND'); + } + + if (gift.reply) { + throw new BadRequestException('GIFT.ALREADY_REPLIED'); + } + + return this.giftsRepository.replyToGift(gift, body); + } + + findGifts(user: IJwtPayload, filters: GiftFiltersRequestDto) { + return this.giftsRepository.findGifts(user, filters); + } + + private async prepareGiftImages(gifts: Gift[]) { + await Promise.all( + gifts.map(async (gift) => { + gift.image.url = await this.ociService.generatePreSignedUrl(gift.image); + }), + ); + } +} diff --git a/src/gift/services/index.ts b/src/gift/services/index.ts new file mode 100644 index 0000000..4bb6e70 --- /dev/null +++ b/src/gift/services/index.ts @@ -0,0 +1 @@ +export * from './gifts.service'; diff --git a/src/guardian/entities/guradian.entity.ts b/src/guardian/entities/guradian.entity.ts index be8d625..6ae87ca 100644 --- a/src/guardian/entities/guradian.entity.ts +++ b/src/guardian/entities/guradian.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { Allowance } from '~/allowance/entities'; import { Customer } from '~/customer/entities'; +import { Gift } from '~/gift/entities'; import { Junior } from '~/junior/entities'; import { MoneyRequest } from '~/money-request/entities'; import { Task } from '~/task/entities'; @@ -39,6 +40,9 @@ export class Guardian extends BaseEntity { @OneToMany(() => Allowance, (allowance) => allowance.guardian) allowances?: Allowance[]; + @OneToMany(() => Gift, (gift) => gift.giver) + gifts?: Gift[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index ed20d25..f4bcdb4 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -13,6 +13,7 @@ import { import { Allowance } from '~/allowance/entities'; import { Customer } from '~/customer/entities'; import { Document } from '~/document/entities'; +import { Gift } from '~/gift/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { MoneyRequest } from '~/money-request/entities'; import { Category, SavingGoal } from '~/saving-goals/entities'; @@ -78,6 +79,9 @@ export class Junior extends BaseEntity { @OneToMany(() => Allowance, (allowance) => allowance.junior) allowances!: Allowance[]; + @OneToMany(() => Gift, (gift) => gift.recipient) + gifts!: Gift[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date;