Skip to content
Merged
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DiscountModule } from './discount/discount.module';
import { PaymentModule } from './payments/payment.module';
import { OrderModule } from './order/order.module';
import { NotificationModule } from './notification/notification.module';
import { CartModule } from './cart/cart.module';
import { RecommendationModule } from './recommendation/recommendation.module';
import { ReportsModule } from './reports/reports.module';

Expand Down Expand Up @@ -61,6 +62,7 @@ import { ReportsModule } from './reports/reports.module';
PaymentModule,
OrderModule,
NotificationModule,
CartModule,
RecommendationModule,
ReportsModule,
],
Expand Down
14 changes: 14 additions & 0 deletions src/cart/cart.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/auth/auth.module';
import { Cart } from './entities/cart.entity';
import { CartItem } from './entities/cart-item.entity';
import { CartController } from './controllers/cart.controller';
import { CartService } from './cart.service';

@Module({
imports: [TypeOrmModule.forFeature([Cart, CartItem]), AuthModule],
controllers: [CartController],
providers: [CartService],
})
export class CartModule {}
119 changes: 119 additions & 0 deletions src/cart/cart.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cart } from './entities/cart.entity';
import { CartItem } from './entities/cart-item.entity';
import { User } from 'src/user/entities/user.entity';
import { CreateCartDTO, UpdateCartDTO } from './dto/cart.dto';

@Injectable()
export class CartService {
constructor(
@InjectRepository(Cart)
private cartRepository: Repository<Cart>,
@InjectRepository(CartItem)
private itemRepository: Repository<CartItem>,
) {}

async getCartById(id: string): Promise<Cart> {
const cart = await this.cartRepository.findOne({
where: { id },
relations: [
'items',
'items.productPresentation',
'items.productPresentation.product',
'items.productPresentation.presentation',
'items.productPresentation.promo',
'items.productPresentation.product.images',
],
});
if (!cart) {
throw new NotFoundException(`Cart with id #${id} not found`);
}
return cart;
}

async getCartByUserId(userId: string): Promise<Cart> {
const cart = await this.cartRepository.findOne({
where: { user: { id: userId } },
relations: [
'items',
'items.productPresentation',
'items.productPresentation.product',
'items.productPresentation.presentation',
'items.productPresentation.promo',
'items.productPresentation.product.images',
],
});
if (!cart) {
throw new NotFoundException(`Cart for user #${userId} not found`);
}
return cart;
}

async createCart(user: User, dto: CreateCartDTO): Promise<Cart> {
const cartExists = await this.cartRepository.exists({ where: { user } });
if (cartExists) {
throw new BadRequestException(`Cart for user #${user.id} already exists`);
}
const cart = this.cartRepository.create({ user });
cart.items = dto.items.map((i) =>
this.itemRepository.create({
productPresentation: { id: i.productPresentationId },
quantity: i.quantity,
}),
);
const saved = await this.cartRepository.save(cart);
return await this.getCartById(saved.id);
}

async listCartItems(user: User): Promise<CartItem[]> {
const cart = await this.getCartByUserId(user.id);
return cart?.items ?? [];
}

async updateCart(user: User, dto: UpdateCartDTO): Promise<Cart> {
const cart = await this.getCartByUserId(user.id);
const incoming = new Map(
dto.items.map((i) => [i.productPresentationId, i.quantity]),
);

for (const item of cart.items) {
if (!incoming.has(item.productPresentation.id)) {
await this.itemRepository.delete(item.id);
} else {
const qty = incoming.get(item.productPresentation.id)!;
if (qty <= 0) {
await this.itemRepository.delete(item.id);
} else if (qty !== item.quantity) {
item.quantity = qty;
await this.itemRepository.save(item);
}
incoming.delete(item.productPresentation.id);
}
}

for (const [productId, qty] of incoming.entries()) {
if (qty > 0) {
const newItem = this.itemRepository.create({
cart: { id: cart.id },
productPresentation: { id: productId },
quantity: qty,
});
await this.itemRepository.save(newItem);
}
}

const updatedCart = await this.getCartById(cart.id);

if (updatedCart.items.length === 0) {
await this.cartRepository.delete(cart.id);
}

return updatedCart;
}
}
63 changes: 63 additions & 0 deletions src/cart/controllers/cart.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
Controller,
Post,
Get,
Patch,
Body,
HttpCode,
Req,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CartService } from '../cart.service';
import {
CreateCartDTO,
CartDTO,
CartItemDTO,
UpdateCartDTO,
} from '../dto/cart.dto';
import { CustomRequest, AuthGuard } from 'src/auth/auth.guard';
import { plainToInstance } from 'class-transformer';

@ApiTags('Cart')
@Controller('cart')
export class CartController {
constructor(private readonly cartService: CartService) {}

@Post()
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.CREATED)
async createCart(
@Req() req: CustomRequest,
@Body() dto: CreateCartDTO,
): Promise<CartDTO> {
const cart = await this.cartService.createCart(req.user, dto);
return plainToInstance(CartDTO, cart, {
excludeExtraneousValues: true,
});
}

@Get()
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async listCart(@Req() req: CustomRequest): Promise<CartItemDTO[]> {
const items = await this.cartService.listCartItems(req.user);
return plainToInstance(CartItemDTO, items, {
excludeExtraneousValues: true,
});
}

