diff --git a/.env.example b/.env.example index 270aabb..4024433 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,5 @@ PHARMATECH_SENDER="Andy de Pharmatech " # Resemd RESEND_API_KEY= -ADMIN_PASSWORD= \ No newline at end of file +ADMIN_PASSWORD= +ADMIN_EMAIL= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b1fc02f..1250bcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "api", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "api", - "version": "0.4.0", + "version": "0.5.0", "license": "UNLICENSED", "dependencies": { "@nestjs/cli": "^11.0.5", diff --git a/package.json b/package.json index 7b32eb4..3907d1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "0.5.0", + "version": "1.0.0", "description": "", "author": "", "private": true, @@ -19,6 +19,7 @@ "test:watch": "vitest run --watch --config vitest.config.ts", "test:coverage": "vitest run --coverage.enabled --coverage.all --config vitest.config.ts", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", + "migration:create": "npm run typeorm -- migration:create", "migration:generate": "npm run typeorm -- migration:generate -d ./src/database/typeorm.config.ts", "migration:run": "npm run typeorm -- migration:run -d ./src/database/typeorm.config.ts", "migration:revert": "npm run typeorm -- migration:revert -d ./src/database/typeorm.config.ts", diff --git a/src/active-principle/active-principle.controller.ts b/src/active-principle/active-principle.controller.ts new file mode 100644 index 0000000..b745ca9 --- /dev/null +++ b/src/active-principle/active-principle.controller.ts @@ -0,0 +1,66 @@ +import { + Controller, + Get, + Query, + UseGuards, + UseInterceptors, + HttpStatus, +} from '@nestjs/common'; +import { ActivePrincipleService } from './active-principle.service'; +import { RolesGuard } from 'src/auth/roles.guard'; +import { AuthGuard } from 'src/auth/auth.guard'; +import { Roles } from 'src/auth/roles.decorador'; +import { UserRole } from 'src/user/entities/user.entity'; +import { PaginationInterceptor } from 'src/utils/pagination.interceptor'; +import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; +import { Pagination } from 'src/utils/pagination.decorator'; +import { + ApiOperation, + ApiQuery, + ApiResponse, + getSchemaPath, +} from '@nestjs/swagger'; +import { ResponseActivePrincipleDTO } from './dto/active-principle.dto'; +import { PaginationDTO } from 'src/utils/dto/pagination.dto'; + +@Controller('active-principle') +export class ActivePrincipleController { + constructor( + private readonly activePrincipleService: ActivePrincipleService, + ) {} + + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get() + @UseInterceptors(PaginationInterceptor) + @ApiOperation({ summary: 'List active principles' }) + @ApiQuery({ name: 'q', required: false, description: 'Search term' }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + description: 'Successful retrieval', + status: HttpStatus.OK, + schema: { + allOf: [ + { $ref: getSchemaPath(PaginationDTO) }, + { + properties: { + results: { + type: 'array', + items: { $ref: getSchemaPath(ResponseActivePrincipleDTO) }, + }, + }, + }, + ], + }, + }) + async findAll( + @Pagination() pagination: PaginationQueryDTO, + @Query('q') q?: string, + ): Promise<{ data: ResponseActivePrincipleDTO[]; total: number }> { + const { page, limit } = pagination; + const data = await this.activePrincipleService.findAll(page, limit, q); + const total = await this.activePrincipleService.countActivePrinciples(q); + return { data, total }; + } +} diff --git a/src/active-principle/active-principle.module.ts b/src/active-principle/active-principle.module.ts new file mode 100644 index 0000000..53cd5d1 --- /dev/null +++ b/src/active-principle/active-principle.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ActivePrinciple } from './entities/active-principle.entity'; +import { ActivePrincipleController } from './active-principle.controller'; +import { ActivePrincipleService } from './active-principle.service'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([ActivePrinciple]), AuthModule], + controllers: [ActivePrincipleController], + providers: [ActivePrincipleService], +}) +export class ActivePrincipleModule {} diff --git a/src/active-principle/active-principle.service.ts b/src/active-principle/active-principle.service.ts new file mode 100644 index 0000000..0cc8a28 --- /dev/null +++ b/src/active-principle/active-principle.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { ActivePrinciple } from './entities/active-principle.entity'; + +@Injectable() +export class ActivePrincipleService { + constructor( + @InjectRepository(ActivePrinciple) + private readonly activePrincipleRepository: Repository, + ) {} + + async findAll( + page: number, + limit: number, + q?: string, + ): Promise { + const where = q ? { name: ILike(`%${q}%`) } : {}; + return await this.activePrincipleRepository.find({ + where, + skip: (page - 1) * limit, + take: limit, + }); + } + + async countActivePrinciples(q?: string): Promise { + const where = q ? { name: ILike(`%${q}%`) } : {}; + return await this.activePrincipleRepository.count({ where }); + } +} diff --git a/src/active-principle/dto/active-principle.dto.ts b/src/active-principle/dto/active-principle.dto.ts new file mode 100644 index 0000000..621d546 --- /dev/null +++ b/src/active-principle/dto/active-principle.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +export class ResponseActivePrincipleDTO { + @Expose() + @ApiProperty({ example: 'Acetaminophen' }) + @IsString() + name: string; +} diff --git a/src/active-principle/entities/active-principle.entity.ts b/src/active-principle/entities/active-principle.entity.ts new file mode 100644 index 0000000..ba80495 --- /dev/null +++ b/src/active-principle/entities/active-principle.entity.ts @@ -0,0 +1,8 @@ +import { BaseModel } from 'src/utils/entity'; +import { Column, Entity } from 'typeorm'; + +@Entity() +export class ActivePrinciple extends BaseModel { + @Column({ type: 'varchar', length: 255 }) + name: string; +} diff --git a/src/active-principle/migrations/1747107651171-add-active-principle-migration.ts b/src/active-principle/migrations/1747107651171-add-active-principle-migration.ts new file mode 100644 index 0000000..41363df --- /dev/null +++ b/src/active-principle/migrations/1747107651171-add-active-principle-migration.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActivePrincipleMigration1747107651171 + implements MigrationInterface +{ + name = 'AddActivePrincipleMigration1747107651171'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "active_principle" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP, "name" character varying(255) NOT NULL, CONSTRAINT "PK_872c6d07a950a9ec64da5714eb6" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "active_principle"`); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index a23fa01..c709ed9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,8 @@ import { CartModule } from './cart/cart.module'; import { RecommendationModule } from './recommendation/recommendation.module'; import { ReportsModule } from './reports/reports.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { SalesModule } from './sales/sales.module'; +import { ActivePrincipleModule } from './active-principle/active-principle.module'; @Module({ imports: [ @@ -67,6 +69,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; CartModule, RecommendationModule, ReportsModule, + SalesModule, + ActivePrincipleModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f24662b..f7e9789 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -7,10 +7,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { UserService } from 'src/user/user.service'; import { EmailModule } from 'src/email/email.module'; import { AuthGuard } from './auth.guard'; +import { BranchModule } from 'src/branch/branch.module'; @Module({ imports: [ forwardRef(() => UserModule), + forwardRef(() => BranchModule), ConfigModule, JwtModule.registerAsync({ useFactory: (configService: ConfigService) => ({ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 361f257..54ec600 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -92,7 +92,10 @@ export class AuthService { async updatePassword(user: User, newPasswod: string): Promise { const password = await this.encryptPassword(newPasswod); - await this.userService.update(user, { password }); + await this.userService.update(user, { + password, + isGenericPassword: false, + }); return true; } diff --git a/src/branch/branch.module.ts b/src/branch/branch.module.ts index 09693be..46e7bd2 100644 --- a/src/branch/branch.module.ts +++ b/src/branch/branch.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { BranchService } from './branch.service'; import { BranchController } from './branch.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -14,9 +14,10 @@ import { State } from 'src/state/entities/state.entity'; @Module({ imports: [ TypeOrmModule.forFeature([Branch, City, State, Country]), - AuthModule, + forwardRef(() => AuthModule), ], controllers: [BranchController], providers: [BranchService, CityService, StateService, CountryService], + exports: [BranchService], }) export class BranchModule {} diff --git a/src/branch/branch.service.ts b/src/branch/branch.service.ts index df8d5c0..0fd9f56 100644 --- a/src/branch/branch.service.ts +++ b/src/branch/branch.service.ts @@ -93,4 +93,20 @@ export class BranchService { }); return deleted.affected === 1; } + + async findNearestBranch(lat: number, lng: number): Promise { + const branch = await this.branchRepository + .createQueryBuilder('branch') + .orderBy( + `ST_DistanceSphere(ST_MakePoint(branch.longitude, branch.latitude), ST_MakePoint(:lng, :lat))`, + ) + .setParameters({ lat, lng }) + .getOne(); + + if (!branch) { + throw new NotFoundException('No closer branch found'); + } + + return branch; + } } diff --git a/src/branch/dto/branch.dto.ts b/src/branch/dto/branch.dto.ts index afcc97f..99677a5 100644 --- a/src/branch/dto/branch.dto.ts +++ b/src/branch/dto/branch.dto.ts @@ -1,6 +1,13 @@ import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { + IsNotEmpty, + IsNumber, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; import { CityDTO } from 'src/city/dto/city.dto'; import { BaseDTO } from 'src/utils/dto/base.dto'; @@ -36,6 +43,7 @@ class BranchDTO { export class CreateBranchDTO extends BranchDTO { @IsNotEmpty() + @IsUUID() @ApiProperty({ description: 'The city id of the branch' }) cityId: string; } @@ -44,6 +52,7 @@ export class UpdateBranchDTO extends PartialType(CreateBranchDTO) {} export class ResponseBranchDTO extends IntersectionType(BranchDTO, BaseDTO) { @Expose() - @ApiProperty({ description: 'The city of the branch' }) + @ApiProperty({ description: 'The city of the branch', type: CityDTO }) + @Type(() => CityDTO) city: CityDTO; } diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 0cfd744..ec76f28 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -15,7 +15,6 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_NAME'), entities: [__dirname + '/../**/*.entity{.ts,.js}'], - subscribers: [__dirname + '/../**/*.subscriber{.ts,.js}'], synchronize: false, ssl: configService.get('POSTGRES_SSL', false), }), diff --git a/src/database/migrations/1747014730112-enable-postgis-extension-migration.ts b/src/database/migrations/1747014730112-enable-postgis-extension-migration.ts new file mode 100644 index 0000000..3df47ae --- /dev/null +++ b/src/database/migrations/1747014730112-enable-postgis-extension-migration.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EnablePostgisExtensionMigration1747014730112 + implements MigrationInterface +{ + name = 'EnablePostgisExtensionMigration1747014730112'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS postgis`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP EXTENSION IF EXISTS postgis`); + } +} diff --git a/src/discount/controllers/coupon.controller.ts b/src/discount/controllers/coupon.controller.ts index 039929e..f26d040 100644 --- a/src/discount/controllers/coupon.controller.ts +++ b/src/discount/controllers/coupon.controller.ts @@ -18,6 +18,8 @@ import { UpdateCouponDTO, ResponseCouponDTO, CouponQueryDTO, + CouponListDeleteDTO, + CouponListUpdateDTO, } from '../dto/coupon.dto'; import { AuthGuard } from 'src/auth/auth.guard'; import { RolesGuard } from 'src/auth/roles.guard'; @@ -41,7 +43,7 @@ export class CouponController { @Post() @UseGuards(AuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) @ApiOperation({ summary: 'Create a coupon' }) @ApiResponse({ status: HttpStatus.CREATED, @@ -54,7 +56,7 @@ export class CouponController { @Get() @UseGuards(AuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) @UseInterceptors(PaginationInterceptor) @ApiOperation({ summary: 'List all coupons' }) @ApiQuery({ @@ -115,6 +117,42 @@ export class CouponController { return { data, total }; } + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @Delete('bulk') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk delete coupons' }) + @ApiResponse({ + description: 'Successful bulk deletion of coupons', + status: HttpStatus.NO_CONTENT, + }) + async bulkDelete( + @Body() couponListDeleteDTO: CouponListDeleteDTO, + ): Promise { + await this.couponService.bulkDelete(couponListDeleteDTO.ids); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @Patch('bulk') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk update coupons' }) + @ApiResponse({ + description: 'Successful bulk update of coupons', + status: HttpStatus.NO_CONTENT, + }) + async bulkUpdate( + @Body() couponListUpdateDTO: CouponListUpdateDTO, + ): Promise { + await this.couponService.bulkUpdate( + couponListUpdateDTO.ids, + couponListUpdateDTO.maxUses, + couponListUpdateDTO.expirationDate, + ); + } + @Get(':code') @ApiOperation({ summary: 'Get coupon by code' }) @ApiResponse({ @@ -128,7 +166,8 @@ export class CouponController { @Patch(':code') @UseGuards(AuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @ApiBearerAuth() @ApiOperation({ summary: 'Update coupon by code' }) @ApiResponse({ status: HttpStatus.OK, @@ -145,7 +184,8 @@ export class CouponController { @HttpCode(HttpStatus.NO_CONTENT) @Delete(':code') @UseGuards(AuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @ApiBearerAuth() @ApiOperation({ summary: 'Delete coupon by code' }) @ApiResponse({ status: HttpStatus.NO_CONTENT, diff --git a/src/discount/controllers/promo.controller.ts b/src/discount/controllers/promo.controller.ts index cd45416..9e7ad28 100644 --- a/src/discount/controllers/promo.controller.ts +++ b/src/discount/controllers/promo.controller.ts @@ -29,6 +29,8 @@ import { UpdatePromoDTO, ResponsePromoDTO, PromoQueryDTO, + PromoListDeleteDTO, + PromoListUpdateDTO, } from '../dto/promo.dto'; import { PromoService } from '../services/promo.service'; import { UserRole } from 'src/user/entities/user.entity'; @@ -39,7 +41,7 @@ import { PaginationInterceptor } from 'src/utils/pagination.interceptor'; @ApiExtraModels(PaginationDTO, ResponsePromoDTO) @ApiBearerAuth() @UseGuards(AuthGuard, RolesGuard) -@Roles(UserRole.ADMIN) +@Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) export class PromoController { constructor(private readonly promoService: PromoService) {} @@ -115,6 +117,35 @@ export class PromoController { return { data, total }; } + @HttpCode(HttpStatus.NO_CONTENT) + @Delete('bulk') + @ApiOperation({ summary: 'Bulk delete promos' }) + @ApiResponse({ + description: 'Successful bulk deletion of promos', + status: HttpStatus.NO_CONTENT, + }) + async bulkDelete( + @Body() promoListDeleteDTO: PromoListDeleteDTO, + ): Promise { + await this.promoService.bulkDelete(promoListDeleteDTO.ids); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Patch('bulk') + @ApiOperation({ summary: 'Bulk update promos' }) + @ApiResponse({ + description: 'Successful bulk update of promos', + status: HttpStatus.NO_CONTENT, + }) + async bulkUpdate( + @Body() promoListUpdateDTO: PromoListUpdateDTO, + ): Promise { + await this.promoService.bulkUpdate( + promoListUpdateDTO.ids, + promoListUpdateDTO.expiredAt, + ); + } + @Get(':id') @ApiOperation({ summary: 'Get promo by ID' }) @ApiResponse({ diff --git a/src/discount/dto/coupon.dto.ts b/src/discount/dto/coupon.dto.ts index 0454d38..5f0048a 100644 --- a/src/discount/dto/coupon.dto.ts +++ b/src/discount/dto/coupon.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, PartialType, IntersectionType } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { Transform, Expose } from 'class-transformer'; import { IsNotEmpty, IsString, @@ -8,6 +8,7 @@ import { Min, IsDateString, IsOptional, + IsUUID, } from 'class-validator'; import { BaseDTO } from 'src/utils/dto/base.dto'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; @@ -73,3 +74,33 @@ export class CouponQueryDTO extends PaginationQueryDTO { this.expirationBetween = expirationBetween ? expirationBetween : []; } } + +export class CouponListDeleteDTO { + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: 'List of coupon ids to be deleted', + type: [String], + }) + ids: string[]; +} + +export class CouponListUpdateDTO { + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: 'List of coupon ids to be updated', + type: [String], + }) + ids: string[]; + + @Expose() + @IsOptional() + @ApiProperty({ description: 'The new expiration date of the coupons' }) + expirationDate: Date; + + @Expose() + @ApiProperty({ description: 'Maximum number of coupon uses' }) + @IsOptional() + @IsInt() + @Min(0) + maxUses: number; +} diff --git a/src/discount/dto/promo.dto.ts b/src/discount/dto/promo.dto.ts index f32c012..6e1bdbc 100644 --- a/src/discount/dto/promo.dto.ts +++ b/src/discount/dto/promo.dto.ts @@ -6,6 +6,7 @@ import { IsString, IsDateString, IsOptional, + IsUUID, } from 'class-validator'; import { BaseDTO } from 'src/utils/dto/base.dto'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; @@ -58,3 +59,25 @@ export class PromoQueryDTO extends PaginationQueryDTO { this.expirationBetween = expirationBetween ? expirationBetween : []; } } + +export class PromoListDeleteDTO { + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: 'List of promo ids to be deleted', + type: [String], + }) + ids: string[]; +} + +export class PromoListUpdateDTO { + @IsUUID(undefined, { each: true }) + @ApiProperty({ + description: 'List of promo ids to be updated', + type: [String], + }) + ids: string[]; + + @IsNotEmpty() + @ApiProperty({ description: 'The new expiration date of the promos' }) + expiredAt: Date; +} diff --git a/src/discount/services/coupon.service.ts b/src/discount/services/coupon.service.ts index 1b6536b..79cdf70 100644 --- a/src/discount/services/coupon.service.ts +++ b/src/discount/services/coupon.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { Coupon } from '../entities/coupon.entity'; import { CouponDTO, UpdateCouponDTO } from '../dto/coupon.dto'; @@ -76,4 +76,29 @@ export class CouponService { } return true; } + + async bulkDelete(ids: string[]) { + const coupons = await this.couponRepository.findBy({ + id: In(ids), + deletedAt: IsNull(), + }); + if (coupons.length === 0) { + throw new NotFoundException(`No coupons found with the given IDs`); + } + await this.couponRepository.softDelete({ id: In(ids) }); + } + + async bulkUpdate(ids: string[], maxUses?: number, expirationDate?: Date) { + const coupons = await this.couponRepository.findBy({ + id: In(ids), + deletedAt: IsNull(), + }); + if (coupons.length === 0) { + throw new NotFoundException(`No coupons found with the given IDs`); + } + const couponsToUpdate = coupons.map((coupon) => { + return { ...coupon, maxUses, expirationDate }; + }); + await this.couponRepository.save(couponsToUpdate); + } } diff --git a/src/discount/services/promo.service.ts b/src/discount/services/promo.service.ts index edbea56..b07b655 100644 --- a/src/discount/services/promo.service.ts +++ b/src/discount/services/promo.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { PromoDTO, UpdatePromoDTO } from '../dto/promo.dto'; import { Promo } from '../entities/promo.entity'; @@ -69,4 +69,29 @@ export class PromoService { }); return result.affected === 1; } + + async bulkDelete(ids: string[]) { + const promos = await this.promoRepository.findBy({ + id: In(ids), + deletedAt: IsNull(), + }); + if (promos.length === 0) { + throw new NotFoundException(`No promos found with the given IDs`); + } + await this.promoRepository.softDelete({ id: In(ids) }); + } + + async bulkUpdate(ids: string[], expiredAt: Date) { + const promos = await this.promoRepository.findBy({ + id: In(ids), + deletedAt: IsNull(), + }); + if (promos.length === 0) { + throw new NotFoundException(`No promos found with the given IDs`); + } + await this.promoRepository.update( + { id: In(ids) }, + { expiredAt, updatedAt: new Date() }, + ); + } } diff --git a/src/email/email.controller.spec.ts b/src/email/email.controller.spec.ts deleted file mode 100644 index 9470aa8..0000000 --- a/src/email/email.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { EmailController } from './email.controller'; - -describe('EmailController', () => { - let controller: EmailController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [EmailController], - }).compile(); - - controller = module.get(EmailController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/inventory/dto/inventory.dto.ts b/src/inventory/dto/inventory.dto.ts index af4c9b6..40a2f5a 100644 --- a/src/inventory/dto/inventory.dto.ts +++ b/src/inventory/dto/inventory.dto.ts @@ -12,9 +12,11 @@ import { import { ResponseBranchDTO } from 'src/branch/dto/branch.dto'; import { BaseDTO } from 'src/utils/dto/base.dto'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; -import { Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; +import { ProductPresentationDTO } from 'src/products/dto/product.dto'; export class InventoryDTO { + @Expose() @ApiProperty({ description: 'The stock quantity of the inventory' }) @IsNumber() @IsNotEmpty() @@ -48,20 +50,21 @@ export class CreateInventoryDTO extends InventoryDTO { export class UpdateInventoryDTO extends PartialType(InventoryDTO) {} -class ProductPresentationDTO extends BaseDTO { - @ApiProperty({ description: 'The price of the product presentation' }) - price: number; -} - export class ResponseInventoryDTO extends IntersectionType( InventoryDTO, BaseDTO, ) { - @ApiProperty({ description: 'The branch in wich the product is available' }) + @Expose() + @ApiProperty({ + description: 'The branch in wich the product is available', + type: ResponseBranchDTO, + }) branch: ResponseBranchDTO; + @Expose() @ApiProperty({ description: 'The product presentation of the product in the inventory', + type: ProductPresentationDTO, }) productPresentation: ProductPresentationDTO; } diff --git a/src/inventory/inventory.controller.ts b/src/inventory/inventory.controller.ts index dc863bc..00003c4 100644 --- a/src/inventory/inventory.controller.ts +++ b/src/inventory/inventory.controller.ts @@ -11,6 +11,8 @@ import { HttpCode, Query, UseInterceptors, + Req, + BadRequestException, } from '@nestjs/common'; import { InventoryService } from './inventory.service'; import { @@ -21,7 +23,7 @@ import { BulkUpdateInventoryDTO, } from './dto/inventory.dto'; import { RolesGuard } from 'src/auth/roles.guard'; -import { AuthGuard } from 'src/auth/auth.guard'; +import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { UserRole } from 'src/user/entities/user.entity'; import { Roles } from 'src/auth/roles.decorador'; import { BranchId } from 'src/auth/branch-id.decorator'; @@ -37,6 +39,7 @@ import { PaginationDTO } from 'src/utils/dto/pagination.dto'; import { PaginationInterceptor } from 'src/utils/pagination.interceptor'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; import { Pagination } from 'src/utils/pagination.decorator'; +import { plainToInstance } from 'class-transformer'; @Controller('inventory') export class InventoryController { @@ -53,8 +56,15 @@ export class InventoryController { type: ResponseInventoryDTO, }) async create( + @Req() req: CustomRequest, @Body() createInventoryDTO: CreateInventoryDTO, ): Promise { + if (req.user.role === UserRole.BRANCH_ADMIN) { + if (!req.user.branch) { + throw new BadRequestException('Branch not found'); + } + createInventoryDTO.branchId = req.user.branch.id; + } return await this.inventoryService.create(createInventoryDTO); } @@ -109,17 +119,19 @@ export class InventoryController { @Query() query: InventoryQueryDTO, ): Promise<{ data: ResponseInventoryDTO[]; total: number }> { const { page, limit } = pagination; - const data = await this.inventoryService.findAll( + const [data, total] = await this.inventoryService.findAll( page, limit, query.branchId, query.productPresentationId, ); - const total = await this.inventoryService.countInventories( - query.branchId, - query.productPresentationId, - ); - return { data, total }; + return { + data: plainToInstance(ResponseInventoryDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }), + total, + }; } @Get(':id') @@ -144,10 +156,15 @@ export class InventoryController { type: Inventory, }) async update( + @Req() req: CustomRequest, @Param('id') id: string, @Body() updateInventoryDTO: UpdateInventoryDTO, ) { - return await this.inventoryService.update(id, updateInventoryDTO); + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + return await this.inventoryService.update(id, updateInventoryDTO, branchId); } @HttpCode(HttpStatus.NO_CONTENT) @@ -161,8 +178,15 @@ export class InventoryController { status: HttpStatus.NO_CONTENT, type: Inventory, }) - async remove(@Param('id') id: string): Promise { - await this.inventoryService.remove(id); + async remove( + @Req() req: CustomRequest, + @Param('id') id: string, + ): Promise { + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + await this.inventoryService.remove(id, branchId); } @UseGuards(AuthGuard, RolesGuard) diff --git a/src/inventory/inventory.service.ts b/src/inventory/inventory.service.ts index dae18e3..3a7cb8b 100644 --- a/src/inventory/inventory.service.ts +++ b/src/inventory/inventory.service.ts @@ -9,7 +9,7 @@ import { BulkUpdateInventoryDTO, } from './dto/inventory.dto'; import { Inventory } from './entities/inventory.entity'; -import { Repository } from 'typeorm'; +import { FindOneOptions, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { ProductPresentation } from 'src/products/entities/product-presentation.entity'; import { BranchService } from 'src/branch/branch.service'; @@ -47,39 +47,38 @@ export class InventoryService { return await this.inventoryRepository.save(inventory); } - async countInventories( - branchId?: string, - productPresentationId?: string, - ): Promise { - return await this.inventoryRepository.count({ - relations: ['branch', 'productPresentation'], - where: { - branch: branchId ? { id: branchId } : undefined, - productPresentation: productPresentationId - ? { id: productPresentationId } - : undefined, - }, - }); - } - async findAll( page: number, pageSize: number, branchId?: string, productPresentationId?: string, - ): Promise { - return await this.inventoryRepository.find({ - relations: ['branch', 'productPresentation'], - where: { - branch: branchId ? { id: branchId } : undefined, - productPresentation: productPresentationId - ? { id: productPresentationId } - : undefined, - }, - order: { createdAt: 'DESC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); + ): Promise<[Inventory[], number]> { + const query = this.inventoryRepository.createQueryBuilder('inventory'); + query + .innerJoinAndSelect('inventory.branch', 'branch') + .innerJoinAndSelect('branch.city', 'city') + .innerJoinAndSelect('city.state', 'state') + .innerJoinAndSelect('state.country', 'country') + .innerJoinAndSelect( + 'inventory.productPresentation', + 'productPresentation', + ) + .innerJoinAndSelect('productPresentation.product', 'product') + .innerJoinAndSelect('productPresentation.presentation', 'presentation') + .innerJoinAndSelect('product.manufacturer', 'manufacturer'); + if (branchId) { + query.andWhere('branch.id = :branchId', { branchId }); + } + if (productPresentationId) { + query.andWhere('productPresentation.id = :productPresentationId', { + productPresentationId, + }); + } + query.orderBy('inventory.createdAt', 'DESC'); + query.skip((page - 1) * pageSize); + query.take(pageSize); + const [inventories, total] = await query.getManyAndCount(); + return [inventories, total]; } async findOne(id: string): Promise { @@ -111,14 +110,40 @@ export class InventoryService { async update( id: string, updateInventoryDTO: UpdateInventoryDTO, + branchId?: string, ): Promise { - const inventory = await this.findOne(id); + let where: FindOneOptions = { where: { id } }; + if (branchId) { + where = { + where: { id, branch: { id: branchId } }, + }; + } + const inventory = await this.inventoryRepository.findOne(where); + if (!inventory) { + throw new NotFoundException(`Inventory #${id} not found`); + } + const delta = updateInventoryDTO.stockQuantity! - inventory.stockQuantity; + const movement = this.inventoryMovementRepository.create({ + inventory, + quantity: Math.abs(delta), + type: delta > 0 ? MovementType.IN : MovementType.OUT, + }); const updatedInventory = { ...inventory, ...updateInventoryDTO }; + await this.inventoryMovementRepository.save(movement); return await this.inventoryRepository.save(updatedInventory); } - async remove(id: string): Promise { - const inventory = await this.findOne(id); + async remove(id: string, branchId?: string): Promise { + let where: FindOneOptions = { where: { id } }; + if (branchId) { + where = { + where: { id, branch: { id: branchId } }, + }; + } + const inventory = await this.inventoryRepository.findOne(where); + if (!inventory) { + throw new NotFoundException(`Inventory #${id} not found`); + } const deleted = await this.inventoryRepository.softDelete(inventory.id); if (!deleted.affected) { throw new NotFoundException(`Inventory #${id} not found`); @@ -160,31 +185,63 @@ export class InventoryService { ): Promise { const inventoriesToSave: Inventory[] = []; const movementsToSave: InventoryMovement[] = []; - //const lotsToSave: Lot[] = []; + + const totalByPresentation: Record = {}; + for (const item of bulkUpdateDto.inventories) { + totalByPresentation[item.productPresentationId] = + (totalByPresentation[item.productPresentationId] || 0) + item.quantity; + } + + const updatedPresentation: Record = {}; + + const originalStockMap: Record = {}; + for (const key of Object.keys(inventoryMap)) { + originalStockMap[key] = inventoryMap[key].stockQuantity; + } + + const lotMap = new Map< + string, + { quantity: number; expirationDate: Date } + >(); for (const item of bulkUpdateDto.inventories) { const inventory = inventoryMap[item.productPresentationId]; if (!inventory) continue; - inventory.stockQuantity = item.quantity; - inventoriesToSave.push(inventory); + if (!updatedPresentation[item.productPresentationId]) { + const originalQty = originalStockMap[item.productPresentationId]; + const newTotalQty = totalByPresentation[item.productPresentationId]; - const movement = this.inventoryMovementRepository.create({ - inventory, - quantity: item.quantity, - type: MovementType.IN, - }); - movementsToSave.push(movement); + inventory.stockQuantity = newTotalQty; + inventoriesToSave.push(inventory); + + const delta = newTotalQty - originalQty; + if (delta !== 0) { + const movement = this.inventoryMovementRepository.create({ + inventory, + quantity: Math.abs(delta), + type: delta > 0 ? MovementType.IN : MovementType.OUT, + }); + movementsToSave.push(movement); + } + updatedPresentation[item.productPresentationId] = true; + } - // if (item.expirationDate) { - // const lot = this.lotRepository.create({ - // productPresentation: { id: item.productPresentationId }, - // branch: { id: branchId }, - // quantity: item.quantity, - // expirationDate: new Date(item.expirationDate), - // }); - // lotsToSave.push(lot); - // } + if (item.expirationDate) { + const expDate = + item.expirationDate instanceof Date + ? item.expirationDate + : new Date(item.expirationDate); + const key = `${item.productPresentationId}_${expDate.toISOString()}`; + if (lotMap.has(key)) { + lotMap.get(key)!.quantity += item.quantity; + } else { + lotMap.set(key, { + quantity: item.quantity, + expirationDate: expDate, + }); + } + } } const updatedInventories = @@ -192,9 +249,31 @@ export class InventoryService { if (movementsToSave.length > 0) { await this.inventoryMovementRepository.save(movementsToSave); } - // if (lotsToSave.length > 0) { - // await this.lotRepository.save(lotsToSave); - // } + + for (const [key, info] of lotMap.entries()) { + const [presentationId] = key.split('_'); + + const existingLot = await this.lotRepository.findOne({ + where: { + productPresentation: { id: presentationId }, + branch: { id: branchId }, + expirationDate: info.expirationDate, + }, + }); + + if (existingLot) { + existingLot.quantity = info.quantity; + await this.lotRepository.save(existingLot); + } else { + const newLot = this.lotRepository.create({ + productPresentation: { id: presentationId }, + branch: { id: branchId }, + quantity: info.quantity, + expirationDate: info.expirationDate, + }); + await this.lotRepository.save(newLot); + } + } return updatedInventories; } diff --git a/src/main.ts b/src/main.ts index 391f259..8420a89 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,4 +31,5 @@ async function bootstrap() { SwaggerModule.setup('docs', app, documentFactory); await app.listen(process.env.PORT ?? 3000); } + bootstrap(); diff --git a/src/notification/controllers/notification.controller.ts b/src/notification/controllers/notification.controller.ts index 88b6ef4..8cb5846 100644 --- a/src/notification/controllers/notification.controller.ts +++ b/src/notification/controllers/notification.controller.ts @@ -8,11 +8,14 @@ import { HttpCode, HttpStatus, ParseUUIDPipe, + Sse, + MessageEvent, } from '@nestjs/common'; import { NotificationService } from '../services/notification.service'; import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { UserRole } from 'src/user/entities/user.entity'; +import { Observable } from 'rxjs'; @Controller('notification') export class NotificationController { @@ -48,4 +51,15 @@ export class NotificationController { ): Promise { await this.notificationService.markAsReadAsCustomer(orderId, req.user.id); } + @Sse('stream') + @UseGuards(AuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'SSE real-time notification flow' }) + @ApiResponse({ + status: 200, + description: 'Notification SSE Event Stream', + }) + streamNotifications(@Req() req: CustomRequest): Observable { + return this.notificationService.subscribeToNotifications(req.user.id); + } } diff --git a/src/notification/services/notification.service.ts b/src/notification/services/notification.service.ts index 0bda105..973f197 100644 --- a/src/notification/services/notification.service.ts +++ b/src/notification/services/notification.service.ts @@ -2,6 +2,8 @@ import { Injectable, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Notification } from '../entities/notification.entity'; +import { Subject, Observable, filter, defaultIfEmpty } from 'rxjs'; +import { MessageEvent } from '@nestjs/common'; @Injectable() export class NotificationService { @@ -9,6 +11,7 @@ export class NotificationService { @InjectRepository(Notification) private readonly notificationRepository: Repository, ) {} + private notifications$ = new Subject(); async getAllNotifications() { return await this.notificationRepository.find({ @@ -38,4 +41,26 @@ export class NotificationService { notification.isRead = true; await this.notificationRepository.save(notification); } + + emitNotification(notification: Notification) { + const event: MessageEvent = { + data: notification.message, + id: notification.order.user.id, + type: 'notification', + }; + this.notifications$.next(event); + } + + subscribeToNotifications(userId: string): Observable { + return this.notifications$ + .asObservable() + .pipe(filter((evt) => evt.id == userId)) + .pipe( + defaultIfEmpty({ + data: 'empty', + id: 'empty', + type: 'empty', + } as MessageEvent), + ); + } } diff --git a/src/notification/subscribers/notification.subscriber.ts b/src/notification/subscribers/notification.subscriber.ts index 4579973..80ff90e 100644 --- a/src/notification/subscribers/notification.subscriber.ts +++ b/src/notification/subscribers/notification.subscriber.ts @@ -2,14 +2,24 @@ import { EntitySubscriberInterface, EventSubscriber, UpdateEvent, + DataSource, } from 'typeorm'; import { Order, OrderStatus } from 'src/order/entities/order.entity'; import { Notification } from '../entities/notification.entity'; +import { NotificationService } from '../services/notification.service'; +import { Injectable } from '@nestjs/common'; @EventSubscriber() +@Injectable() export class NotificationSubscriber implements EntitySubscriberInterface { + constructor( + private readonly dataSource: DataSource, + private readonly notificationService: NotificationService, + ) { + this.dataSource.subscribers.push(this); + } listenTo() { return Order; } @@ -57,6 +67,11 @@ export class NotificationSubscriber }); } - await notificationRepository.save(notification); + const saved = await notificationRepository.save(notification); + const fullNotification = await notificationRepository.findOneOrFail({ + where: { id: saved.id }, + relations: ['order', 'order.user'], + }); + this.notificationService.emitNotification(fullNotification); } } diff --git a/src/order/controllers/order-delivery.controller.ts b/src/order/controllers/order-delivery.controller.ts index 286a66a..ebe97db 100644 --- a/src/order/controllers/order-delivery.controller.ts +++ b/src/order/controllers/order-delivery.controller.ts @@ -11,7 +11,7 @@ import { Param, Patch, } from '@nestjs/common'; -import { OrderService } from '../order.service'; +import { OrderService } from '../services/order.service'; import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { ApiBearerAuth, @@ -29,10 +29,14 @@ import { } from '../dto/order-delivery.dto'; import { User } from 'src/user/entities/user.entity'; import { plainToInstance } from 'class-transformer'; +import { OrderDeliveryService } from '../services/order-delivery.controller'; @Controller('delivery') export class OrderDeliveryController { - constructor(private readonly orderService: OrderService) {} + constructor( + private readonly orderService: OrderService, + private readonly orderDeliveryService: OrderDeliveryService, + ) {} @Get() @UseGuards(AuthGuard) @@ -92,12 +96,17 @@ export class OrderDeliveryController { @Query() pagination: OrderDeliveryQueryDTO, ): Promise<{ data: OrderDeliveryDTO[]; total: number }> { const { page, limit, status, branchId, employeeId } = pagination; - const data = await this.orderService.findAllOD(req.user, page, limit, { - status, - branchId, - employeeId, - }); - const total = await this.orderService.countDeliveries(req.user, { + const data = await this.orderDeliveryService.findAll( + req.user, + page, + limit, + { + status, + branchId, + employeeId, + }, + ); + const total = await this.orderDeliveryService.countDeliveries(req.user, { status, branchId, employeeId, @@ -121,7 +130,7 @@ export class OrderDeliveryController { async getDelivery( @Param('deliveryId') deliveryId: string, ): Promise { - const delivery = await this.orderService.getDelivery(deliveryId); + const delivery = await this.orderDeliveryService.findOne(deliveryId); return plainToInstance(OrderDeliveryDTO, delivery, { excludeExtraneousValues: true, @@ -140,7 +149,7 @@ export class OrderDeliveryController { @Body() updateDeliveryDto: UpdateDeliveryDTO, ): Promise { const user = req.user as User; - const updatedDelivery = await this.orderService.updateDelivery( + const updatedDelivery = await this.orderDeliveryService.update( user, deliveryId, updateDeliveryDto, diff --git a/src/order/controllers/order.controller.ts b/src/order/controllers/order.controller.ts index e6094b8..51ff5d0 100644 --- a/src/order/controllers/order.controller.ts +++ b/src/order/controllers/order.controller.ts @@ -14,9 +14,10 @@ import { Patch, NotFoundException, } from '@nestjs/common'; -import { OrderService } from '../order.service'; +import { OrderService } from '../services/order.service'; import { CreateOrderDTO, + OrderListUpdateDTO, OrderQueryDTO, ResponseOrderDetailedDTO, ResponseOrderDTO, @@ -138,10 +139,14 @@ export class OrderController { @Req() req: CustomRequest, @Query() query: OrderQueryDTO, ): Promise<{ data: ResponseOrderDTO[]; total: number }> { - const { page, limit, userId, branchId, status, type } = query; + const { page, limit, userId, status, type } = query; + let { branchId } = query; let user; if ([UserRole.ADMIN, UserRole.BRANCH_ADMIN].includes(req.user.role)) { if (userId) user = userId; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } } else { user = req.user.id; } @@ -162,6 +167,30 @@ export class OrderController { }; } + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @Patch('bulk') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk update orders' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Orders updated successfully', + }) + async bulkUpdate( + @Req() req: CustomRequest, + @Body() updateOrderDto: OrderListUpdateDTO, + ): Promise { + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + await this.orderService.bulkUpdate( + updateOrderDto.orders, + updateOrderDto.status, + branchId, + ); + } + @Get(':id') @UseGuards(AuthGuard) @ApiBearerAuth() @@ -178,6 +207,11 @@ export class OrderController { let order: Order; if ([UserRole.ADMIN, UserRole.BRANCH_ADMIN].includes(req.user.role)) { order = await this.orderService.findOne(id); + if (req.user.role === UserRole.BRANCH_ADMIN) { + if (order.branch.id !== req.user.branch.id) { + throw new NotFoundException('Order not found'); + } + } } else if ([UserRole.DELIVERY].includes(req.user.role)) { order = await this.orderService.findOne(id); if (order.type !== OrderType.DELIVERY) { @@ -209,9 +243,18 @@ export class OrderController { type: ResponseOrderDetailedDTO, }) async update( + @Req() req: CustomRequest, @Param('id', new ParseUUIDPipe()) id: string, @Body() updateOrderStatusDTO: UpdateOrderStatusDTO, ) { - return await this.orderService.update(id, updateOrderStatusDTO.status); + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + return await this.orderService.update( + id, + updateOrderStatusDTO.status, + branchId, + ); } } diff --git a/src/order/dto/order-delivery.dto.ts b/src/order/dto/order-delivery.dto.ts index d0fee0b..365dc67 100644 --- a/src/order/dto/order-delivery.dto.ts +++ b/src/order/dto/order-delivery.dto.ts @@ -3,7 +3,14 @@ import { ApiPropertyOptional, IntersectionType, } from '@nestjs/swagger'; -import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + IsDateString, + IsLatitude, + IsLongitude, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; import { Expose, Transform, Type } from 'class-transformer'; import { BaseUserDTO } from 'src/user/dto/user.dto'; @@ -162,3 +169,35 @@ export class OrderDeliveryEmployeeDTO extends IntersectionType( @Type(() => EmployeeDTO) employee: EmployeeDTO; } + +export class UpdateDeliveryWsDTO { + @ApiProperty({ description: 'ID of the order delivery' }) + @IsString() + @IsUUID() + id: string; + + @ApiPropertyOptional({ description: 'New delivery status' }) + @IsOptional() + @IsString() + deliveryStatus?: string; + + @ApiPropertyOptional({ + description: 'indicate the delivery is rejected (employee unassign)', + }) + @IsOptional() + employeeId?: string; +} + +export class UpdateCoordinatesWsDTO { + @ApiProperty({ description: 'ID of the order delivery' }) + @IsUUID() + orderId: string; + + @ApiProperty({ description: 'Latitude of the delivery location' }) + @IsLatitude() + latitude: number; + + @ApiProperty({ description: 'Longitude of the delivery location' }) + @IsLongitude() + longitude: number; +} diff --git a/src/order/dto/order.ts b/src/order/dto/order.ts index 1096e04..604d1ca 100644 --- a/src/order/dto/order.ts +++ b/src/order/dto/order.ts @@ -3,6 +3,7 @@ import { IsArray, IsEnum, IsInt, + IsNotEmpty, IsOptional, IsPositive, IsString, @@ -19,6 +20,7 @@ import { ResponseOrderProductPresentationDetailDTO } from 'src/products/dto/prod import { ResponseBranchDTO } from 'src/branch/dto/branch.dto'; import { OrderDeliveryEmployeeDTO } from './order-delivery.dto'; import { PaymentMethod } from 'src/payments/entities/payment-information.entity'; +import { ResponsePaymentConfirmationDTO } from 'src/payments/dto/payment-confirmation.dto'; export class CreateOrderDetailDTO { @ApiProperty({ description: 'ID of the product presentation' }) @@ -114,9 +116,19 @@ export class ResponseOrderDetailDTO { @IsPositive() quantity: number; + @Expose() + @ApiProperty({ description: 'Product price' }) + @IsInt() + @IsPositive() + price: number; + @Expose() @ApiProperty({ description: 'Subtotal price of the order detail' }) subtotal: number; + + @Expose() + @ApiProperty({ description: 'Discount applied to the order detail' }) + discount: number; } export class ResponseOrderDTO extends BaseDTO { @@ -140,6 +152,15 @@ export class ResponseOrderDTO extends BaseDTO { @IsEnum(PaymentMethod) @IsOptional() paymentMethod: PaymentMethod; + + @Expose() + @ApiProperty({ + description: 'Payment confirmation data (if any)', + type: ResponsePaymentConfirmationDTO, + required: false, + }) + @Type(() => ResponsePaymentConfirmationDTO) + paymentConfirmation?: ResponsePaymentConfirmationDTO; } export class ResponseOrderDetailedDTO extends ResponseOrderDTO { @@ -213,6 +234,16 @@ export class UpdateOrderStatusWsDTO { status: OrderStatus; } +export class OrderListUpdateDTO { + @ArrayNotEmpty() + @IsUUID(undefined, { each: true }) + orders: string[]; + + @IsNotEmpty() + @IsEnum(OrderStatus) + status: OrderStatus; +} + export class SalesReportDTO { @Expose() @ApiProperty({ description: 'ID of the order' }) diff --git a/src/order/entities/order.entity.ts b/src/order/entities/order.entity.ts index 47a05b0..b210278 100644 --- a/src/order/entities/order.entity.ts +++ b/src/order/entities/order.entity.ts @@ -2,7 +2,14 @@ import { Branch } from 'src/branch/entities/branch.entity'; import { ProductPresentation } from 'src/products/entities/product-presentation.entity'; import { User } from 'src/user/entities/user.entity'; import { BaseModel, UUIDModel } from 'src/utils/entity'; -import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, +} from 'typeorm'; import { OrderDelivery, OrderDetailDelivery } from './order_delivery.entity'; import { PaymentMethod } from 'src/payments/entities/payment-information.entity'; import { PaymentConfirmation } from 'src/payments/entities/payment-confirmation.entity'; @@ -58,11 +65,11 @@ export class Order extends BaseModel { }) paymentMethod: PaymentMethod; - @OneToMany( + @OneToOne( () => PaymentConfirmation, (paymentConfirmation) => paymentConfirmation.order, ) - paymentConfirmations: PaymentConfirmation[]; + paymentConfirmation: PaymentConfirmation; } @Entity() @@ -83,9 +90,15 @@ export class OrderDetail extends UUIDModel { @Column({ type: 'int', name: 'quantity' }) quantity: number; + @Column({ type: 'int', name: 'price', default: 0 }) + price: number; + @Column({ type: 'int', name: 'subtotal' }) subtotal: number; + @Column({ type: 'int', default: 0 }) + discount: number; + @OneToMany( () => OrderDetailDelivery, (orderDeliveryDetail) => orderDeliveryDetail.orderDetail, diff --git a/src/order/migrations/1747172177914-add-price-order-detail-migration.ts b/src/order/migrations/1747172177914-add-price-order-detail-migration.ts new file mode 100644 index 0000000..404b0e6 --- /dev/null +++ b/src/order/migrations/1747172177914-add-price-order-detail-migration.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPriceOrderDetailMigration1747172177914 + implements MigrationInterface +{ + name = 'AddPriceOrderDetailMigration1747172177914'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" ADD "price" integer DEFAULT 0 NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "order_detail" DROP COLUMN "price"`); + } +} diff --git a/src/order/migrations/1747426506331-add-payment-confirmation-in-order-detail-migration.ts b/src/order/migrations/1747426506331-add-payment-confirmation-in-order-detail-migration.ts new file mode 100644 index 0000000..5f1cbba --- /dev/null +++ b/src/order/migrations/1747426506331-add-payment-confirmation-in-order-detail-migration.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPaymentConfirmationInOrderDetailMigration1747426506331 + implements MigrationInterface +{ + name = 'AddPaymentConfirmationInOrderDetailMigration1747426506331'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" ADD "payment_confirmation_id" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "order_detail" ADD CONSTRAINT "FK_2cb24b410baacf4bd387b22757e" FOREIGN KEY ("payment_confirmation_id") REFERENCES "payment_confirmation"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" DROP CONSTRAINT "FK_2cb24b410baacf4bd387b22757e"`, + ); + await queryRunner.query( + `ALTER TABLE "order_detail" DROP COLUMN "payment_confirmation_id"`, + ); + } +} diff --git a/src/order/migrations/1748382133862-add-promo-discount-to-order-detail-migration.ts b/src/order/migrations/1748382133862-add-promo-discount-to-order-detail-migration.ts new file mode 100644 index 0000000..b5a76f0 --- /dev/null +++ b/src/order/migrations/1748382133862-add-promo-discount-to-order-detail-migration.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPromoDiscountToOrderDetailMigration1748382133862 + implements MigrationInterface +{ + name = 'AddPromoDiscountToOrderDetailMigration1748382133862'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" ADD "discount" integer NOT NULL DEFAULT '0'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" DROP COLUMN "discount"`, + ); + } +} diff --git a/src/order/order.gateway.ts b/src/order/order.gateway.ts index cad60da..ec6fbbb 100644 --- a/src/order/order.gateway.ts +++ b/src/order/order.gateway.ts @@ -16,13 +16,18 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { UpdateOrderStatusWsDTO } from './dto/order'; -import { OrderService } from './order.service'; +import { OrderService } from './services/order.service'; import { AuthGuardWs } from 'src/auth/auth.guard'; import { AuthService } from 'src/auth/auth.service'; import { RolesGuardWs } from 'src/auth/roles.guard'; import { Roles } from 'src/auth/roles.decorador'; import { UserRole } from 'src/user/entities/user.entity'; import { WebsocketExceptionsFilter } from './ws.filters'; +import { + UpdateCoordinatesWsDTO, + UpdateDeliveryWsDTO, +} from './dto/order-delivery.dto'; +import { OrderDeliveryService } from './services/order-delivery.controller'; @WebSocketGateway({ cors: { @@ -37,6 +42,7 @@ export class OrderGateway implements OnGatewayConnection, OnGatewayDisconnect { constructor( private readonly authService: AuthService, private readonly orderService: OrderService, + private readonly orderDeliveryService: OrderDeliveryService, ) {} handleConnection(client: Socket) { @@ -60,18 +66,91 @@ export class OrderGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() data: UpdateOrderStatusWsDTO, ) { - this.orderService.update(data.id, data.status).then((order) => { - if (!order) { - this.server.to(client.id).emit('error', { - message: 'Order not found', - data: { id: data.id }, - }); - } - this.orderService.getUserByOrderId(order.id).then((user) => { - if (user.wsId) { - client.to(user.wsId).emit('order', order); + this.orderService + .findOneWithUser(data.id) + .then((order) => { + this.orderService + .getUserByOrderId(order.id) + .then((user) => { + if (user.wsId) { + client.to(user.wsId).emit('orderUpdated', { + orderId: order.id, + status: data.status, + }); + } + }) + .catch((error) => { + client.to(client.id).emit('error', error); + }); + }) + .catch((error) => { + { + client.to(client.id).emit('error', error); + } + }); + } + + @UseFilters(new WebsocketExceptionsFilter()) + @UsePipes( + new ValidationPipe({ + exceptionFactory: (errors) => new WsException(errors), + }), + ) + @UseGuards(AuthGuardWs, RolesGuardWs) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @SubscribeMessage('updateDelivery') + updateDelivery( + @ConnectedSocket() client: Socket, + @MessageBody() data: UpdateDeliveryWsDTO, + ) { + this.orderDeliveryService + .findOne(data.id) + .then((delivery) => { + this.orderService + .getUserByOrderId(delivery.order.id) + .then((user) => { + if (user.wsId) { + client.to(user.wsId).emit('deliveryUpdated', { + orderDeliveryId: delivery.id, + status: data.deliveryStatus, + employeeId: delivery.employee.id, + }); + } + }) + .catch((error) => { + client.to(client.id).emit('error', error); + }); + }) + .catch((error) => { + client.to(client.id).emit('error', error); + }); + } + + @UseFilters(new WebsocketExceptionsFilter()) + @UsePipes( + new ValidationPipe({ + exceptionFactory: (errors) => new WsException(errors), + }), + ) + @UseGuards(AuthGuardWs, RolesGuardWs) + @Roles(UserRole.DELIVERY) + @SubscribeMessage('updateCoordinates') + updateCoordinates( + @ConnectedSocket() client: Socket, + @MessageBody() data: UpdateCoordinatesWsDTO, + ) { + this.orderService + .findOneWithUser(data.orderId) + .then((order) => { + if (order.user.wsId) { + client.to(order.user.wsId).emit('coordinatesUpdated', { + latitude: data.latitude, + longitude: data.longitude, + }); } + }) + .catch((error) => { + client.to(client.id).emit('error', error); }); - }); } } diff --git a/src/order/order.module.ts b/src/order/order.module.ts index f00cfbe..05dc39f 100644 --- a/src/order/order.module.ts +++ b/src/order/order.module.ts @@ -1,5 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; -import { OrderService } from './order.service'; +import { OrderService } from './services/order.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Order, OrderDetail } from './entities/order.entity'; import { BranchService } from 'src/branch/branch.service'; @@ -26,6 +26,7 @@ import { CouponService } from 'src/discount/services/coupon.service'; import { InventoryModule } from 'src/inventory/inventory.module'; import { OrderGateway } from './order.gateway'; import { EmailModule } from 'src/email/email.module'; +import { OrderDeliveryService } from './services/order-delivery.controller'; @Module({ imports: [ @@ -57,6 +58,7 @@ import { EmailModule } from 'src/email/email.module'; PromoService, CouponService, OrderGateway, + OrderDeliveryService, ], exports: [OrderService], }) diff --git a/src/order/services/order-delivery.controller.ts b/src/order/services/order-delivery.controller.ts new file mode 100644 index 0000000..e78a28f --- /dev/null +++ b/src/order/services/order-delivery.controller.ts @@ -0,0 +1,166 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { User, UserRole } from 'src/user/entities/user.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrderDelivery } from '../entities/order_delivery.entity'; +import { UpdateDeliveryDTO } from '../dto/order-delivery.dto'; +import { UserService } from 'src/user/user.service'; + +@Injectable() +export class OrderDeliveryService { + constructor( + @InjectRepository(OrderDelivery) + private orderDeliveryRepository: Repository, + private userService: UserService, + ) {} + + async findAll( + user: User, + page: number, + pageSize: number, + filters?: { + status?: string; + branchId?: string; + employeeId?: string; + }, + ): Promise { + const query = this.orderDeliveryRepository + .createQueryBuilder('delivery') + .leftJoinAndSelect('delivery.order', 'order') + .leftJoinAndSelect('order.user', 'orderUser') + .leftJoinAndSelect('orderUser.profile', 'profile') + .leftJoinAndSelect('delivery.address', 'address') + .leftJoinAndSelect('address.city', 'city') + .leftJoinAndSelect('city.state', 'state') + .leftJoinAndSelect('state.country', 'country') + .leftJoinAndSelect('delivery.branch', 'branch') + .leftJoinAndSelect('delivery.employee', 'employee') + .where('delivery.deletedAt IS NULL'); + + if (user.role !== UserRole.ADMIN) { + query.andWhere('employee.id = :userId', { userId: user.id }); + } else if (filters?.employeeId) { + query.andWhere('employee.id = :employeeId', { + employeeId: filters.employeeId, + }); + } + + if (filters?.status) { + query.andWhere('delivery.deliveryStatus = :status', { + status: filters.status, + }); + } + + if (filters?.branchId) { + query.andWhere('branch.id = :branchId', { branchId: filters.branchId }); + } + + const delivery = query + .orderBy('delivery.createdAt', 'DESC') + .skip((page - 1) * pageSize) + .take(pageSize); + + return await delivery.getMany(); + } + + async countDeliveries( + user: User, + filters?: { + status?: string; + branchId?: string; + employeeId?: string; + }, + ): Promise { + const query = this.orderDeliveryRepository + .createQueryBuilder('delivery') + .leftJoin('delivery.branch', 'branch') + .leftJoin('delivery.employee', 'employee') + .where('delivery.deletedAt IS NULL'); + + if (user.role !== UserRole.ADMIN) { + query.andWhere('employee.id = :userId', { userId: user.id }); + } else if (filters?.employeeId) { + query.andWhere('employee.id = :employeeId', { + employeeId: filters.employeeId, + }); + } + + if (filters?.status) { + query.andWhere('delivery.deliveryStatus = :status', { + status: filters.status, + }); + } + + if (filters?.branchId) { + query.andWhere('branch.id = :branchId', { branchId: filters.branchId }); + } + + return await query.getCount(); + } + + async findOne(deliveryId: string): Promise { + const delivery = await this.orderDeliveryRepository.findOne({ + where: { id: deliveryId }, + relations: [ + 'order', + 'order.user', + 'order.user.profile', + 'address', + 'address.city', + 'address.city.state', + 'address.city.state.country', + 'employee', + 'branch', + ], + }); + if (!delivery) { + throw new NotFoundException('Delivery not found.'); + } + return delivery; + } + + async update( + user: User, + deliveryId: string, + updateData: UpdateDeliveryDTO, + ): Promise { + const delivery = await this.orderDeliveryRepository.findOne({ + where: { id: deliveryId }, + relations: [ + 'order', + 'order.user', + 'address', + 'address.city', + 'address.city.state', + 'address.city.state.country', + 'employee', + 'branch', + ], + }); + if (!delivery) { + throw new NotFoundException('Delivery not found.'); + } + + const updateDelivery = this.orderDeliveryRepository.merge( + delivery, + updateData, + ); + if (updateData.employeeId) { + const employee = await this.userService.findUserById( + updateData.employeeId, + ); + if (!employee) { + throw new NotFoundException('Employee not found.'); + } + if (employee.role !== UserRole.DELIVERY) { + throw new BadRequestException('User is not an employee.'); + } + updateDelivery.employee = employee; + } + return await this.orderDeliveryRepository.save(updateDelivery); + } +} diff --git a/src/order/order.service.ts b/src/order/services/order.service.ts similarity index 76% rename from src/order/order.service.ts rename to src/order/services/order.service.ts index 868bc6a..538e1ff 100644 --- a/src/order/order.service.ts +++ b/src/order/services/order.service.ts @@ -3,24 +3,23 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { CreateOrderDTO, SalesReportDTO } from './dto/order'; -import { User, UserRole } from 'src/user/entities/user.entity'; +import { CreateOrderDTO, SalesReportDTO } from '../dto/order'; +import { User } from 'src/user/entities/user.entity'; import { ProductPresentationService } from 'src/products/services/product-presentation.service'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Order, OrderDetail, OrderStatus, OrderType, -} from './entities/order.entity'; +} from '../entities/order.entity'; import { BranchService } from 'src/branch/branch.service'; import { Branch } from 'src/branch/entities/branch.entity'; import { OrderDelivery, OrderDeliveryStatus, -} from './entities/order_delivery.entity'; -import { UpdateDeliveryDTO } from './dto/order-delivery.dto'; +} from '../entities/order_delivery.entity'; import { UserAddress } from 'src/user/entities/user-address.entity'; import { UserService } from 'src/user/user.service'; import { CouponService } from 'src/discount/services/coupon.service'; @@ -66,13 +65,15 @@ export class OrderService { 'User address ID is required for delivery orders', ); } - // TODO: Find the closest branch to the address - const branches = await this.branchService.findAll(1, 10); - branch = branches[0]; userAddress = await this.userService.getAddress( user.id, createOrderDTO.userAddressId, ); + const nearestBranch = await this.branchService.findNearestBranch( + userAddress.latitude, + userAddress.longitude, + ); + branch = nearestBranch; } else { throw new BadRequestException('Invalid order type'); } @@ -101,10 +102,12 @@ export class OrderService { ); } } + let totalPrice = productsWithQuantity.reduce((acc, product) => { - const price = product.promo - ? product.price - (product.price * product.promo.discount) / 100 - : product.price; + const price = + product.promo && product.promo.expiredAt > new Date() + ? product.price - (product.price * product.promo.discount) / 100 + : product.price; return acc + Math.round(price) * product.quantity; }, 0); @@ -126,15 +129,16 @@ export class OrderService { }); const order = await this.orderRepository.save(orderToCreate); const orderDetails = productsWithQuantity.map((product) => { + const priceWithDiscount = product.promo + ? product.price - (product.price * product.promo.discount) / 100 + : product.price; return this.orderDetailRepository.create({ order, productPresentation: product, quantity: product.quantity, - subtotal: product.promo - ? Math.round( - product.price - (product.price * product.promo.discount) / 100, - ) * product.quantity - : product.price * product.quantity, + price: product.price, + subtotal: Math.round(priceWithDiscount) * product.quantity, + discount: product.promo ? product.promo.discount : 0, }); }); await this.orderDetailRepository.save(orderDetails); @@ -198,9 +202,10 @@ export class OrderService { return { orders, total }; } - async findOne(id: string, userId?: string) { + async findOne(id: string, userId?: string, branchId?: string) { const where: Record = { id }; if (userId) where.user = { id: userId }; + if (branchId) where.branch = { id: branchId }; const order = await this.orderRepository.findOne({ where: where, relations: [ @@ -212,6 +217,7 @@ export class OrderService { 'details.productPresentation.product.images', 'details.productPresentation.presentation', 'orderDeliveries.employee', + 'paymentConfirmation', ], }); if (!order) { @@ -232,6 +238,7 @@ export class OrderService { 'details.productPresentation.product.images', 'details.productPresentation.presentation', 'orderDeliveries.employee', + 'paymentConfirmation', 'user', 'user.profile', ], @@ -242,8 +249,12 @@ export class OrderService { return order; } - async update(id: string, status: OrderStatus): Promise { - const order = await this.findOne(id); + async update( + id: string, + status: OrderStatus, + branchId?: string, + ): Promise { + const order = await this.findOne(id, undefined, branchId); if (order.status === OrderStatus.COMPLETED) { console.log('A COMPLETED order cannot be modified'); return order; @@ -276,150 +287,29 @@ export class OrderService { return await this.orderRepository.save(order); } - async findAllOD( - user: User, - page: number, - pageSize: number, - filters?: { - status?: string; - branchId?: string; - employeeId?: string; - }, - ): Promise { - const query = this.orderDeliveryRepository - .createQueryBuilder('delivery') - .leftJoinAndSelect('delivery.order', 'order') - .leftJoinAndSelect('order.user', 'orderUser') - .leftJoinAndSelect('orderUser.profile', 'profile') - .leftJoinAndSelect('delivery.address', 'address') - .leftJoinAndSelect('address.city', 'city') - .leftJoinAndSelect('city.state', 'state') - .leftJoinAndSelect('state.country', 'country') - .leftJoinAndSelect('delivery.branch', 'branch') - .leftJoinAndSelect('delivery.employee', 'employee') - .where('delivery.deletedAt IS NULL'); - - if (user.role !== UserRole.ADMIN) { - query.andWhere('employee.id = :userId', { userId: user.id }); - } else if (filters?.employeeId) { - query.andWhere('employee.id = :employeeId', { - employeeId: filters.employeeId, - }); - } - - if (filters?.status) { - query.andWhere('delivery.deliveryStatus = :status', { - status: filters.status, - }); - } - - if (filters?.branchId) { - query.andWhere('branch.id = :branchId', { branchId: filters.branchId }); - } - - const delivery = query - .orderBy('delivery.createdAt', 'DESC') - .skip((page - 1) * pageSize) - .take(pageSize); - - return await delivery.getMany(); - } - - async countDeliveries( - user: User, - filters?: { - status?: string; - branchId?: string; - employeeId?: string; - }, - ): Promise { - const query = this.orderDeliveryRepository - .createQueryBuilder('delivery') - .leftJoin('delivery.branch', 'branch') - .leftJoin('delivery.employee', 'employee') - .where('delivery.deletedAt IS NULL'); - - if (user.role !== UserRole.ADMIN) { - query.andWhere('employee.id = :userId', { userId: user.id }); - } else if (filters?.employeeId) { - query.andWhere('employee.id = :employeeId', { - employeeId: filters.employeeId, - }); - } - - if (filters?.status) { - query.andWhere('delivery.deliveryStatus = :status', { - status: filters.status, - }); + async bulkUpdate( + ordersIds: string[], + status: OrderStatus, + branchId?: string, + ) { + let where: Record = { id: In(ordersIds) }; + if (branchId) { + where = { + ...where, + branch: { id: branchId }, + }; } - - if (filters?.branchId) { - query.andWhere('branch.id = :branchId', { branchId: filters.branchId }); + const orders = await this.orderRepository.findBy(where); + if (orders.length === 0) { + throw new NotFoundException('No orders found'); } - - return await query.getCount(); - } - - async getDelivery(deliveryId: string): Promise { - const delivery = await this.orderDeliveryRepository.findOne({ - where: { id: deliveryId }, - relations: [ - 'order', - 'order.user', - 'order.user.profile', - 'address', - 'address.city', - 'address.city.state', - 'address.city.state.country', - 'employee', - 'branch', - ], + const updatedOrders = orders.map((order) => { + if (order.status !== OrderStatus.COMPLETED) { + order.status = status; + return order; + } else return order; }); - if (!delivery) { - throw new NotFoundException('Delivery not found.'); - } - return delivery; - } - - async updateDelivery( - user: User, - deliveryId: string, - updateData: UpdateDeliveryDTO, - ): Promise { - const delivery = await this.orderDeliveryRepository.findOne({ - where: { id: deliveryId }, - relations: [ - 'order', - 'order.user', - 'address', - 'address.city', - 'address.city.state', - 'address.city.state.country', - 'employee', - 'branch', - ], - }); - if (!delivery) { - throw new NotFoundException('Delivery not found.'); - } - - const updateDelivery = this.orderDeliveryRepository.merge( - delivery, - updateData, - ); - if (updateData.employeeId) { - const employee = await this.userService.findUserById( - updateData.employeeId, - ); - if (!employee) { - throw new NotFoundException('Employee not found.'); - } - if (employee.role !== UserRole.DELIVERY) { - throw new BadRequestException('User is not an employee.'); - } - updateDelivery.employee = employee; - } - return await this.orderDeliveryRepository.save(updateDelivery); + return await this.orderRepository.save(updatedOrders); } async countOrdersCompleted( @@ -547,8 +437,8 @@ export class OrderService { .addSelect('o.createdAt', 'date') .addSelect('o.type', 'type') .addSelect('SUM(d.quantity)', 'quantity') - .addSelect('SUM(d.subtotal)', 'subtotal') - .addSelect('SUM(d.quantity * pp.price) - o.totalPrice', 'discount') + .addSelect('SUM(d.quantity * d.price)', 'subtotal') + .addSelect('SUM(d.quantity * d.price) - o.totalPrice', 'discount') .addSelect('o.totalPrice', 'total') .innerJoin('o.user', 'u') .innerJoin('o.details', 'd') diff --git a/src/payments/controllers/payment-confirmation.controller.ts b/src/payments/controllers/payment-confirmation.controller.ts index 68d5547..44cf789 100644 --- a/src/payments/controllers/payment-confirmation.controller.ts +++ b/src/payments/controllers/payment-confirmation.controller.ts @@ -1,10 +1,18 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; import { PaymentConfirmationService } from '../services/payment-confirmation.service'; import { CreatePaymentConfirmationDTO, ResponsePaymentConfirmationDTO, } from '../dto/payment-confirmation.dto'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiOperation, + ApiResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; @ApiTags('Payment Confirmation') @Controller('payment-confirmation') @@ -14,6 +22,8 @@ export class PaymentConfirmationController { ) {} @Post() + @UseGuards(AuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Create Payment Confirmation', description: 'Registers a new payment confirmation for a user order.', @@ -23,9 +33,20 @@ export class PaymentConfirmationController { description: 'Payment confirmation created successfully.', type: ResponsePaymentConfirmationDTO, }) + @ApiUnauthorizedResponse({ + description: 'Unauthorized - Missing or invalid JWT token.', + }) + @ApiForbiddenResponse({ + description: + 'Forbidden - The order does not belong to the authenticated user.', + }) async create( + @Req() req: CustomRequest, @Body() createPaymentConfirmationDto: CreatePaymentConfirmationDTO, ): Promise { - return this.paymentConfirmationService.create(createPaymentConfirmationDto); + return this.paymentConfirmationService.create( + req.user.id, + createPaymentConfirmationDto, + ); } } diff --git a/src/payments/entities/payment-confirmation.entity.ts b/src/payments/entities/payment-confirmation.entity.ts index 0e98477..f41e688 100644 --- a/src/payments/entities/payment-confirmation.entity.ts +++ b/src/payments/entities/payment-confirmation.entity.ts @@ -1,6 +1,6 @@ import { Order } from 'src/order/entities/order.entity'; import { BaseModel } from 'src/utils/entity'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; @Entity('payment_confirmation') export class PaymentConfirmation extends BaseModel { @@ -16,7 +16,7 @@ export class PaymentConfirmation extends BaseModel { @Column({ type: 'character varying', name: 'phone_number' }) phoneNumber: string; - @ManyToOne(() => Order, (order) => order.paymentConfirmations, { + @OneToOne(() => Order, (order) => order.paymentConfirmation, { onDelete: 'RESTRICT', }) @JoinColumn({ name: 'order_id' }) diff --git a/src/payments/migrations/1748379101213-update-one-to-one-relation-migration.ts b/src/payments/migrations/1748379101213-update-one-to-one-relation-migration.ts new file mode 100644 index 0000000..8203577 --- /dev/null +++ b/src/payments/migrations/1748379101213-update-one-to-one-relation-migration.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateOneToOneRelationMigration1748379101213 + implements MigrationInterface +{ + name = 'UpdateOneToOneRelationMigration1748379101213'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "order_detail" DROP CONSTRAINT "FK_2cb24b410baacf4bd387b22757e"`, + ); + await queryRunner.query( + `ALTER TABLE "order_detail" DROP COLUMN "payment_confirmation_id"`, + ); + await queryRunner.query( + `ALTER TABLE "payment_confirmation" DROP CONSTRAINT "FK_de3ea608b9f32c2184b551c554b"`, + ); + await queryRunner.query( + `ALTER TABLE "payment_confirmation" ADD CONSTRAINT "UQ_de3ea608b9f32c2184b551c554b" UNIQUE ("order_id")`, + ); + await queryRunner.query( + `ALTER TABLE "payment_confirmation" ADD CONSTRAINT "FK_de3ea608b9f32c2184b551c554b" FOREIGN KEY ("order_id") REFERENCES "order"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "payment_confirmation" DROP CONSTRAINT "FK_de3ea608b9f32c2184b551c554b"`, + ); + await queryRunner.query( + `ALTER TABLE "payment_confirmation" DROP CONSTRAINT "UQ_de3ea608b9f32c2184b551c554b"`, + ); + await queryRunner.query( + `ALTER TABLE "payment_confirmation" ADD CONSTRAINT "FK_de3ea608b9f32c2184b551c554b" FOREIGN KEY ("order_id") REFERENCES "order"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "order_detail" ADD "payment_confirmation_id" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "order_detail" ADD CONSTRAINT "FK_2cb24b410baacf4bd387b22757e" FOREIGN KEY ("payment_confirmation_id") REFERENCES "payment_confirmation"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/payments/services/payment-confirmation.service.ts b/src/payments/services/payment-confirmation.service.ts index ee12094..0aed084 100644 --- a/src/payments/services/payment-confirmation.service.ts +++ b/src/payments/services/payment-confirmation.service.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PaymentConfirmation } from '../entities/payment-confirmation.entity'; import { CreatePaymentConfirmationDTO } from '../dto/payment-confirmation.dto'; -import { OrderService } from 'src/order/order.service'; +import { OrderService } from 'src/order/services/order.service'; @Injectable() export class PaymentConfirmationService { @@ -14,11 +14,18 @@ export class PaymentConfirmationService { ) {} async create( + userId: string, createPaymentConfirmationDto: CreatePaymentConfirmationDTO, ): Promise { - const order = await this.orderService.findOne( + const order = await this.orderService.findOneWithUser( createPaymentConfirmationDto.orderId, ); + + if (order.user.id !== userId) { + throw new ForbiddenException( + 'You are not allowed to confirm this payment', + ); + } const confirmation = this.paymentConfirmationRepository.create({ ...createPaymentConfirmationDto, order, diff --git a/src/products/controllers/lot.controller.ts b/src/products/controllers/lot.controller.ts new file mode 100644 index 0000000..1bccda6 --- /dev/null +++ b/src/products/controllers/lot.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Get, + HttpStatus, + Query, + UseInterceptors, +} from '@nestjs/common'; +import { LotService } from '../services/lot.service'; +import { PaginationInterceptor } from 'src/utils/pagination.interceptor'; +import { + ApiOperation, + ApiQuery, + ApiResponse, + getSchemaPath, +} from '@nestjs/swagger'; +import { + PaginationDTO, + PaginationQueryDTO, +} from 'src/utils/dto/pagination.dto'; +import { Pagination } from 'src/utils/pagination.decorator'; +import { plainToInstance } from 'class-transformer'; +import { LotQueryDTO, ResponseLotDTO } from '../dto/lot.dto'; + +@Controller('lot') +export class LotController { + constructor(private readonly lotService: LotService) {} + + @Get() + @UseInterceptors(PaginationInterceptor) + @ApiOperation({ summary: 'List all lots' }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination', + type: Number, + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number, + example: 10, + }) + @ApiQuery({ + name: 'branchId', + required: false, + description: 'Filter by branch ID', + type: String, + }) + @ApiQuery({ + name: 'productPresentationId', + required: false, + description: 'Filter by product presentation ID', + type: String, + }) + @ApiResponse({ + description: 'Successful retrieval of lots', + status: HttpStatus.OK, + schema: { + allOf: [ + { $ref: getSchemaPath(PaginationDTO) }, + { + properties: { + results: { + type: 'array', + items: { $ref: getSchemaPath(ResponseLotDTO) }, + }, + }, + }, + ], + }, + }) + async findAll( + @Pagination() pagination: PaginationQueryDTO, + @Query() query: LotQueryDTO, + ): Promise<{ data: ResponseLotDTO[]; total: number }> { + const { page, limit } = pagination; + const [data, total] = await this.lotService.findAll( + page, + limit, + query.branchId, + query.productPresentationId, + ); + return { + data: plainToInstance(ResponseLotDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }), + total, + }; + } +} diff --git a/src/products/dto/lot.dto.ts b/src/products/dto/lot.dto.ts new file mode 100644 index 0000000..9d5c631 --- /dev/null +++ b/src/products/dto/lot.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, IntersectionType } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsOptional, IsUUID } from 'class-validator'; +import { ResponseBranchDTO } from 'src/branch/dto/branch.dto'; +import { BaseDTO } from 'src/utils/dto/base.dto'; +import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; +import { ProductPresentationDTO } from './product.dto'; + +export class LotDTO { + @Expose() + quantity: number; + + @Expose() + expirationDate: Date; +} + +export class LotQueryDTO extends PaginationQueryDTO { + @ApiProperty({ description: 'The branch id of the lot' }) + @IsOptional() + @IsUUID() + branchId: string; + + @ApiProperty({ description: 'The product presentation id of the lot' }) + @IsOptional() + @IsUUID() + productPresentationId: string; +} + +export class ResponseLotDTO extends IntersectionType(LotDTO, BaseDTO) { + @Expose() + @ApiProperty({ + description: 'The branch in wich the product is available', + type: ResponseBranchDTO, + }) + branch: ResponseBranchDTO; + + @Expose() + @ApiProperty({ + description: 'The product presentation of the product in the lot', + type: ProductPresentationDTO, + }) + productPresentation: ProductPresentationDTO; +} diff --git a/src/products/dto/product-presentation.dto.ts b/src/products/dto/product-presentation.dto.ts index 43b01fb..0d54193 100644 --- a/src/products/dto/product-presentation.dto.ts +++ b/src/products/dto/product-presentation.dto.ts @@ -93,3 +93,16 @@ export class ResponseOrderProductPresentationDetailDTO extends IntersectionType( @ApiProperty({ type: ResponsePromoDTO }) promo: ResponsePromoDTO; } + +export class ProductPresentationListUpdateDTO { + @ApiProperty({ description: 'The IDs of the product presentation' }) + @IsUUID(undefined, { each: true }) + ids: string[]; + + @ApiProperty({ + description: 'Indicates if the product presentation is visible', + default: true, + }) + @IsBoolean() + isVisible: boolean; +} diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts index 940bf17..deb1532 100644 --- a/src/products/dto/product.dto.ts +++ b/src/products/dto/product.dto.ts @@ -12,6 +12,7 @@ import { CategoryResponseDTO } from 'src/category/dto/category.dto'; import { ResponsePresentationDTO } from './presentation.dto'; import { PaginationQueryDTO } from 'src/utils/dto/pagination.dto'; import { Expose, Transform, Type } from 'class-transformer'; +import { ResponsePromoDTO } from 'src/discount/dto/promo.dto'; export class AddCategoryDTO { @IsString() @@ -83,6 +84,11 @@ export class ProductPresentationDTO extends BaseDTO { @Type(() => ProductDTO) product: ProductDTO; + @Expose() + @Type(() => ResponsePromoDTO) + @ApiProperty({ type: ResponsePromoDTO }) + promo: ResponsePromoDTO; + @Expose() @ApiProperty({ description: 'Stock quantity in all branches' }) stock: number; diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 819a971..c9d377a 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -1,6 +1,9 @@ import { + Body, Controller, Get, + HttpStatus, + Patch, Query, Req, UseGuards, @@ -8,10 +11,12 @@ import { } from '@nestjs/common'; import { ProductsService } from './products.service'; import { + ApiBearerAuth, ApiExtraModels, ApiOkResponse, ApiOperation, ApiQuery, + ApiResponse, getSchemaPath, } from '@nestjs/swagger'; import { ProductPresentationDTO, ProductQueryDTO } from './dto/product.dto'; @@ -20,6 +25,11 @@ import { PaginationInterceptor } from 'src/utils/pagination.interceptor'; import { plainToInstance } from 'class-transformer'; import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { RecommendationService } from 'src/recommendation/recommendation.service'; +import { RolesGuard } from 'src/auth/roles.guard'; +import { Roles } from 'src/auth/roles.decorador'; +import { UserRole } from 'src/user/entities/user.entity'; +import { ProductPresentationListUpdateDTO } from './dto/product-presentation.dto'; +import { ProductPresentationService } from './services/product-presentation.service'; @Controller('product') @ApiExtraModels(PaginationDTO, ProductPresentationDTO) @@ -27,6 +37,7 @@ export class ProductsController { constructor( private productsServices: ProductsService, private recommendationService: RecommendationService, + private productPresentationService: ProductPresentationService, ) {} @Get() @UseInterceptors(PaginationInterceptor) @@ -140,19 +151,20 @@ export class ProductsController { isVisible, id, } = pagination; - const { products, total } = await this.productsServices.getProducts( - page, - limit, - q, - categoryId, - manufacturerId, - branchId, - presentationId, - genericProductId, - priceRange, - isVisible, - id, - ); + const { products, total } = + await this.productsServices.getProductQueryBuilder( + page, + limit, + q, + categoryId, + manufacturerId, + branchId, + presentationId, + genericProductId, + priceRange, + isVisible, + id, + ); return { data: plainToInstance(ProductPresentationDTO, products, { @@ -187,4 +199,22 @@ export class ProductsController { total: products.total, }; } + + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Patch('presentation/bulk') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk update orders' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Orders updated successfully', + }) + async bulkUpdate( + @Body() updateProductPresentationDto: ProductPresentationListUpdateDTO, + ): Promise { + await this.productPresentationService.bulkUpdate( + updateProductPresentationDto.ids, + updateProductPresentationDto.isVisible, + ); + } } diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 4526f3c..2e50739 100644 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -26,6 +26,9 @@ import { ProductCategoryController } from './controllers/product-category.contro import { ProductCategoryService } from './services/product-category.service'; import { CategoryService } from 'src/category/category.service'; import { RecommendationService } from 'src/recommendation/recommendation.service'; +import { LotService } from './services/lot.service'; +import { LotController } from './controllers/lot.controller'; +import { Lot } from './entities/lot.entity'; @Module({ imports: [ @@ -37,6 +40,7 @@ import { RecommendationService } from 'src/recommendation/recommendation.service Country, Category, ProductImage, + Lot, ]), AuthModule, forwardRef(() => DiscountModule), @@ -49,6 +53,7 @@ import { RecommendationService } from 'src/recommendation/recommendation.service ProductPresentationController, ProductImageController, ProductCategoryController, + LotController, ], providers: [ CategoryService, @@ -61,6 +66,7 @@ import { RecommendationService } from 'src/recommendation/recommendation.service ProductImageService, ProductCategoryService, RecommendationService, + LotService, ], }) export class ProductsModule {} diff --git a/src/products/products.service.ts b/src/products/products.service.ts index d01381e..36a163e 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, Between, ILike } from 'typeorm'; +import { Repository, In, Between, ILike, Brackets } from 'typeorm'; import { ProductPresentation } from './entities/product-presentation.entity'; @Injectable() @@ -10,6 +10,100 @@ export class ProductsService { private productPresentationRepository: Repository, ) {} + async getProductQueryBuilder( + page: number, + limit: number, + searchQuery?: string, + categoryIds?: string[], + manufacturerIds?: string[], + branchIds?: string[], + presentationIds?: string[], + genericProductIds?: string[], + priceRange?: number[], + isVisible?: boolean, + ids?: string[], + ) { + const query = this.productPresentationRepository.createQueryBuilder( + 'product_presentation', + ); + query + .innerJoinAndSelect('product_presentation.product', 'product') + .innerJoinAndSelect('product.images', 'images') + .innerJoinAndSelect('product.manufacturer', 'manufacturer') + .innerJoinAndSelect('product.categories', 'categories') + .innerJoinAndSelect('product_presentation.presentation', 'presentation') + .leftJoinAndSelect('product_presentation.promo', 'promo'); + + if (searchQuery) { + query.where( + new Brackets((qb) => { + qb.where('product.name ILIKE :searchQuery', { + searchQuery: `%${searchQuery}%`, + }) + .orWhere('product.genericName ILIKE :searchQuery', { + searchQuery: `%${searchQuery}%`, + }) + .orWhere('product.description ILIKE :searchQuery', { + searchQuery: `%${searchQuery}%`, + }); + }), + ); + } + if (ids && ids.length > 0) { + query.andWhere('product_presentation.id IN (:...ids)', { ids }); + } + if (categoryIds && categoryIds.length > 0) { + query.andWhere('product.categories.id IN (:...categoryIds)', { + categoryIds, + }); + } + if (manufacturerIds && manufacturerIds.length > 0) { + query.andWhere('product.manufacturer.id IN (:...manufacturerIds)', { + manufacturerIds, + }); + } + if (branchIds && branchIds.length > 0) { + query + .innerJoinAndSelect('product_presentation.inventories', 'inventories') + .andWhere('inventories.branch.id IN (:...branchIds)', { branchIds }); + } + if (presentationIds && presentationIds.length > 0) { + query.andWhere( + 'product_presentation.presentation.id IN (:...presentationIds)', + { + presentationIds, + }, + ); + } + if (genericProductIds && genericProductIds.length > 0) { + query.andWhere('product.id IN (:...genericProductIds)', { + genericProductIds, + }); + } + if (priceRange && priceRange.length === 2) { + query.andWhere( + 'product_presentation.price BETWEEN :minPrice AND :maxPrice', + { + minPrice: priceRange[0], + maxPrice: priceRange[1], + }, + ); + } + if (typeof isVisible === 'boolean') { + query.andWhere('product_presentation.isVisible = :isVisible', { + isVisible: isVisible, + }); + } else { + query.andWhere('product_presentation.isVisible = true'); + } + query + .orderBy('product_presentation.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + const [products, total] = await query.getManyAndCount(); + return { products, total }; + } + async getProducts( page: number, limit: number, @@ -113,6 +207,12 @@ export class ProductsService { categories: 'product.categories', presentation: 'product_presentation.presentation', }, + leftJoinAndSelect: { + promo: 'product_presentation.promo', + }, + }, + order: { + createdAt: 'DESC', }, where, skip: (page - 1) * limit, diff --git a/src/products/services/lot.service.ts b/src/products/services/lot.service.ts new file mode 100644 index 0000000..89b2420 --- /dev/null +++ b/src/products/services/lot.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Lot } from '../entities/lot.entity'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class LotService { + constructor( + @InjectRepository(Lot) + private lotRepository: Repository, + ) {} + + async findAll( + page: number, + limit: number, + branchId?: string, + productPresentationId?: string, + ): Promise<[Lot[], number]> { + const query = this.lotRepository + .createQueryBuilder('lot') + .innerJoinAndSelect('lot.productPresentation', 'productPresentation') + .innerJoinAndSelect('lot.branch', 'branch') + .innerJoinAndSelect('branch.city', 'city') + .innerJoinAndSelect('city.state', 'state') + .innerJoinAndSelect('state.country', 'country') + .innerJoinAndSelect('productPresentation.product', 'product') + .innerJoinAndSelect('productPresentation.presentation', 'presentation') + .innerJoinAndSelect('product.manufacturer', 'manufacturer'); + + if (branchId) { + query.andWhere('branch.id = :branchId', { branchId }); + } + + if (productPresentationId) { + query.andWhere('productPresentation.id = :productPresentationId', { + productPresentationId, + }); + } + query.skip((page - 1) * limit); + query.take(limit); + query.orderBy('lot.createdAt', 'DESC'); + const [lots, count] = await query.getManyAndCount(); + return [lots, count]; + } +} diff --git a/src/products/services/product-presentation.service.ts b/src/products/services/product-presentation.service.ts index 1921bee..4af0ba7 100644 --- a/src/products/services/product-presentation.service.ts +++ b/src/products/services/product-presentation.service.ts @@ -115,6 +115,22 @@ export class ProductPresentationService { return await this.repository.save(updatedProductPresentation); } + async bulkUpdate(productPresentationIds: string[], isVisible: boolean) { + const productPresentations = await this.repository.findBy({ + id: In(productPresentationIds), + }); + if (productPresentations.length === 0) { + throw new NotFoundException('No product presentations found'); + } + const updatedProductPresentations = productPresentations.map( + (productPresentation) => { + productPresentation.isVisible = isVisible; + return productPresentation; + }, + ); + return await this.repository.save(updatedProductPresentations); + } + async remove(productId: string, presentationId: string): Promise { const productPresentation = await this.findOneProductPresentation( productId, diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index f7a35f1..a446b91 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -4,12 +4,13 @@ import { Query, BadRequestException, UseGuards, + Req, } from '@nestjs/common'; -import { OrderService } from 'src/order/order.service'; +import { OrderService } from 'src/order/services/order.service'; import { UserService } from 'src/user/user.service'; import { OrderStatus } from 'src/order/entities/order.entity'; import { Roles } from 'src/auth/roles.decorador'; -import { AuthGuard } from 'src/auth/auth.guard'; +import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { RolesGuard } from 'src/auth/roles.guard'; import { UserRole } from 'src/user/entities/user.entity'; import { ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger'; @@ -57,6 +58,7 @@ export class ReportsController { }, }) async getDashboard( + @Req() req: CustomRequest, @Query('startDate') start: string, @Query('endDate') end: string, @Query('branchId') branchId?: string, @@ -64,6 +66,9 @@ export class ReportsController { if (!start || !end) { throw new BadRequestException('startDate and endDate are required'); } + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } const startDate = new Date(start); const endDate = new Date(end); const [openOrders, completedOrders, totalSales, totalNewUsers] = @@ -86,6 +91,7 @@ export class ReportsController { totalNewUsers, }; } + @Get('order') @ApiBearerAuth() @ApiQuery({ @@ -120,10 +126,14 @@ export class ReportsController { }, }) async getOrdersByStatus( + @Req() req: CustomRequest, @Query('startDate') start: string, @Query('endDate') end: string, @Query('branchId') branchId?: string, ) { + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } if (!start || !end) { throw new BadRequestException('startDate and endDate are required'); } @@ -160,10 +170,14 @@ export class ReportsController { type: FullSalesReportDTO, }) async getSalesReport( + @Req() req: CustomRequest, @Query('startDate') start: string, @Query('endDate') end: string, @Query('branchId') branchId?: string, ) { + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } if (!start || !end) { throw new BadRequestException('startDate and endDate are required'); } diff --git a/src/sales/dto/predict-sales.dto.ts b/src/sales/dto/predict-sales.dto.ts new file mode 100644 index 0000000..1dbfeb5 --- /dev/null +++ b/src/sales/dto/predict-sales.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional } from 'class-validator'; + +export class PredictSalesDTO { + @IsOptional() + days?: string = '7'; +} + +export class DailySaleDTO { + date: string; + total: number; +} + +export class PredictedSaleDTO { + @ApiProperty() + date: string; + + @ApiProperty() + @IsInt() + predictedTotal: number; +} diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts new file mode 100644 index 0000000..edfda33 --- /dev/null +++ b/src/sales/sales.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { SalesService } from './sales.service'; +import { PredictedSaleDTO, PredictSalesDTO } from './dto/predict-sales.dto'; +import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; +import { RolesGuard } from 'src/auth/roles.guard'; +import { Roles } from 'src/auth/roles.decorador'; +import { UserRole } from 'src/user/entities/user.entity'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, +} from '@nestjs/swagger'; + +@Controller('sales') +export class SalesController { + constructor(private readonly salesService: SalesService) {} + + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) + @Get('predict') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Predict future daily sales', + description: + 'Returns sales predictions starting from today for the number of future days specified.', + }) + @ApiQuery({ + name: 'days', + required: false, + description: 'Number of future days to predict (default: 7)', + type: Number, + example: 5, + }) + @ApiResponse({ + status: 200, + description: 'List of predicted daily sales values', + type: [PredictedSaleDTO], + }) + async predict( + @Req() req: CustomRequest, + @Query() query: PredictSalesDTO, + ): Promise { + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + const salesData = await this.salesService.getDailySales(branchId); + const dailySales = this.salesService.fillMissingDates(salesData); + const days = query.days ? parseInt(query.days, 10) || 7 : 7; + return await this.salesService.predictNext(days, dailySales); + } +} diff --git a/src/sales/sales.module.ts b/src/sales/sales.module.ts new file mode 100644 index 0000000..193092b --- /dev/null +++ b/src/sales/sales.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Order } from 'src/order/entities/order.entity'; +import { SalesController } from './sales.controller'; +import { SalesService } from './sales.service'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Order]), AuthModule], + controllers: [SalesController], + providers: [SalesService], +}) +export class SalesModule {} diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts new file mode 100644 index 0000000..cd48143 --- /dev/null +++ b/src/sales/sales.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Order, OrderStatus } from '../order/entities/order.entity'; +import { Repository } from 'typeorm'; +import * as tf from '@tensorflow/tfjs'; +import { DailySaleDTO, PredictedSaleDTO } from './dto/predict-sales.dto'; + +@Injectable() +export class SalesService { + constructor( + @InjectRepository(Order) + private readonly orderRepository: Repository, + ) {} + + async getDailySales(branchId?: string): Promise { + const query = this.orderRepository + .createQueryBuilder('order') + .select('DATE(order.createdAt)', 'date') + .addSelect('SUM(order.totalPrice)', 'total') + .where('order.status = :status', { status: OrderStatus.COMPLETED }) + .groupBy('DATE(order.createdAt)') + .orderBy('DATE(order.createdAt)', 'ASC'); + if (branchId) { + query.andWhere('order.branchId = :branchId', { branchId }); + } + const sales = await query.getRawMany<{ date: string; total: string }>(); + + return sales.map((row) => ({ + date: row.date, + total: parseInt(row.total), + })); + } + + fillMissingDates(data: DailySaleDTO[]): DailySaleDTO[] { + if (data.length < 2) return data; + + const filled = []; + const start = new Date(data[0].date); + const yesterday = new Date(); + yesterday.setHours(0, 0, 0, 0); + yesterday.setDate(yesterday.getDate() - 1); + + const dateMap = new Map( + data.map((d) => [new Date(d.date).toISOString().split('T')[0], d.total]), + ); + + for (let d = new Date(start); d <= yesterday; d.setDate(d.getDate() + 1)) { + const dateStr = d.toISOString().split('T')[0]; + + filled.push({ + date: dateStr, + total: dateMap.get(dateStr) ?? 0, + }); + } + + return filled; + } + + async predictNext( + daysAhead: number = 7, + dailySales: DailySaleDTO[], + ): Promise { + if (dailySales.length < 2) return []; + + const inputs = dailySales.map((_, i) => i); + const labels = dailySales.map((s) => s.total); + + const xs = tf.tensor2d(inputs, [inputs.length, 1]); + const ys = tf.tensor2d(labels, [labels.length, 1]); + + const model = tf.sequential(); + model.add(tf.layers.dense({ inputShape: [1], units: 1 })); + model.compile({ optimizer: 'sgd', loss: 'meanSquaredError' }); + + await model.fit(xs, ys, { epochs: 3 }); + + const futureDays = Array.from( + { length: daysAhead }, + (_, i) => inputs.length + i, + ); + + const predictions = model.predict( + tf.tensor2d(futureDays, [daysAhead, 1]), + ) as tf.Tensor; + + const predictedValues = await predictions.array(); + + return futureDays.map((_, index) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + index); + + const formatted = date.toISOString().split('T')[0]; + + return { + date: formatted, + predictedTotal: parseInt( + (predictedValues as number[][])[index][0].toFixed(2), + ), + }; + }); + } +} diff --git a/src/user/dto/user.dto.ts b/src/user/dto/user.dto.ts index 912d564..b7522f2 100644 --- a/src/user/dto/user.dto.ts +++ b/src/user/dto/user.dto.ts @@ -16,10 +16,14 @@ import { MaxLength, MinLength, IsString, + IsUUID, + IsBoolean, + ArrayNotEmpty, } from 'class-validator'; import { UserGender } from '../entities/profile.entity'; import { IsOlderThan } from 'src/utils/is-older-than-validator'; import { UserRole } from '../entities/user.entity'; +import { ResponseBranchDTO } from 'src/branch/dto/branch.dto'; class PasswordDTO { @ApiProperty({ description: 'La contraseña del usuario' }) @@ -88,16 +92,54 @@ export class BaseUserDTO { @IsOptional() @IsEnum(UserGender) gender?: UserGender; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiProperty({ + description: 'Indicates whether the user has a generic password', + }) + isGenericPassword?: boolean; } export class UserDTO extends IntersectionType(BaseUserDTO, PasswordDTO) {} +export class UserListUpdateDTO { + @ApiProperty({ + description: 'List of user IDs to be updated', + type: [String], + }) + @ArrayNotEmpty() + @IsUUID(undefined, { each: true }) + users: string[]; + + @Expose() + @IsOptional() + @ApiProperty({ description: 'If the user has validated the email' }) + isValidated: boolean; + + @Expose() + @IsOptional() + @IsEnum(UserRole) + @ApiProperty({ description: 'Role of the user', enum: UserRole }) + role: UserRole; +} + export class UserAdminDTO extends BaseUserDTO { @ApiProperty({ description: 'the role of the user' }) @IsNotEmpty() @Expose() role: UserRole; + @ApiProperty({ + description: 'branchId of the user (branch_admin or delivery)', + required: false, + }) + @IsOptional() + @IsUUID() + @Expose() + branchId?: string; + // Data use if it is a delivery @ApiProperty({ description: 'Motorcycle brand', required: false }) @@ -213,6 +255,13 @@ export class UserListDTO extends OmitType(UserDTO, ['birthDate'] as const) { @Expose() isValidated: boolean; + @Expose() + @ApiProperty({ + description: 'If the user has downloades and login in the mobile app', + }) + @IsOptional() + isMobileCustomer: boolean; + @ApiProperty({ description: 'Profile object' }) @Expose() @Type(() => ProfileDTO) @@ -222,6 +271,15 @@ export class UserListDTO extends OmitType(UserDTO, ['birthDate'] as const) { @Expose() @Type(() => UserMotoDTO) userMoto: UserMotoDTO; + + @Expose() + @ApiProperty({ + description: 'Branch object', + type: ResponseBranchDTO, + nullable: true, + }) + @Type(() => ResponseBranchDTO) + branch?: ResponseBranchDTO; } export class UpdateUserDTO extends PartialType( diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 86cadb6..da83a58 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -96,4 +96,10 @@ export class User extends BaseModel { @Column({ type: 'character varying', name: 'ws_id', nullable: true }) wsId: string | undefined; + + @Column({ type: 'boolean', default: false, name: 'is_mobile_customer' }) + isMobileCustomer: boolean; + + @Column({ type: 'boolean', default: false, name: 'is_generic_password' }) + isGenericPassword: boolean; } diff --git a/src/user/migrations/1747259587010-add-flags-to-user-migration.ts b/src/user/migrations/1747259587010-add-flags-to-user-migration.ts new file mode 100644 index 0000000..b9387c9 --- /dev/null +++ b/src/user/migrations/1747259587010-add-flags-to-user-migration.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFlagsToUserMigration1747259587010 + implements MigrationInterface +{ + name = 'AddFlagsToUserMigration1747259587010'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "is_mobile_customer" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "is_generic_password" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "is_generic_password"`, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "is_mobile_customer"`, + ); + } +} diff --git a/src/user/migrations/1748541346159-seed-default-admin-migration.ts b/src/user/migrations/1748541346159-seed-default-admin-migration.ts new file mode 100644 index 0000000..b55d93d --- /dev/null +++ b/src/user/migrations/1748541346159-seed-default-admin-migration.ts @@ -0,0 +1,49 @@ +import * as bcrypt from 'bcryptjs'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedDefaultAdminMigration1748541346159 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const adminEmail = process.env.ADMIN_EMAIL; + const adminPassword = process.env.ADMIN_PASSWORD; + if (!adminEmail || !adminPassword) { + throw new Error( + 'ADMIN_EMAIL and ADMIN_PASSWORD environment variables must be set', + ); + } + const hashedPassword = await bcrypt.hash(adminPassword, 10); + const id = '88e4e6de-77fb-49d4-8ec2-d925e3a29a55'; + await queryRunner.query(` + INSERT INTO public.user (id, email, password, role, first_name, last_name, document_id, is_validated) + VALUES ( + '${id}', + '${adminEmail}', + '${hashedPassword}', + 'admin', + 'Administrador', + 'Pharmatech', + '1', + true + ); + INSERT INTO public.profile (user_id, birth_date) + VALUES ( + '${id}', + '2000-01-01' + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const adminEmail = process.env.ADMIN_EMAIL; + if (!adminEmail) { + throw new Error('ADMIN_EMAIL environment variable must be set'); + } + await queryRunner.query(` + DELETE FROM public.user WHERE email = '${adminEmail}'; + DELETE FROM public.profile WHERE user_id = ( + SELECT id FROM public.user WHERE email = '${adminEmail}' + ); + `); + } +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 8d7ee8b..ea9e56f 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -36,6 +36,7 @@ import { UserAdminDTO, UpdateUserDTO, UserMotoDTO, + UserListUpdateDTO, } from './dto/user.dto'; import { PaginationDTO, UserQueryDTO } from 'src/utils/dto/pagination.dto'; import { plainToInstance } from 'class-transformer'; @@ -75,6 +76,23 @@ export class UserController { await this.userService.validateEmail(userOtp); } + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Patch('bulk') + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk update users' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Users updated successfully', + }) + async bulkUpdate(@Body() updateUserDto: UserListUpdateDTO): Promise { + await this.userService.bulkUpdate( + updateUserDto.users, + updateUserDto.isValidated, + updateUserDto.role, + ); + } + @HttpCode(HttpStatus.OK) @Get(':userId') @UseGuards(AuthGuard, UserOrAdminGuard) diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 8e858e4..cc33946 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -9,6 +9,8 @@ import { AuthModule } from 'src/auth/auth.module'; import { Branch } from 'src/branch/entities/branch.entity'; import { UserAddress } from './entities/user-address.entity'; import { UserMoto } from './entities/user-moto.entity'; +import { BranchModule } from 'src/branch/branch.module'; +import { EmailModule } from 'src/email/email.module'; @Module({ imports: [ @@ -21,6 +23,8 @@ import { UserMoto } from './entities/user-moto.entity'; UserMoto, ]), forwardRef(() => AuthModule), + forwardRef(() => BranchModule), + EmailModule, ], providers: [UserService], exports: [UserService, TypeOrmModule], diff --git a/src/user/user.service.ts b/src/user/user.service.ts index d43d58c..bd2295b 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { UserAdminDTO, UserDTO, UpdateUserDTO } from './dto/user.dto'; import { User, UserRole } from './entities/user.entity'; import { UserOTP } from './entities/user-otp.entity'; @@ -16,6 +16,9 @@ import { CreateUserAddressDTO } from './dto/user-address.dto'; import { ConfigService } from '@nestjs/config'; import { UserMoto } from './entities/user-moto.entity'; import { UpdateUserMotoDTO } from './dto/user-moto.dto'; +import { BranchService } from 'src/branch/branch.service'; +import { EmailService } from 'src/email/email.service'; +import { generateRandomPassword } from 'src/utils/password'; @Injectable() export class UserService { @@ -31,6 +34,9 @@ export class UserService { @InjectRepository(UserMoto) private UserMotoRepository: Repository, private configService: ConfigService, + + private readonly branchService: BranchService, + private emailService: EmailService, ) {} async userExists(options: Partial): Promise { @@ -122,11 +128,36 @@ export class UserService { if (documentUsed) { throw new BadRequestException('The document is already in use'); } - const password: string = this.configService.get('ADMIN_PASSWORD', ''); + const password: string = generateRandomPassword(8); const hashedPassword = await bcrypt.hash(password, 10); const newUser = this.userRepository.create(user); newUser.password = hashedPassword; + newUser.isGenericPassword = true; + if ( + user.role === UserRole.BRANCH_ADMIN || + user.role === UserRole.DELIVERY + ) { + if (!user.branchId) { + throw new BadRequestException('branchId is required for this role'); + } + + const branch = await this.branchService.findOne(user.branchId); + + newUser.branch = branch; + } const userCreated = await this.userRepository.save(newUser); + + await this.emailService.sendEmail({ + recipients: [{ email: user.email, name: user.firstName }], + subject: 'Welcome to Pharmatech', + html: ` +

