diff --git a/apps/backend/src/aws/aws-s3.service.ts b/apps/backend/src/aws/aws-s3.service.ts index 045038d52..c588a52f7 100644 --- a/apps/backend/src/aws/aws-s3.service.ts +++ b/apps/backend/src/aws/aws-s3.service.ts @@ -1,6 +1,15 @@ import { Injectable } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +interface MulterFile { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; +} + @Injectable() export class AWSS3Service { private client: S3Client; @@ -22,7 +31,7 @@ export class AWSS3Service { }); } - async upload(files: Express.Multer.File[]): Promise { + async upload(files: MulterFile[]): Promise { const uploadedFileUrls: string[] = []; try { for (const file of files) { diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 9573736c8..e7a9739ce 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -25,6 +25,7 @@ import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-R import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders'; +import { MoveRequestFieldsToOrders1770571145350 } from '../migrations/1770571145350-MoveRequestFieldsToOrders'; const schemaMigrations = [ User1725726359198, @@ -54,6 +55,7 @@ const schemaMigrations = [ RemoveUnusedStatuses1764816885341, PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, + MoveRequestFieldsToOrders1770571145350, ]; export default schemaMigrations; diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 45b0d2d28..46772a4c7 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -2,19 +2,13 @@ import { RequestsService } from './request.service'; import { RequestsController } from './request.controller'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; -import { AWSS3Service } from '../aws/aws-s3.service'; -import { OrdersService } from '../orders/order.service'; -import { Readable } from 'stream'; import { FoodRequest } from './request.entity'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { FoodType } from '../donationItems/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; -import { Order } from '../orders/order.entity'; const mockRequestsService = mock(); -const mockOrdersService = mock(); -const mockAWSS3Service = mock(); const foodRequest: Partial = { requestId: 1, @@ -28,10 +22,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockReset(); mockRequestsService.find.mockReset(); mockRequestsService.create.mockReset(); - mockRequestsService.updateDeliveryDetails?.mockReset(); mockRequestsService.getOrderDetails.mockReset(); - mockAWSS3Service.upload.mockReset(); - mockOrdersService.updateStatus.mockReset(); const module: TestingModule = await Test.createTestingModule({ controllers: [RequestsController], @@ -40,14 +31,6 @@ describe('RequestsController', () => { provide: RequestsService, useValue: mockRequestsService, }, - { - provide: OrdersService, - useValue: mockOrdersService, - }, - { - provide: AWSS3Service, - useValue: mockAWSS3Service, - }, ], }).compile(); @@ -151,9 +134,6 @@ describe('RequestsController', () => { requestedSize: RequestSize.MEDIUM, requestedItems: ['Test item 1', 'Test item 2'], additionalInformation: 'Test information.', - dateReceived: null, - feedback: null, - photos: null, }; const createdRequest: Partial = { @@ -175,209 +155,7 @@ describe('RequestsController', () => { createBody.requestedSize, createBody.requestedItems, createBody.additionalInformation, - createBody.dateReceived, - createBody.feedback, - createBody.photos, - ); - }); - }); - - describe('POST /:requestId/confirm-delivery', () => { - it('should upload photos, update the order, then update the request', async () => { - const requestId = 1; - - const body = { - dateReceived: new Date().toISOString(), - feedback: 'Nice delivery!', - }; - - // Mock Photos - const mockStream = new Readable(); - mockStream._read = () => {}; - - const photos: Express.Multer.File[] = [ - { - fieldname: 'photos', - originalname: 'photo1.jpg', - encoding: '7bit', - mimetype: 'image/jpeg', - buffer: Buffer.from('image1'), - size: 1000, - destination: '', - filename: '', - path: '', - stream: mockStream, - }, - { - fieldname: 'photos', - originalname: 'photo2.jpg', - encoding: '7bit', - mimetype: 'image/jpeg', - buffer: Buffer.from('image2'), - size: 2000, - destination: '', - filename: '', - path: '', - stream: mockStream, - }, - ]; - - const uploadedUrls = [ - 'https://fake-s3/photo1.jpg', - 'https://fake-s3/photo2.jpg', - ]; - - // Mock AWS upload - mockAWSS3Service.upload.mockResolvedValue(uploadedUrls); - - // Mock RequestsService.findOne - mockRequestsService.findOne.mockResolvedValue({ - requestId, - pantryId: 1, - orders: [{ orderId: 99 }], - } as FoodRequest); - - mockOrdersService.updateStatus.mockResolvedValue(); - - const order = new Order(); - order.orderId = 99; - - const updatedRequest: Partial = { - requestId, - pantryId: 1, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: uploadedUrls, - orders: [order], - }; - - mockRequestsService.updateDeliveryDetails.mockResolvedValue( - updatedRequest as FoodRequest, ); - - const result = await controller.confirmDelivery(requestId, body, photos); - - expect(mockAWSS3Service.upload).toHaveBeenCalledWith(photos); - - expect(mockOrdersService.updateStatus).toHaveBeenCalledWith( - 99, - OrderStatus.DELIVERED, - ); - - expect(mockRequestsService.updateDeliveryDetails).toHaveBeenCalledWith( - requestId, - new Date(body.dateReceived), - body.feedback, - uploadedUrls, - ); - - expect(result).toEqual(updatedRequest); - }); - - it('should handle no photos being uploaded', async () => { - const requestId = 1; - - const body = { - dateReceived: new Date().toISOString(), - feedback: 'No photos delivery!', - }; - - mockRequestsService.findOne.mockResolvedValue({ - requestId, - pantryId: 1, - orders: [{ orderId: 100 }], - } as FoodRequest); - - mockOrdersService.updateStatus.mockResolvedValue(); - - const order = new Order(); - order.orderId = 100; - - const updatedRequest: Partial = { - requestId, - pantryId: 1, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: [], - orders: [order], - }; - - mockRequestsService.updateDeliveryDetails.mockResolvedValue( - updatedRequest as FoodRequest, - ); - - const result = await controller.confirmDelivery(requestId, body); - - expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); - expect(mockOrdersService.updateStatus).toHaveBeenCalledWith( - 100, - OrderStatus.DELIVERED, - ); - expect(mockRequestsService.updateDeliveryDetails).toHaveBeenCalledWith( - requestId, - new Date(body.dateReceived), - body.feedback, - [], - ); - expect(result).toEqual(updatedRequest); - }); - - it('should handle empty photos array', async () => { - const requestId = 1; - - const body = { - dateReceived: new Date().toISOString(), - feedback: 'Empty photos array delivery!', - }; - - mockRequestsService.findOne.mockResolvedValue({ - requestId, - pantryId: 1, - orders: [{ orderId: 101 }], - } as FoodRequest); - - mockOrdersService.updateStatus.mockResolvedValue(); - - const order = new Order(); - order.orderId = 101; - - const updatedRequest: Partial = { - requestId, - pantryId: 1, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: [], - orders: [order], - }; - - mockRequestsService.updateDeliveryDetails.mockResolvedValue( - updatedRequest as FoodRequest, - ); - - const result = await controller.confirmDelivery(requestId, body, []); - - expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); - expect(mockOrdersService.updateStatus).toHaveBeenCalledWith( - 101, - OrderStatus.DELIVERED, - ); - expect(mockRequestsService.updateDeliveryDetails).toHaveBeenCalledWith( - requestId, - new Date(body.dateReceived), - body.feedback, - [], - ); - expect(result).toEqual(updatedRequest); - }); - - it('should throw an error for invalid date', async () => { - await expect( - controller.confirmDelivery( - 1, - { dateReceived: 'bad-date', feedback: '' }, - [], - ), - ).rejects.toThrow('Invalid date format for deliveryDate'); }); }); }); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index ec6dc0f04..167734ea0 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -5,30 +5,18 @@ import { ParseIntPipe, Post, Body, - UploadedFiles, - UseInterceptors, BadRequestException, - NotFoundException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { RequestsService } from './request.service'; import { FoodRequest } from './request.entity'; -import { AWSS3Service } from '../aws/aws-s3.service'; -import { FilesInterceptor } from '@nestjs/platform-express'; -import * as multer from 'multer'; -import { OrdersService } from '../orders/order.service'; import { RequestSize } from './types'; -import { OrderStatus } from '../orders/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; @Controller('requests') // @UseInterceptors() export class RequestsController { - constructor( - private requestsService: RequestsService, - private awsS3Service: AWSS3Service, - private ordersService: OrdersService, - ) {} + constructor(private requestsService: RequestsService) {} @Get('/:requestId') async getRequest( @@ -73,19 +61,6 @@ export class RequestsController { nullable: true, example: 'Urgent request', }, - dateReceived: { - type: 'string', - format: 'date-time', - nullable: true, - example: null, - }, - feedback: { type: 'string', nullable: true, example: null }, - photos: { - type: 'array', - items: { type: 'string' }, - nullable: true, - example: [], - }, }, }, }) @@ -96,9 +71,6 @@ export class RequestsController { requestedSize: RequestSize; requestedItems: string[]; additionalInformation: string; - dateReceived: Date; - feedback: string; - photos: string[]; }, ): Promise { if ( @@ -111,84 +83,6 @@ export class RequestsController { body.requestedSize, body.requestedItems, body.additionalInformation, - body.dateReceived, - body.feedback, - body.photos, - ); - } - - //TODO: delete endpoint, here temporarily as a logic reference for order status impl. - @Post('/:requestId/confirm-delivery') - @ApiBody({ - description: 'Details for a confirmation form', - schema: { - type: 'object', - properties: { - dateReceived: { - type: 'string', - format: 'date-time', - nullable: true, - example: new Date().toISOString(), - }, - feedback: { - type: 'string', - nullable: true, - example: 'Wonderful shipment!', - }, - photos: { - type: 'array', - items: { type: 'string' }, - nullable: true, - example: [], - }, - }, - }, - }) - @UseInterceptors( - FilesInterceptor('photos', 10, { storage: multer.memoryStorage() }), - ) - async confirmDelivery( - @Param('requestId', ParseIntPipe) requestId: number, - @Body() body: { dateReceived: string; feedback: string }, - @UploadedFiles() photos?: Express.Multer.File[], - ): Promise { - const formattedDate = new Date(body.dateReceived); - if (isNaN(formattedDate.getTime())) { - throw new Error('Invalid date format for deliveryDate'); - } - - const uploadedPhotoUrls = - photos && photos.length > 0 ? await this.awsS3Service.upload(photos) : []; - console.log( - 'Received photo files:', - photos?.map((p) => p.originalname), - '| Count:', - photos?.length, - ); - - const updatedRequest = await this.requestsService.updateDeliveryDetails( - requestId, - formattedDate, - body.feedback, - uploadedPhotoUrls, - ); - - if (!updatedRequest) { - throw new NotFoundException('Invalid request ID'); - } - - if (!updatedRequest.orders || updatedRequest.orders.length == 0) { - throw new NotFoundException( - 'No associated orders found for this request', - ); - } - - await Promise.all( - updatedRequest.orders.map((order) => - this.ordersService.updateStatus(order.orderId, OrderStatus.DELIVERED), - ), ); - - return updatedRequest; } } diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts index 25ba8e66b..8014c04a7 100644 --- a/apps/backend/src/foodRequests/request.entity.ts +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -8,7 +8,7 @@ import { JoinColumn, } from 'typeorm'; import { Order } from '../orders/order.entity'; -import { RequestSize } from './types'; +import { RequestSize, FoodRequestStatus } from './types'; import { Pantry } from '../pantries/pantries.entity'; @Entity('food_requests') @@ -44,14 +44,14 @@ export class FoodRequest { }) requestedAt: Date; - @Column({ name: 'date_received', type: 'timestamp', nullable: true }) - dateReceived: Date; - - @Column({ name: 'feedback', type: 'text', nullable: true }) - feedback: string; - - @Column({ name: 'photos', type: 'text', array: true, nullable: true }) - photos: string[]; + @Column({ + name: 'status', + type: 'enum', + enumName: 'food_requests_status_enum', + enum: FoodRequestStatus, + default: FoodRequestStatus.ACTIVE, + }) + status: FoodRequestStatus; @OneToMany(() => Order, (order) => order.request, { nullable: true }) orders: Order[]; diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index 14a605d80..756c63d9a 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -5,19 +5,12 @@ import { FoodRequest } from './request.entity'; import { RequestsService } from './request.service'; import { JwtStrategy } from '../auth/jwt.strategy'; import { AuthService } from '../auth/auth.service'; -import { AWSS3Module } from '../aws/aws-s3.module'; -import { MulterModule } from '@nestjs/platform-express'; -import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; @Module({ - imports: [ - AWSS3Module, - MulterModule.register({ dest: './uploads' }), - TypeOrmModule.forFeature([FoodRequest, Order, Pantry]), - ], + imports: [TypeOrmModule.forFeature([FoodRequest, Order, Pantry])], controllers: [RequestsController], - providers: [RequestsService, OrdersService, AuthService, JwtStrategy], + providers: [RequestsService, AuthService, JwtStrategy], }) export class RequestsModule {} diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 23e07c877..077e98961 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -23,9 +23,6 @@ const mockRequest: Partial = { requestedItems: ['Canned Goods', 'Vegetables'], additionalInformation: 'No onions, please.', requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, orders: null, }; @@ -250,9 +247,6 @@ describe('RequestsService', () => { mockRequest.requestedSize, mockRequest.requestedItems, mockRequest.additionalInformation, - mockRequest.dateReceived, - mockRequest.feedback, - mockRequest.photos, ); expect(result).toEqual(mockRequest); @@ -261,9 +255,6 @@ describe('RequestsService', () => { requestedSize: mockRequest.requestedSize, requestedItems: mockRequest.requestedItems, additionalInformation: mockRequest.additionalInformation, - dateReceived: mockRequest.dateReceived, - feedback: mockRequest.feedback, - photos: mockRequest.photos, }); expect(mockRequestsRepository.save).toHaveBeenCalledWith(mockRequest); }); @@ -277,9 +268,6 @@ describe('RequestsService', () => { RequestSize.MEDIUM, ['Canned Goods', 'Vegetables'], 'Additional info', - null, - null, - null, ), ).rejects.toThrow(`Pantry ${invalidPantryId} not found`); @@ -299,9 +287,6 @@ describe('RequestsService', () => { requestedItems: ['Rice', 'Beans'], additionalInformation: 'Gluten-free items only.', requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, orders: null, }, { @@ -311,9 +296,6 @@ describe('RequestsService', () => { requestedItems: ['Fruits', 'Snacks'], additionalInformation: 'No nuts, please.', requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, orders: null, }, ]; @@ -331,163 +313,4 @@ describe('RequestsService', () => { }); }); }); - - describe('updateDeliveryDetails', () => { - it('should update and return the food request with new delivery details', async () => { - const mockOrder: Partial = { - orderId: 1, - request: null, - requestId: 1, - foodManufacturer: null, - shippedBy: 1, - status: OrderStatus.SHIPPED, - createdAt: new Date(), - shippedAt: new Date(), - deliveredAt: null, - }; - - const mockRequest2: Partial = { - ...mockRequest, - orders: [mockOrder] as Order[], - }; - - const requestId = 1; - const deliveryDate = new Date(); - const feedback = 'Good delivery!'; - const photos = ['photo1.jpg', 'photo2.jpg']; - - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest2 as FoodRequest, - ); - - const updatedOrder = { ...mockOrder, status: OrderStatus.DELIVERED }; - - mockRequestsRepository.save.mockResolvedValueOnce({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [updatedOrder], - } as FoodRequest); - - const result = await service.updateDeliveryDetails( - requestId, - deliveryDate, - feedback, - photos, - ); - - expect(result).toEqual({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [updatedOrder], - }); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); - - expect(mockRequestsRepository.save).toHaveBeenCalledWith({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [mockOrder], - }); - }); - - it('should throw an error if the request ID is invalid', async () => { - const requestId = 999; - const deliveryDate = new Date(); - const feedback = 'Good delivery!'; - const photos = ['photo1.jpg', 'photo2.jpg']; - - mockRequestsRepository.findOne.mockResolvedValueOnce(null); - - await expect( - service.updateDeliveryDetails( - requestId, - deliveryDate, - feedback, - photos, - ), - ).rejects.toThrow('Invalid request ID'); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); - }); - - it('should throw an error if there is no associated order', async () => { - const requestId = 1; - const deliveryDate = new Date(); - const feedback = 'Good delivery!'; - const photos = ['photo1.jpg', 'photo2.jpg']; - - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest as FoodRequest, - ); - - await expect( - service.updateDeliveryDetails( - requestId, - deliveryDate, - feedback, - photos, - ), - ).rejects.toThrow('No associated orders found for this request'); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); - }); - - it('should throw an error if the order does not have a food manufacturer', async () => { - const mockOrder: Partial = { - orderId: 1, - request: null, - requestId: 1, - foodManufacturer: null, - shippedBy: null, - status: OrderStatus.SHIPPED, - createdAt: new Date(), - shippedAt: new Date(), - deliveredAt: null, - }; - const mockRequest2: Partial = { - ...mockRequest, - orders: [mockOrder] as Order[], - }; - - const requestId = 1; - const deliveryDate = new Date(); - const feedback = 'Good delivery!'; - const photos = ['photo1.jpg', 'photo2.jpg']; - - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest2 as FoodRequest, - ); - - await expect( - service.updateDeliveryDetails( - requestId, - deliveryDate, - feedback, - photos, - ), - ).rejects.toThrow( - 'No associated food manufacturer found for an associated order', - ); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); - }); - }); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 80093c583..c3ebcb729 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -78,9 +78,6 @@ export class RequestsService { requestedSize: RequestSize, requestedItems: string[], additionalInformation: string | undefined, - dateReceived: Date | undefined, - feedback: string | undefined, - photos: string[] | undefined, ): Promise { validateId(pantryId, 'Pantry'); @@ -94,9 +91,6 @@ export class RequestsService { requestedSize, requestedItems, additionalInformation, - dateReceived, - feedback, - photos, }); return await this.repo.save(foodRequest); @@ -110,44 +104,4 @@ export class RequestsService { relations: ['orders'], }); } - - async updateDeliveryDetails( - requestId: number, - deliveryDate: Date, - feedback: string, - photos: string[], - ): Promise { - validateId(requestId, 'Request'); - - const request = await this.repo.findOne({ - where: { requestId }, - relations: ['orders'], - }); - - if (!request) { - throw new NotFoundException('Invalid request ID'); - } - - if (!request.orders || request.orders.length == 0) { - throw new NotFoundException( - 'No associated orders found for this request', - ); - } - - const orders = request.orders; - - for (const order of orders) { - if (!order.shippedBy) { - throw new NotFoundException( - 'No associated food manufacturer found for an associated order', - ); - } - } - - request.feedback = feedback; - request.dateReceived = deliveryDate; - request.photos = photos; - - return await this.repo.save(request); - } } diff --git a/apps/backend/src/foodRequests/types.ts b/apps/backend/src/foodRequests/types.ts index 1057eef84..ba0378451 100644 --- a/apps/backend/src/foodRequests/types.ts +++ b/apps/backend/src/foodRequests/types.ts @@ -4,3 +4,8 @@ export enum RequestSize { MEDIUM = 'Medium (5-10 boxes)', LARGE = 'Large (10+ boxes)', } + +export enum FoodRequestStatus { + ACTIVE = 'active', + CLOSED = 'closed', +} diff --git a/apps/backend/src/migrations/1770571145350-MoveRequestFieldsToOrders.ts b/apps/backend/src/migrations/1770571145350-MoveRequestFieldsToOrders.ts new file mode 100644 index 000000000..566edd093 --- /dev/null +++ b/apps/backend/src/migrations/1770571145350-MoveRequestFieldsToOrders.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveRequestFieldsToOrders1770571145350 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE food_requests_status_enum AS ENUM ('active', 'closed'); + `); + + await queryRunner.query(` + ALTER TABLE food_requests + ADD COLUMN status food_requests_status_enum NOT NULL DEFAULT 'active', + DROP COLUMN date_received, + DROP COLUMN feedback, + DROP COLUMN photos; + `); + + await queryRunner.query(` + ALTER TABLE orders + ADD COLUMN date_received TIMESTAMP, + ADD COLUMN feedback TEXT, + ADD COLUMN photos TEXT[]; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + DROP COLUMN photos, + DROP COLUMN feedback, + DROP COLUMN date_received; + `); + + await queryRunner.query(` + ALTER TABLE food_requests + ADD COLUMN date_received TIMESTAMP, + ADD COLUMN feedback TEXT, + ADD COLUMN photos TEXT[], + DROP COLUMN status; + `); + + await queryRunner.query(` + DROP TYPE food_requests_status_enum; + `); + } +} diff --git a/apps/backend/src/orders/dtos/confirm-delivery.dto.ts b/apps/backend/src/orders/dtos/confirm-delivery.dto.ts new file mode 100644 index 000000000..e54703c66 --- /dev/null +++ b/apps/backend/src/orders/dtos/confirm-delivery.dto.ts @@ -0,0 +1,5 @@ +export class ConfirmDeliveryDto { + dateReceived: string; + feedback: string; + photos?: Express.Multer.File[]; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index e2e448185..30a20b175 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -8,9 +8,11 @@ import { mock } from 'jest-mock-extended'; import { OrderStatus } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { AWSS3Service } from '../aws/aws-s3.service'; const mockOrdersService = mock(); const mockAllocationsService = mock(); +const mockAWSS3Service = mock(); describe('OrdersController', () => { let controller: OrdersController; @@ -57,6 +59,7 @@ describe('OrdersController', () => { providers: [ { provide: OrdersService, useValue: mockOrdersService }, { provide: AllocationsService, useValue: mockAllocationsService }, + { provide: AWSS3Service, useValue: mockAWSS3Service }, ], }).compile(); @@ -100,4 +103,129 @@ describe('OrdersController', () => { ).toHaveBeenCalledWith(orderId); }); }); + + describe('confirmDelivery', () => { + beforeEach(() => { + mockAWSS3Service.upload.mockReset(); + mockOrdersService.confirmDelivery.mockReset(); + }); + + it('should upload photos and confirm delivery with all fields', async () => { + const orderId = 1; + const body = { + dateReceived: new Date().toISOString(), + feedback: 'Great delivery!', + }; + const mockFiles = [ + { + fieldname: 'photos', + originalname: 'photo1.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + buffer: Buffer.from('photo1'), + size: 1000, + }, + ] as Express.Multer.File[]; + + const uploadedUrls = ['https://s3.example.com/photo1.jpg']; + mockAWSS3Service.upload.mockResolvedValueOnce(uploadedUrls); + + const confirmedOrder: Partial = { + orderId, + status: OrderStatus.DELIVERED, + dateReceived: new Date(body.dateReceived), + feedback: body.feedback, + photos: uploadedUrls, + }; + mockOrdersService.confirmDelivery.mockResolvedValueOnce( + confirmedOrder as Order, + ); + + const result = await controller.confirmDelivery(orderId, body, mockFiles); + + expect(mockAWSS3Service.upload).toHaveBeenCalledWith(mockFiles); + expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( + orderId, + new Date(body.dateReceived), + body.feedback, + uploadedUrls, + ); + expect(result).toEqual(confirmedOrder); + }); + + it('should handle no photos being uploaded', async () => { + const orderId = 2; + const body = { + dateReceived: new Date().toISOString(), + feedback: 'Delivery without photos', + }; + + const confirmedOrder: Partial = { + orderId, + status: OrderStatus.DELIVERED, + dateReceived: new Date(body.dateReceived), + feedback: body.feedback, + photos: [], + }; + mockOrdersService.confirmDelivery.mockResolvedValueOnce( + confirmedOrder as Order, + ); + + const result = await controller.confirmDelivery(orderId, body); + + expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); + expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( + orderId, + new Date(body.dateReceived), + body.feedback, + [], + ); + expect(result).toEqual(confirmedOrder); + }); + + it('should handle empty photos array', async () => { + const orderId = 3; + const body = { + dateReceived: new Date().toISOString(), + feedback: 'Empty photos', + }; + + const confirmedOrder: Partial = { + orderId, + status: OrderStatus.DELIVERED, + dateReceived: new Date(body.dateReceived), + feedback: body.feedback, + photos: [], + }; + mockOrdersService.confirmDelivery.mockResolvedValueOnce( + confirmedOrder as Order, + ); + + const result = await controller.confirmDelivery(orderId, body, []); + + expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); + expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( + orderId, + new Date(body.dateReceived), + body.feedback, + [], + ); + expect(result).toEqual(confirmedOrder); + }); + + it('should throw BadRequestException for invalid date format', async () => { + const orderId = 1; + const body = { + dateReceived: 'invalid-date', + feedback: 'test', + }; + + await expect(controller.confirmDelivery(orderId, body)).rejects.toThrow( + 'Invalid date format for dateReceived', + ); + + expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); + expect(mockOrdersService.confirmDelivery).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 870dc1eff..0fb27bc77 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -1,13 +1,17 @@ import { Controller, Get, + Post, Patch, Param, ParseIntPipe, Body, Query, BadRequestException, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; +import { ApiBody } from '@nestjs/swagger'; import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; @@ -15,12 +19,17 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; +import { AWSS3Service } from '../aws/aws-s3.service'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import * as multer from 'multer'; +import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; @Controller('orders') export class OrdersController { constructor( private readonly ordersService: OrdersService, private readonly allocationsService: AllocationsService, + private readonly awsS3Service: AWSS3Service, ) {} // Called like: /?status=pending&pantryName=Test%20Pantry&pantryName=Test%20Pantry%202 @@ -99,4 +108,56 @@ export class OrdersController { } return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); } + + @Post('/:orderId/confirm-delivery') + @ApiBody({ + description: 'Details for a confirmation form', + schema: { + type: 'object', + properties: { + dateReceived: { + type: 'string', + format: 'date-time', + nullable: true, + example: new Date().toISOString(), + }, + feedback: { + type: 'string', + nullable: true, + example: 'Wonderful shipment!', + }, + photos: { + type: 'array', + items: { type: 'string' }, + nullable: true, + example: [], + }, + }, + }, + }) + @UseInterceptors( + FilesInterceptor('photos', 10, { storage: multer.memoryStorage() }), + ) + async confirmDelivery( + @Param('orderId', ParseIntPipe) orderId: number, + @Body() body: ConfirmDeliveryDto, + @UploadedFiles() photos?: Express.Multer.File[], + ): Promise { + body.photos = photos; + + const formattedDate = new Date(body.dateReceived); + if (isNaN(formattedDate.getTime())) { + throw new BadRequestException('Invalid date format for dateReceived'); + } + + const uploadedPhotoUrls = + photos && photos.length > 0 ? await this.awsS3Service.upload(photos) : []; + + return this.ordersService.confirmDelivery( + orderId, + formattedDate, + body.feedback, + uploadedPhotoUrls, + ); + } } diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 9a246d0c9..b3604ad5a 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -67,6 +67,15 @@ export class Order { }) deliveredAt: Date | null; + @Column({ name: 'date_received', type: 'timestamp', nullable: true }) + dateReceived?: Date | null; + + @Column({ name: 'feedback', type: 'text', nullable: true }) + feedback?: string | null; + + @Column({ name: 'photos', type: 'text', array: true, nullable: true }) + photos?: string[] | null; + @OneToMany(() => Allocation, (allocation) => allocation.order) allocations: Allocation[]; } diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 4937eced7..1bd82d9c9 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -7,9 +7,17 @@ import { JwtStrategy } from '../auth/jwt.strategy'; import { AuthService } from '../auth/auth.service'; import { Pantry } from '../pantries/pantries.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { AWSS3Module } from '../aws/aws-s3.module'; +import { MulterModule } from '@nestjs/platform-express'; @Module({ - imports: [TypeOrmModule.forFeature([Order, Pantry]), AllocationModule], + imports: [ + TypeOrmModule.forFeature([Order, Pantry, FoodRequest]), + AllocationModule, + AWSS3Module, + MulterModule.register({ dest: './uploads' }), + ], controllers: [OrdersController], providers: [OrdersService, AuthService, JwtStrategy], exports: [OrdersService], diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index e8e41949e..1652a4eec 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -5,6 +5,9 @@ import { Order } from './order.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { OrderStatus } from './types'; import { Pantry } from '../pantries/pantries.entity'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { Repository } from 'typeorm'; +import { FoodRequestStatus } from '../foodRequests/types'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); @@ -33,6 +36,10 @@ describe('OrdersService', () => { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, ], }).compile(); @@ -121,4 +128,149 @@ describe('OrdersService', () => { expect(orders[0].status).toBe(OrderStatus.DELIVERED); }); }); + + describe('confirmDelivery', () => { + it('should update order with delivery details and set status to delivered', async () => { + // Get an existing shipped order from dummy data + const orderRepo = testDataSource.getRepository(Order); + const requestRepo = testDataSource.getRepository(FoodRequest); + + const shippedOrder = await orderRepo.findOne({ + where: { status: OrderStatus.SHIPPED }, + relations: ['request'], + }); + + expect(shippedOrder).toBeDefined(); + + const dateReceived = new Date(); + const feedback = 'Perfect delivery!'; + const photos = ['photo1.jpg', 'photo2.jpg']; + + const result = await service.confirmDelivery( + shippedOrder.orderId, + dateReceived, + feedback, + photos, + ); + + expect(result.orderId).toBe(shippedOrder.orderId); + expect(result.status).toBe(OrderStatus.DELIVERED); + expect(result.dateReceived).toEqual(dateReceived); + expect(result.feedback).toBe(feedback); + expect(result.photos).toEqual(photos); + expect(result.deliveredAt).toBeNull(); + + // Verify request status was updated + const updatedRequest = await requestRepo.findOne({ + where: { requestId: shippedOrder.requestId }, + relations: ['orders'], + }); + + // Check if all orders for this request are delivered + const allDelivered = updatedRequest.orders.every( + (order) => order.status === OrderStatus.DELIVERED, + ); + + if (allDelivered) { + expect(updatedRequest.status).toBe(FoodRequestStatus.CLOSED); + } else { + expect(updatedRequest.status).toBe(FoodRequestStatus.ACTIVE); + } + }); + + it('should set request status to CLOSED when all orders are delivered', async () => { + const orderRepo = testDataSource.getRepository(Order); + const requestRepo = testDataSource.getRepository(FoodRequest); + + // Find a request with only one order that's shipped + const request = await requestRepo.findOne({ + where: { status: FoodRequestStatus.ACTIVE }, + relations: ['orders'], + }); + + // Find a shipped order for this request + const shippedOrder = request.orders.find( + (order) => order.status === OrderStatus.SHIPPED, + ); + + if (shippedOrder) { + // Mark all other orders as delivered first + for (const order of request.orders) { + if (order.orderId !== shippedOrder.orderId) { + order.status = OrderStatus.DELIVERED; + await orderRepo.save(order); + } + } + + // Now confirm the last shipped order + await service.confirmDelivery( + shippedOrder.orderId, + new Date(), + 'Final delivery', + [], + ); + + // Verify request is now closed + const updatedRequest = await requestRepo.findOne({ + where: { requestId: request.requestId }, + relations: ['orders'], + }); + + expect( + updatedRequest.orders.every( + (o) => o.status === OrderStatus.DELIVERED, + ), + ).toBe(true); + expect(updatedRequest.status).toBe(FoodRequestStatus.CLOSED); + } + }); + + it('should set request status to ACTIVE when not all orders are delivered', async () => { + const orderRepo = testDataSource.getRepository(Order); + const requestRepo = testDataSource.getRepository(FoodRequest); + + // Find a request with multiple orders + const request = await requestRepo.findOne({ + where: { status: FoodRequestStatus.ACTIVE }, + relations: ['orders'], + }); + + if (request && request.orders.length > 1) { + const shippedOrder = request.orders.find( + (order) => order.status === OrderStatus.SHIPPED, + ); + + if (shippedOrder) { + // Confirm only one order, leaving others undelivered + await service.confirmDelivery( + shippedOrder.orderId, + new Date(), + 'Partial delivery', + [], + ); + + // Verify request is still active + const updatedRequest = await requestRepo.findOne({ + where: { requestId: request.requestId }, + relations: ['orders'], + }); + + expect( + updatedRequest.orders.some( + (o) => o.status !== OrderStatus.DELIVERED, + ), + ).toBe(true); + expect(updatedRequest.status).toBe(FoodRequestStatus.ACTIVE); + } + } + }); + + it('should throw NotFoundException for invalid order id', async () => { + const invalidOrderId = 99999; + + await expect( + service.confirmDelivery(invalidOrderId, new Date(), 'test', []), + ).rejects.toThrow(`Order ${invalidOrderId} not found`); + }); + }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index fdce0ab38..4ad6833ed 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -5,6 +5,7 @@ import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { FoodRequest } from '../foodRequests/request.entity'; +import { FoodRequestStatus } from '../foodRequests/types'; import { validateId } from '../utils/validation.utils'; import { OrderStatus } from './types'; @@ -13,6 +14,8 @@ export class OrdersService { constructor( @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, + @InjectRepository(FoodRequest) + private requestRepo: Repository, ) {} async getAll(filters?: { status?: string; pantryNames?: string[] }) { @@ -144,6 +147,62 @@ export class OrdersService { .execute(); } + async confirmDelivery( + orderId: number, + dateReceived: Date, + feedback: string, + photos: string[], + ): Promise { + validateId(orderId, 'Order'); + + const order = await this.repo.findOne({ + where: { orderId }, + }); + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + order.dateReceived = dateReceived; + order.feedback = feedback; + order.photos = photos; + order.status = OrderStatus.DELIVERED; + + const updatedOrder = await this.repo.save(order); + + await this.updateRequestStatus(order.requestId); + + return updatedOrder; + } + + private async updateRequestStatus(requestId: number): Promise { + validateId(requestId, 'Request'); + + const request = await this.requestRepo.findOne({ + where: { requestId }, + relations: ['orders'], + }); + + if (!request) { + throw new NotFoundException(`Request ${requestId} not found`); + } + + const orders = request.orders || []; + if (!orders.length) { + throw new NotFoundException(`No orders found for request ${requestId}`); + } + + const allDelivered = orders.every( + (order) => order.status === OrderStatus.DELIVERED, + ); + + request.status = allDelivered + ? FoodRequestStatus.CLOSED + : FoodRequestStatus.ACTIVE; + + await this.requestRepo.save(request); + } + async getOrdersByPantry(pantryId: number): Promise { validateId(pantryId, 'Pantry'); diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 12fd07980..b4e33ec4c 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -180,13 +180,11 @@ export interface FoodRequest { requestId: number; pantryId: number; pantry: Pantry; - requestedSize: string; + requestedSize: RequestSize; requestedItems: string[]; additionalInformation: string | null; requestedAt: Date; - dateReceived: Date | null; - feedback: string | null; - photos: string[] | null; + status: FoodRequestStatus; orders?: Order[]; } @@ -200,6 +198,9 @@ export interface Order { createdAt: string; shippedAt: string | null; deliveredAt: string | null; + dateReceived?: string | null; + feedback?: string | null; + photos?: string[] | null; } export interface OrderItemDetails { @@ -223,14 +224,9 @@ export interface FoodManufacturer { export interface CreateFoodRequestBody { pantryId: number; - requestedSize: string; + requestedSize: RequestSize; requestedItems: string[]; - additionalInformation: string | null | undefined; - status: string; - fulfilledBy: number | null | undefined; - dateReceived: Date | null | undefined; - feedback: string | null | undefined; - photos: string[] | null | undefined; + additionalInformation?: string | null; } export interface CreateMultipleDonationItemsBody { @@ -275,6 +271,11 @@ export enum RequestSize { LARGE = 'Large (10+ boxes)', } +export enum FoodRequestStatus { + ACTIVE = 'active', + CLOSED = 'closed', +} + export enum DonationFrequency { YEARLY = 'yearly', BIWEEKLY = 'biweekly',