diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 8fc31d76..90ece797 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; - +import { RequestsModule } from './foodRequests/request.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; @@ -14,7 +14,6 @@ import typeorm from './config/typeorm'; isGlobal: true, load: [typeorm], }), - // Load TypeORM config async so we can target the config file (config/typeorm.ts) for migrations TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => @@ -22,6 +21,7 @@ import typeorm from './config/typeorm'; }), UsersModule, AuthModule, + RequestsModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 91ad5508..39d107ee 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -1,11 +1,4 @@ -import { - BadRequestException, - Body, - Controller, - Post, - Request, - UseGuards, -} from '@nestjs/common'; +import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; import { SignInDto } from './dtos/sign-in.dto'; import { SignUpDto } from './dtos/sign-up.dto'; @@ -16,7 +9,6 @@ import { DeleteUserDto } from './dtos/delete-user.dto'; import { User } from '../users/user.entity'; import { SignInResponseDto } from './dtos/sign-in-response.dto'; import { RefreshTokenDto } from './dtos/refresh-token.dto'; -import { AuthGuard } from '@nestjs/passport'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; import { ForgotPasswordDto } from './dtos/forgot-password.dto'; diff --git a/apps/backend/src/aws/aws-s3.module.ts b/apps/backend/src/aws/aws-s3.module.ts new file mode 100644 index 00000000..bcb05aca --- /dev/null +++ b/apps/backend/src/aws/aws-s3.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { AWSS3Service } from './aws-s3.service'; + +@Global() +@Module({ + imports: [], + providers: [AWSS3Service], + exports: [AWSS3Service], +}) +export class AWSS3Module {} diff --git a/apps/backend/src/aws/aws-s3.service.ts b/apps/backend/src/aws/aws-s3.service.ts new file mode 100644 index 00000000..69b2e3e6 --- /dev/null +++ b/apps/backend/src/aws/aws-s3.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +@Injectable() +export class AWSS3Service { + private client: S3Client; + private readonly bucket: string; + private readonly region: string; + + constructor() { + this.region = process.env.AWS_REGION || 'us-east-2'; + this.bucket = process.env.AWS_BUCKET_NAME; + if (!this.bucket) { + throw new Error('AWS_BUCKET_NAME is not defined'); + } + this.client = new S3Client({ + region: this.region, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + async upload(files: Express.Multer.File[]): Promise { + const uploadedFileUrls: string[] = []; + try { + for (const file of files) { + const fileName = file.originalname; + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: fileName, + Body: file.buffer, + ContentType: file.mimetype || 'application/octet-stream', + }); + + await this.client.send(command); + + const url = `https://${this.bucket}.s3.${this.region}.amazonaws.com/${fileName}`; + uploadedFileUrls.push(url); + } + return uploadedFileUrls; + } catch (error) { + throw new Error('File upload to AWS failed'); + } + } +} diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts new file mode 100644 index 00000000..7c763daf --- /dev/null +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + Post, + Body, + UploadedFiles, + UseInterceptors, +} 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'; + +@Controller('requests') +// @UseInterceptors() +export class FoodRequestsController { + constructor( + private requestsService: RequestsService, + private awsS3Service: AWSS3Service, + ) {} + + @Get('/:pantryId') + async getAllPantryRequests( + @Param('pantryId', ParseIntPipe) pantryId: number, + ): Promise { + return this.requestsService.find(pantryId); + } + + @Post('/create') + @ApiBody({ + description: 'Details for creating a food request', + schema: { + type: 'object', + properties: { + pantryId: { type: 'integer', example: 1 }, + requestedSize: { type: 'string', example: 'Medium (5-10 boxes)' }, + requestedItems: { + type: 'array', + items: { type: 'string' }, + example: ['Rice Noodles', 'Quinoa'], + }, + additionalInformation: { + type: 'string', + nullable: true, + example: 'Urgent request', + }, + status: { type: 'string', example: 'pending' }, + fulfilledBy: { type: 'integer', nullable: true, example: null }, + 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: [], + }, + }, + }, + }) + async createRequest( + @Body() + body: { + pantryId: number; + requestedSize: string; + requestedItems: string[]; + additionalInformation: string; + status: string; + fulfilledBy: number; + dateReceived: Date; + feedback: string; + photos: string[]; + }, + ): Promise { + return this.requestsService.create( + body.pantryId, + body.requestedSize, + body.requestedItems, + body.additionalInformation, + body.status, + body.fulfilledBy, + body.dateReceived, + body.feedback, + body.photos, + ); + } + + @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) : []; + + return this.requestsService.updateDeliveryDetails( + requestId, + formattedDate, + body.feedback, + uploadedPhotoUrls, + ); + } +} diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts new file mode 100644 index 00000000..46a77811 --- /dev/null +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; + +@Entity('food_requests') +export class FoodRequest { + @PrimaryGeneratedColumn({ name: 'request_id' }) + requestId: number; + + @Column({ name: 'pantry_id', type: 'int' }) + pantryId: number; + + @Column({ name: 'requested_size', type: 'varchar', length: 50 }) + requestedSize: string; + + @Column({ name: 'requested_items', type: 'text', array: true }) + requestedItems: string[]; + + @Column({ name: 'additional_information', type: 'text', nullable: true }) + additionalInformation: string; + + @CreateDateColumn({ + name: 'requested_at', + type: 'timestamp', + default: () => 'NOW()', + }) + requestedAt: Date; + + @Column({ name: 'status', type: 'varchar', length: 25, default: 'pending' }) + status: string; + + @Column({ name: 'fulfilled_by', type: 'int', nullable: true }) + fulfilledBy: number; + + @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[]; +} diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts new file mode 100644 index 00000000..ed868bbb --- /dev/null +++ b/apps/backend/src/foodRequests/request.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FoodRequestsController } from './request.controller'; +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'; + +@Module({ + imports: [ + AWSS3Module, + MulterModule.register({ dest: './uploads' }), + TypeOrmModule.forFeature([FoodRequest]), + ], + controllers: [FoodRequestsController], + providers: [RequestsService, AuthService, JwtStrategy], +}) +export class RequestsModule {} diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts new file mode 100644 index 00000000..6423a37e --- /dev/null +++ b/apps/backend/src/foodRequests/request.service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FoodRequest } from './request.entity'; + +@Injectable() +export class RequestsService { + constructor( + @InjectRepository(FoodRequest) private repo: Repository, + ) {} + + async create( + pantryId: number, + requestedSize: string, + requestedItems: string[], + additionalInformation: string | null, + status: string = 'pending', + fulfilledBy: number | null, + dateReceived: Date | null, + feedback: string | null, + photos: string[] | null, + ) { + const foodRequest = this.repo.create({ + pantryId, + requestedSize, + requestedItems, + additionalInformation, + status, + fulfilledBy, + dateReceived, + feedback, + photos, + }); + + return await this.repo.save(foodRequest); + } + + async find(pantryId: number) { + if (!pantryId || pantryId < 1) { + throw new NotFoundException('Invalid pantry ID'); + } + return await this.repo.find({ where: { pantryId } }); + } + + async updateDeliveryDetails( + requestId: number, + deliveryDate: Date, + feedback: string, + photos: string[], + ): Promise { + const request = await this.repo.findOne({ where: { requestId } }); + + if (!request) { + throw new NotFoundException('Invalid request ID'); + } + + request.feedback = feedback; + request.dateReceived = deliveryDate; + request.photos = photos; + request.status = 'fulfilled'; + + return await this.repo.save(request); + } +} diff --git a/apps/backend/tsconfig.spec.json b/apps/backend/tsconfig.spec.json index 9b2a121d..a106a83c 100644 --- a/apps/backend/tsconfig.spec.json +++ b/apps/backend/tsconfig.spec.json @@ -9,6 +9,7 @@ "jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/types/**/*.d.ts" ] } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 19db5939..b79f6d46 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -4,13 +4,14 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; -import { submitFoodRequestForm } from '@components/forms/foodRequestForm'; -import RequestFood from '@containers/foodRequest'; import LandingPage from '@containers/landingPage'; import PantryOverview from '@containers/pantryOverview'; import PantryPastOrders from '@containers/pantryPastOrders'; import Pantries from '@containers/pantries'; import Orders from '@containers/orders'; +import { submitFoodRequestFormModal } from '@components/forms/requestFormModalButton'; +import { submitDeliveryConfirmationFormModal } from '@components/forms/deliveryConfirmationModalButton'; +import FormRequests from '@containers/FormRequests'; const router = createBrowserRouter([ { @@ -38,13 +39,20 @@ const router = createBrowserRouter([ path: '/orders', element: , }, + { + path: '/request-form/:pantryId', + element: , + }, + { + path: '/food-request', + action: submitFoodRequestFormModal, + }, + { + path: '/confirm-delivery', + action: submitDeliveryConfirmationFormModal, + }, ], }, - { - path: '/food-request', - element: , - action: submitFoodRequestForm, - }, ]); export const App: React.FC = () => { diff --git a/apps/frontend/src/components/forms/deliveryConfirmationModalButton.tsx b/apps/frontend/src/components/forms/deliveryConfirmationModalButton.tsx new file mode 100644 index 00000000..954b5b05 --- /dev/null +++ b/apps/frontend/src/components/forms/deliveryConfirmationModalButton.tsx @@ -0,0 +1,184 @@ +import { + Box, + FormControl, + FormLabel, + Input, + Button, + FormHelperText, + Textarea, + useDisclosure, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + HStack, + Text, +} from '@chakra-ui/react'; +import { Form, ActionFunction, ActionFunctionArgs } from 'react-router-dom'; + +interface DeliveryConfirmationModalButtonProps { + requestId: number; +} + +const photoNames: string[] = []; +const globalPhotos: File[] = []; + +const DeliveryConfirmationModalButton: React.FC< + DeliveryConfirmationModalButtonProps +> = ({ requestId }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + const handlePhotoChange = async ( + event: React.ChangeEvent, + ) => { + const files = event.target.files; + + if (files) { + for (const file of Array.from(files)) { + if (!photoNames.some((photo) => photo.includes(file.name))) { + try { + photoNames.push(file.name); + globalPhotos.push(file); + } catch (error) { + alert('Failed to handle ' + file.name + ': ' + error); + } + } + } + } + }; + + const renderPhotoNames = () => { + return globalPhotos.map((photo, index) => ( + + + {photo.name} + + + )); + }; + + return ( + <> + + + + + + Delivery Confirmation Form + + + +
+ + + + Delivery Date + + + Select the delivery date. + + + + Feedback + +