Hola ${newUser.firstName}, tu cuenta ha sido creada.

+

Aca esta tu contraseña: ${password}

+

Por favor, cambiala al iniciar sesión.

+ `, + text: `Hola ${newUser.firstName}, tu cuenta ha sido creada, aca esta tu contraseña: ${password}`, + }); + const profile = new Profile(); profile.user = userCreated; if (user.gender) { @@ -384,7 +415,23 @@ export class UserService { if (!user) { return; } - user.wsId = undefined; - await this.userRepository.save(user); + await this.userRepository.update(user.id, { wsId: '' }); + } + + async bulkUpdate( + userIds: string[], + isValidated?: boolean, + UserRole?: UserRole, + ): Promise { + const users = await this.userRepository.findBy({ id: In(userIds) }); + if (!users.length) { + throw new NotFoundException('No users found'); + } + + const updatedUsers = users.map((user) => { + return { ...user, isValidated, role: UserRole }; + }); + + return await this.userRepository.save(updatedUsers); } } diff --git a/src/utils/pagination.interceptor.ts b/src/utils/pagination.interceptor.ts index 53e8342..6ba0e68 100644 --- a/src/utils/pagination.interceptor.ts +++ b/src/utils/pagination.interceptor.ts @@ -22,10 +22,7 @@ export class PaginationInterceptor implements NestInterceptor { next: CallHandler, ): Observable> { const req = context.switchToHttp().getRequest(); - const paginationQuery = plainToInstance(PaginationQueryDTO, req.query); - console.log('paginationQuery after transformation:', paginationQuery); - const errors = validateSync(paginationQuery); if (errors.length > 0) { @@ -35,8 +32,6 @@ export class PaginationInterceptor implements NestInterceptor { } req.pagination = paginationQuery; - console.log('Pagination received:', paginationQuery); - const { page, limit } = paginationQuery; const baseUrl = `${req.protocol}://${req.get('host')}${req.path}`; diff --git a/src/utils/password.ts b/src/utils/password.ts new file mode 100644 index 0000000..3c08951 --- /dev/null +++ b/src/utils/password.ts @@ -0,0 +1,10 @@ +export function generateRandomPassword(length: number = 8): string { + const charset = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+[]{}|;:,.<>?'; + let password = ''; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * charset.length); + password += charset[randomIndex]; + } + return password; +}