@Patch()
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async updateCart(
@Req() req: CustomRequest,
@Body() dto: UpdateCartDTO,
): Promise<CartDTO> {
const cart = await this.cartService.updateCart(req.user, dto);
return plainToInstance(CartDTO, cart, {
excludeExtraneousValues: true,
});
}
}
76 changes: 76 additions & 0 deletions src/cart/dto/cart.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsUUID,
IsInt,
Min,
ArrayNotEmpty,
ValidateNested,
} from 'class-validator';
import { Type, Expose } from 'class-transformer';
import { ResponseOrderProductPresentationDetailDTO } from 'src/products/dto/product-presentation.dto';

export class BaseCartItemDTO {
@Expose()
@ApiProperty({ description: 'product presentation quantity', minimum: 1 })
@IsInt()
@Min(1)
quantity: number;
}

export class CreateCartItemDTO extends BaseCartItemDTO {
@Expose()
@ApiProperty({ description: 'ID of the product presentation' })
@IsUUID()
productPresentationId: string;
}

export class CartItemDTO extends BaseCartItemDTO {
@Expose()
@ApiProperty({ description: 'ID of item in the cart' })
@IsUUID()
id: string;

@Expose()
@ApiProperty({
description: 'Product presentation',
type: ResponseOrderProductPresentationDetailDTO,
})
@Type(() => ResponseOrderProductPresentationDetailDTO)
productPresentation: ResponseOrderProductPresentationDetailDTO;
}

export class CreateCartDTO {
@ApiProperty({
description: 'Items list',
type: [CreateCartItemDTO],
})
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CreateCartItemDTO)
items: CreateCartItemDTO[];
}

export class UpdateCartDTO {
@ApiProperty({
description: 'Items list',
type: [CreateCartItemDTO],
})
@ValidateNested({ each: true })
@Type(() => CreateCartItemDTO)
items: CreateCartItemDTO[];
}

export class CartDTO {
@Expose()
@ApiProperty({ description: 'ID of the cart' })
@IsUUID()
id: string;

@Expose()
@ApiProperty({
description: 'Cart items',
type: [CartItemDTO],
})
@Type(() => CartItemDTO)
items: CartItemDTO[];
}
22 changes: 22 additions & 0 deletions src/cart/entities/cart-item.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { UUIDModel } from 'src/utils/entity';
import { Entity, ManyToOne, JoinColumn, Column } from 'typeorm';
import { Cart } from './cart.entity';
import { ProductPresentation } from 'src/products/entities/product-presentation.entity';

@Entity()
export class CartItem extends UUIDModel {
@ManyToOne(() => Cart, (cart) => cart.items)
@JoinColumn({ name: 'cart_id' })
cart: Cart;

@ManyToOne(
() => ProductPresentation,
(productPresentation) => productPresentation.cartItems,
{ eager: true },
)
@JoinColumn({ name: 'product_presentation_id' })
productPresentation: ProductPresentation;

@Column({ type: 'int', name: 'quantity' })
quantity: number;
}
14 changes: 14 additions & 0 deletions src/cart/entities/cart.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { BaseModel } from 'src/utils/entity';
import { Entity, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
import { User } from 'src/user/entities/user.entity';
import { CartItem } from './cart-item.entity';

@Entity()
export class Cart extends BaseModel {
@ManyToOne(() => User, (user) => user.carts)
@JoinColumn({ name: 'user_id' })
user: User;

@OneToMany(() => CartItem, (cartItem) => cartItem.cart, { cascade: true })
items: CartItem[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddEntityCartAndCartItemMigration1745784567962
implements MigrationInterface
{
name = 'AddEntityCartAndCartItemMigration1745784567962';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "cart" ("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, "user_id" uuid, CONSTRAINT "PK_c524ec48751b9b5bcfbf6e59be7" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "cart_item" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "quantity" integer NOT NULL, "cart_id" uuid, "product_presentation_id" uuid, CONSTRAINT "PK_bd94725aa84f8cf37632bcde997" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "cart" ADD CONSTRAINT "FK_f091e86a234693a49084b4c2c86" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cart_item" ADD CONSTRAINT "FK_b6b2a4f1f533d89d218e70db941" FOREIGN KEY ("cart_id") REFERENCES "cart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cart_item" ADD CONSTRAINT "FK_67a2e8406e01ffa24ff9026944e" FOREIGN KEY ("product_presentation_id") REFERENCES "product_presentation"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "cart_item" DROP CONSTRAINT "FK_67a2e8406e01ffa24ff9026944e"`,
);
await queryRunner.query(
`ALTER TABLE "cart_item" DROP CONSTRAINT "FK_b6b2a4f1f533d89d218e70db941"`,
);
await queryRunner.query(
`ALTER TABLE "cart" DROP CONSTRAINT "FK_f091e86a234693a49084b4c2c86"`,
);
await queryRunner.query(`DROP TABLE "cart_item"`);
await queryRunner.query(`DROP TABLE "cart"`);
}
}
5 changes: 5 additions & 0 deletions src/products/entities/product-presentation.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { Lot } from './lot.entity';
import { Promo } from '../../discount/entities/promo.entity';
import { Inventory } from 'src/inventory/entities/inventory.entity';
import { OrderDetail } from 'src/order/entities/order.entity';
import { CartItem } from 'src/cart/entities/cart-item.entity';

@Entity('product_presentation')
export class ProductPresentation extends BaseModel {
@ManyToOne(() => Product, (product) => product.presentations)
Expand Down Expand Up @@ -47,6 +49,9 @@ export class ProductPresentation extends BaseModel {
)
orders: OrderDetail[];

@OneToMany(() => CartItem, (cartItem) => cartItem.productPresentation)
cartItems: CartItem[];

@VirtualColumn({
query: (alias) =>
`SELECT SUM(stock_quantity) FROM inventory WHERE product_presentation_id = ${alias}.id`,
Expand Down
Loading