diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index e7177b51..fc1b27d6 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -26,6 +26,7 @@ import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-Upd import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders'; import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; +import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity'; const schemaMigrations = [ User1725726359198, @@ -56,6 +57,7 @@ const schemaMigrations = [ PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, UpdateManufacturerEntity1768680807820, + UpdateOrderEntity1769990652833, ]; export default schemaMigrations; diff --git a/apps/backend/src/migrations/1769990652833-UpdateOrderEntity.ts b/apps/backend/src/migrations/1769990652833-UpdateOrderEntity.ts new file mode 100644 index 00000000..780483df --- /dev/null +++ b/apps/backend/src/migrations/1769990652833-UpdateOrderEntity.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateOrderEntity1769990652833 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + ADD COLUMN IF NOT EXISTS tracking_link VARCHAR(255), + ADD COLUMN IF NOT EXISTS shipping_cost NUMERIC(10,2); + + UPDATE orders + SET tracking_link = 'www.samplelink/samplelink', + shipping_cost = 20.00 + WHERE status = 'delivered' OR status = 'shipped' AND shipped_at IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + DROP COLUMN IF EXISTS tracking_link, + DROP COLUMN IF EXISTS shipping_cost; + `); + } +} diff --git a/apps/backend/src/orders/dtos/tracking-cost.dto.ts b/apps/backend/src/orders/dtos/tracking-cost.dto.ts new file mode 100644 index 00000000..1c29ce6e --- /dev/null +++ b/apps/backend/src/orders/dtos/tracking-cost.dto.ts @@ -0,0 +1,15 @@ +import { IsUrl, IsNumber, Min, IsOptional } from 'class-validator'; + +export class TrackingCostDto { + @IsUrl({}, { message: 'Tracking link must be a valid URL' }) + @IsOptional() + trackingLink?: string; + + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'Shipping cost must have at most 2 decimal places' }, + ) + @Min(0, { message: 'Shipping cost cannot be negative' }) + @IsOptional() + shippingCost?: number; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index e2e44818..980fc6ba 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -8,6 +8,7 @@ import { mock } from 'jest-mock-extended'; import { OrderStatus } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { TrackingCostDto } from './dtos/tracking-cost.dto'; const mockOrdersService = mock(); const mockAllocationsService = mock(); @@ -100,4 +101,20 @@ describe('OrdersController', () => { ).toHaveBeenCalledWith(orderId); }); }); + + describe('updateTrackingCostInfo', () => { + it('should call ordersService.updateTrackingCostInfo with correct parameters', async () => { + const orderId = 1; + const trackingLink = 'www.samplelink/samplelink'; + const shippingCost = 15.99; + const dto: TrackingCostDto = { trackingLink, shippingCost }; + + await controller.updateTrackingCostInfo(orderId, dto); + + expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( + orderId, + dto, + ); + }); + }); }); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 1b4e8c38..a3ec003a 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -7,6 +7,7 @@ import { Body, Query, BadRequestException, + ValidationPipe, } from '@nestjs/common'; import { OrdersService } from './order.service'; import { Order } from './order.entity'; @@ -15,6 +16,7 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; +import { TrackingCostDto } from './dtos/tracking-cost.dto'; @Controller('orders') export class OrdersController { @@ -99,4 +101,13 @@ export class OrdersController { } return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); } + + @Patch('/:orderId/update-tracking-cost-info') + async updateTrackingCostInfo( + @Param('orderId', ParseIntPipe) orderId: number, + @Body(new ValidationPipe()) + dto: TrackingCostDto, + ): Promise { + return this.ordersService.updateTrackingCostInfo(orderId, dto); + } } diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 18b6afcc..32145287 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -15,27 +15,27 @@ import { Allocation } from '../allocations/allocations.entity'; @Entity('orders') export class Order { @PrimaryGeneratedColumn({ name: 'order_id' }) - orderId: number; + orderId!: number; @ManyToOne(() => FoodRequest, { nullable: false }) @JoinColumn({ name: 'request_id', referencedColumnName: 'requestId', }) - request: FoodRequest; + request!: FoodRequest; @Column({ name: 'request_id' }) - requestId: number; + requestId!: number; - @ManyToOne(() => FoodManufacturer, { nullable: false }) + @ManyToOne(() => FoodManufacturer, { nullable: true }) @JoinColumn({ name: 'shipped_by', referencedColumnName: 'foodManufacturerId', }) - foodManufacturer: FoodManufacturer; + foodManufacturer?: FoodManufacturer; @Column({ name: 'shipped_by', nullable: true }) - shippedBy: number; + shippedBy?: number; @Column({ name: 'status', @@ -44,29 +44,46 @@ export class Order { enum: OrderStatus, default: OrderStatus.PENDING, }) - status: OrderStatus; + status!: OrderStatus; @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'NOW()', }) - createdAt: Date; + createdAt!: Date; @Column({ name: 'shipped_at', type: 'timestamp', nullable: true, }) - shippedAt: Date | null; + shippedAt?: Date; @Column({ name: 'delivered_at', type: 'timestamp', nullable: true, }) - deliveredAt: Date | null; + deliveredAt?: Date; @OneToMany(() => Allocation, (allocation) => allocation.order) - allocations: Allocation[]; + allocations!: Allocation[]; + + @Column({ + name: 'tracking_link', + type: 'varchar', + length: 255, + nullable: true, + }) + trackingLink?: string; + + @Column({ + name: 'shipping_cost', + type: 'numeric', + precision: 10, + scale: 2, + nullable: true, + }) + shippingCost?: number; } diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 2751af3d..4948f2fd 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Order } from './order.entity'; @@ -7,6 +11,7 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { validateId } from '../utils/validation.utils'; import { OrderStatus } from './types'; +import { TrackingCostDto } from './dtos/tracking-cost.dto'; @Injectable() export class OrdersService { @@ -124,6 +129,11 @@ export class OrdersService { if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } + if (!order.foodManufacturer) { + throw new NotFoundException( + `Order ${orderId} does not have a food manufacturer assigned`, + ); + } return order.foodManufacturer; } @@ -137,8 +147,9 @@ export class OrdersService { .set({ status: newStatus as OrderStatus, shippedBy: 1, - shippedAt: newStatus === OrderStatus.SHIPPED ? new Date() : null, - deliveredAt: newStatus === OrderStatus.DELIVERED ? new Date() : null, + shippedAt: newStatus === OrderStatus.SHIPPED ? new Date() : undefined, + deliveredAt: + newStatus === OrderStatus.DELIVERED ? new Date() : undefined, }) .where('order_id = :orderId', { orderId }) .execute(); @@ -159,4 +170,49 @@ export class OrdersService { return orders; } + + async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { + validateId(orderId, 'Order'); + if (!dto.trackingLink && !dto.shippingCost) { + throw new BadRequestException( + 'At least one of tracking link or shipping cost must be provided', + ); + } + + const order = await this.repo.findOneBy({ orderId }); + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + const isFirstTimeSetting = !order.trackingLink && !order.shippingCost; + + if (isFirstTimeSetting && (!dto.trackingLink || !dto.shippingCost)) { + throw new BadRequestException( + 'Must provide both tracking link and shipping cost on initial assignment', + ); + } + + if ( + order.status !== OrderStatus.SHIPPED && + order.status !== OrderStatus.PENDING + ) { + throw new BadRequestException( + 'Can only update tracking info for pending or shipped orders', + ); + } + + if (dto.trackingLink) order.trackingLink = dto.trackingLink; + if (dto.shippingCost) order.shippingCost = dto.shippingCost; + + if ( + order.status === OrderStatus.PENDING && + order.trackingLink && + order.shippingCost + ) { + order.status = OrderStatus.SHIPPED; + order.shippedAt = new Date(); + } + + await this.repo.save(order); + } } diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 4b6d5052..be0b33b5 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -193,12 +193,15 @@ export interface Order { orderId: number; request: FoodRequest; requestId: number; - foodManufacturer: FoodManufacturer | null; - shippedBy: number | null; + foodManufacturer?: FoodManufacturer; + shippedBy?: number; status: OrderStatus; createdAt: string; - shippedAt: string | null; - deliveredAt: string | null; + shippedAt?: Date; + deliveredAt?: Date; + allocations: Allocation[]; + trackingLink?: string; + shippingCost?: number; } export interface OrderItemDetails {