Skip to content
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,6 +57,7 @@ const schemaMigrations = [
PopulateDummyData1768501812134,
RemovePantryFromOrders1769316004958,
UpdateManufacturerEntity1768680807820,
UpdateOrderEntity1769990652833,
];

export default schemaMigrations;
24 changes: 24 additions & 0 deletions apps/backend/src/migrations/1769990652833-UpdateOrderEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class UpdateOrderEntity1769990652833 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE orders
DROP COLUMN IF EXISTS tracking_link,
DROP COLUMN IF EXISTS shipping_cost;
`);
}
}
15 changes: 15 additions & 0 deletions apps/backend/src/orders/dtos/tracking-cost.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions apps/backend/src/orders/order.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrdersService>();
const mockAllocationsService = mock<AllocationsService>();
Expand Down Expand Up @@ -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,
);
});
});
});
11 changes: 11 additions & 0 deletions apps/backend/src/orders/order.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Body,
Query,
BadRequestException,
ValidationPipe,
} from '@nestjs/common';
import { OrdersService } from './order.service';
import { Order } from './order.entity';
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
return this.ordersService.updateTrackingCostInfo(orderId, dto);
}
}
39 changes: 28 additions & 11 deletions apps/backend/src/orders/order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}
62 changes: 59 additions & 3 deletions apps/backend/src/orders/order.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand All @@ -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);
}
}
11 changes: 7 additions & 4 deletions apps/frontend/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down