diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c0ee816e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=securing-safe-food +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=PLACEHOLDER_PASSWORD \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6033c03e..0e2cd373 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ Thumbs.db # Environment file *.env *.env.* +!.env.example diff --git a/README.md b/README.md index fa1b09c0..a9f3469e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,30 @@ -# Scaffolding +# Securing Safe Food ✨ **This workspace has been generated by [Nx, a Smart, fast and extensible build system.](https://nx.dev)** ✨ +## Environment Setup + +Install app dependencies by running this at the repo root (`ssf`) + +``` +yarn install +``` + +To setup your backend, follow the backend-specific instructions [here](apps/backend/README.md) + +*Note: you may need to prefix your `nx` commands with `npx`. For example, to serve the frontend, if: +``` +nx serve frontend +``` + +doesn't work, try: + +``` +npx nx serve frontend +``` + ## Start the app To start the development server run `nx serve frontend`. Open your browser and navigate to http://localhost:4200/. Happy coding! diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 00000000..8e4e513c --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,28 @@ +## Backend Setup + +This part can be a little tricky! If you run into some confusing errors along the way, don't be afraid to reach out if have any trouble! + +You'll need to download: + +1. [PostgreSQL](https://www.postgresql.org/download/) +2. [PgAdmin 4](https://www.pgadmin.org/download/) (if PostgreSQL didn't come with it) + +Then, set up a database called `securing-safe-food`. If you're not familiar with how to do so, it's easy to do through PgAdmin + +1. Open PgAdmin and configure your credentials (if necessary). Then, right click on the `Databases` dropdown (under `Servers` > `PostgreSQL [version]`) + +![alt text](resources/pg-setup-1.png) + +2. Enter "securing-safe-food" as the database name + +![alt text](resources/pg-setup-2.png) + +Next, create a file called `.env` in the **root directory** (under `ssf/`) and copy over the contents from `.env.example`. Replace `DATABASE_PASSWORD` with the password you entered for the `postgres` user (NOT necessarily your PgAdmin master password!) + +You can check that your database connection details are correct by running `nx serve backend` - if you can see the following line in the terminal, then you've got it right! + +``` +"LOG 🚀 Application is running on: http://localhost:3000/api" +``` + +Finally, run `yarn run typeorm:migrate` to load all the tables into your database. If everything is set up correctly, you should see "Migration ... has been executed successfully." in the terminal. \ No newline at end of file diff --git a/apps/backend/resources/pg-setup-1.png b/apps/backend/resources/pg-setup-1.png new file mode 100644 index 00000000..0d401be4 Binary files /dev/null and b/apps/backend/resources/pg-setup-1.png differ diff --git a/apps/backend/resources/pg-setup-2.png b/apps/backend/resources/pg-setup-2.png new file mode 100644 index 00000000..5c8805fc Binary files /dev/null and b/apps/backend/resources/pg-setup-2.png differ diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0a6461c8..90ece797 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,29 +1,27 @@ 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'; import { AuthModule } from './auth/auth.module'; -import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import typeorm from './config/typeorm'; @Module({ imports: [ - TypeOrmModule.forRoot({ - type: 'mongodb', - host: '127.0.0.1', - port: 27017, - database: 'c4cOpsTest', - // username: 'root', - // password: 'root', - autoLoadEntities: true, - // entities: [join(__dirname, '**/**.entity.{ts,js}')], - // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data - synchronize: true, - namingStrategy: new PluralNamingStrategy(), + ConfigModule.forRoot({ + isGlobal: true, + load: [typeorm], + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => + configService.get('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/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index d78a12dd..a0bae3ad 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -17,7 +17,7 @@ import { SignInDto } from './dtos/sign-in.dto'; import { SignInResponseDto } from './dtos/sign-in-response.dto'; import { createHmac } from 'crypto'; import { RefreshTokenDto } from './dtos/refresh-token.dto'; -import { Status } from '../users/types'; +import { Role } from '../users/types'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; @Injectable() @@ -60,7 +60,7 @@ export class AuthService { async signup( { firstName, lastName, email, password }: SignUpDto, - status: Status = Status.STANDARD, + role: Role = Role.VOLUNTEER, ): Promise { // Needs error handling const signUpCommand = new SignUpCommand({ @@ -77,7 +77,7 @@ export class AuthService { // If you choose to do so, you'll have to first add this custom attribute in your user pool { Name: 'custom:role', - Value: status, + Value: role, }, ], }); 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/config/typeorm.ts b/apps/backend/src/config/typeorm.ts new file mode 100644 index 00000000..5933befb --- /dev/null +++ b/apps/backend/src/config/typeorm.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config'; +import { PluralNamingStrategy } from '../strategies/plural-naming.strategy'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { User1725726359198 } from '../migrations/1725726359198-User'; +import { AddTables1726524792261 } from '../migrations/1726524792261-addTables'; +import { ReviseTables1737522923066 } from '../migrations/1737522923066-reviseTables'; + +const config = { + type: 'postgres', + host: `${process.env.DATABASE_HOST}`, + port: parseInt(`${process.env.DATABASE_PORT}`, 10), + database: `${process.env.DATABASE_NAME}`, + username: `${process.env.DATABASE_USERNAME}`, + password: `${process.env.DATABASE_PASSWORD}`, + autoLoadEntities: true, + synchronize: false, + namingStrategy: new PluralNamingStrategy(), + // Glob patterns (e.g. ../migrations/**.ts) are deprecated, so we have to manually specify each migration + // TODO: see if there's still a way to dynamically load all migrations + migrations: [ + User1725726359198, + AddTables1726524792261, + ReviseTables1737522923066, + ], +}; + +export default registerAs('typeorm', () => config); +export const connectionSource = new DataSource(config as DataSourceOptions); 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/src/migrations/1725726359198-User.ts b/apps/backend/src/migrations/1725726359198-User.ts new file mode 100644 index 00000000..6c887ca3 --- /dev/null +++ b/apps/backend/src/migrations/1725726359198-User.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class User1725726359198 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + status VARCHAR(20), + first_name VARCHAR(255), + last_name VARCHAR(255), + email VARCHAR(255) + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS users`); + } +} diff --git a/apps/backend/src/migrations/1726524792261-addTables.ts b/apps/backend/src/migrations/1726524792261-addTables.ts new file mode 100644 index 00000000..65abfb83 --- /dev/null +++ b/apps/backend/src/migrations/1726524792261-addTables.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTables1726524792261 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS pantries ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + approved BOOLEAN NOT NULL, + ssf_representative_id INT NOT NULL, + pantry_representative_id INT NOT NULL, + restrictions TEXT[] NOT NULL, + + CONSTRAINT fk_ssf_representative_id FOREIGN KEY(ssf_representative_id) REFERENCES users(id), + CONSTRAINT fk_pantry_representative_id FOREIGN KEY(pantry_representative_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS donations ( + id SERIAL PRIMARY KEY, + due_date TIMESTAMP NOT NULL, + pantry_id INT NOT NULL, + status VARCHAR(50) NOT NULL, + feedback TEXT, + contents TEXT[] NOT NULL, + shipment_size TEXT NOT NULL, + additional_info TEXT, + + CONSTRAINT fk_pantry_id FOREIGN KEY(pantry_id) REFERENCES pantries(id) + ); + `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS pantries; DROP TABLE IF EXISTS donations;`, + ); + } +} diff --git a/apps/backend/src/migrations/1737522923066-reviseTables.ts b/apps/backend/src/migrations/1737522923066-reviseTables.ts new file mode 100644 index 00000000..392b5ee0 --- /dev/null +++ b/apps/backend/src/migrations/1737522923066-reviseTables.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ReviseTables1737522923066 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS donations; + DROP TABLE IF EXISTS pantries; + DROP TABLE IF EXISTS users; + `, + ); + await queryRunner.query( + `-- Create users table + CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(20) NOT NULL, + role TEXT[] NOT NULL + ); + + -- Create pantries table + CREATE TABLE IF NOT EXISTS pantries ( + pantry_id SERIAL PRIMARY KEY, + pantry_name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + allergen_clients VARCHAR(25) NOT NULL, + refrigerated_donation VARCHAR(25) NOT NULL, + reserve_food_for_allergic BOOLEAN NOT NULL, + reservation_explanation TEXT NOT NULL, + dedicated_allergy_friendly VARCHAR(25) NOT NULL, + client_visit_frequency VARCHAR(25) NOT NULL, + identify_allergens_confidence VARCHAR(50) NOT NULL, + serve_allergic_children VARCHAR(25) NOT NULL, + newsletter_subscription BOOLEAN NOT NULL, + approved BOOLEAN NOT NULL, + restrictions TEXT[] NOT NULL, + ssf_representative_id INT NOT NULL, + pantry_representative_id INT NOT NULL, + CONSTRAINT fk_ssf_representative_id FOREIGN KEY(ssf_representative_id) REFERENCES users(user_id), + CONSTRAINT fk_pantry_representative_id FOREIGN KEY(pantry_representative_id) REFERENCES users(user_id) + ); + + -- Create food_manufacturers table + CREATE TABLE IF NOT EXISTS food_manufacturers ( + food_manufacturer_id SERIAL PRIMARY KEY, + food_manufacturer_name VARCHAR(255) NOT NULL, + food_manufacturer_representative_id INT NOT NULL, + CONSTRAINT fk_food_manufacturer_representative_id FOREIGN KEY(food_manufacturer_representative_id) REFERENCES users(user_id) + ); + + -- Create volunteer_assignments table + CREATE TABLE IF NOT EXISTS volunteer_assignments ( + assignment_id SERIAL PRIMARY KEY, + pantry_id INT NOT NULL, + volunteer_id INT NOT NULL, + CONSTRAINT fk_volunteer_id FOREIGN KEY(volunteer_id) REFERENCES users(user_id), + CONSTRAINT fk_pantry_id FOREIGN KEY(pantry_id) REFERENCES pantries(pantry_id), + CONSTRAINT unique_pantry_id UNIQUE (pantry_id) + ); + + -- Create food_requests table + CREATE TABLE IF NOT EXISTS food_requests ( + request_id SERIAL PRIMARY KEY, + pantry_id INT NOT NULL, + requested_size VARCHAR(50) NOT NULL, + requested_items TEXT[] NOT NULL, + additional_information TEXT, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + status VARCHAR(25) NOT NULL DEFAULT 'pending', + fulfilled_by INT, + date_received TIMESTAMP, + feedback TEXT, + photos TEXT[], + CONSTRAINT fk_pantry_id FOREIGN KEY(pantry_id) REFERENCES pantries(pantry_id), + CONSTRAINT fk_fulfilled_by FOREIGN KEY(fulfilled_by) REFERENCES food_manufacturers(food_manufacturer_id) + ); + + -- Create donations table + CREATE TABLE IF NOT EXISTS donations ( + donation_id SERIAL PRIMARY KEY, + food_manufacturer_id INT NOT NULL, + date_donated TIMESTAMP NOT NULL DEFAULT NOW(), + status VARCHAR(25) NOT NULL DEFAULT 'available', + CONSTRAINT fk_food_manufacturer_id FOREIGN KEY(food_manufacturer_id) REFERENCES food_manufacturers(food_manufacturer_id) + ); + + -- Create donation_items table + CREATE TABLE IF NOT EXISTS donation_items ( + item_id SERIAL PRIMARY KEY, + donation_id INT NOT NULL, + item_name VARCHAR(255) NOT NULL, + quantity INT NOT NULL, + reserved_quantity INT NOT NULL DEFAULT 0, + status VARCHAR(25) NOT NULL DEFAULT 'available', + CONSTRAINT fk_donation_id FOREIGN KEY(donation_id) REFERENCES donations(donation_id) + ); + + -- Create allocations table + CREATE TABLE IF NOT EXISTS allocations ( + allocation_id SERIAL PRIMARY KEY, + request_id INT NOT NULL, + item_id INT NOT NULL, + allocated_quantity INT NOT NULL, + reserved_at TIMESTAMP NOT NULL DEFAULT NOW(), + fulfilled_at TIMESTAMP, + status VARCHAR(25) NOT NULL DEFAULT 'pending', + CONSTRAINT fk_request_id FOREIGN KEY(request_id) REFERENCES food_requests(request_id), + CONSTRAINT fk_item_id FOREIGN KEY(item_id) REFERENCES donation_items(item_id) + );`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS volunteer_assignments; + DROP TABLE IF EXISTS allocations; + DROP TABLE IF EXISTS donation_items; + DROP TABLE IF EXISTS donations; + DROP TABLE IF EXISTS food_requests; + DROP TABLE IF EXISTS pantries; + DROP TABLE IF EXISTS food_manufacturers; + DROP TABLE IF EXISTS users; + `, + ); + } +} diff --git a/apps/backend/src/strategies/plural-naming.strategy.ts b/apps/backend/src/strategies/plural-naming.strategy.ts index bf3a5122..b3b3614d 100644 --- a/apps/backend/src/strategies/plural-naming.strategy.ts +++ b/apps/backend/src/strategies/plural-naming.strategy.ts @@ -1,22 +1,10 @@ -import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm'; +import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; -export class PluralNamingStrategy - extends DefaultNamingStrategy - implements NamingStrategyInterface -{ - tableName(targetName: string, userSpecifiedName: string | undefined): string { - return userSpecifiedName || targetName.toLowerCase() + 's'; // Pluralize the table name - } - - columnName( - propertyName: string, - customName: string, - embeddedPrefixes: string[], - ): string { - return propertyName; - } - - relationName(propertyName: string): string { - return propertyName; +// Extend SnakeNamingStrategy to follow Postgres naming conventions +export class PluralNamingStrategy extends SnakeNamingStrategy { + tableName(targetName: string, userSpecifiedName: string): string { + return ( + userSpecifiedName || super.tableName(targetName, userSpecifiedName) + 's' + ); // Pluralize the table name } } diff --git a/apps/backend/src/users/types.ts b/apps/backend/src/users/types.ts index dd9a359b..45a69f5a 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -1,4 +1,6 @@ -export enum Status { +export enum Role { ADMIN = 'ADMIN', - STANDARD = 'STANDARD', + VOLUNTEER = 'VOLUNTEER', + PANTRY = 'PANTRY', + FOODMANUFACTURER = 'FOODMANUFACTURER', } diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index dc537091..7e58aecc 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -1,17 +1,18 @@ -import { Entity, Column, ObjectIdColumn, ObjectId } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; -import type { Status } from './types'; +import { Role } from './types'; @Entity() export class User { - @ObjectIdColumn() - _id: ObjectId; - - @Column({ primary: true }) + @PrimaryGeneratedColumn() id: number; - @Column() - status: Status; + @Column({ + type: 'varchar', + length: 20, + default: Role.VOLUNTEER, + }) + role: Role; @Column() firstName: string; diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 018a7678..be02a837 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; -import { Status } from './types'; +import { Role } from './types'; @Injectable() export class UsersService { @@ -13,12 +13,12 @@ export class UsersService { email: string, firstName: string, lastName: string, - status: Status = Status.STANDARD, + role: Role = Role.VOLUNTEER, ) { const userId = (await this.repo.count()) + 1; const user = this.repo.create({ id: userId, - status, + role, firstName, lastName, email, 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/components/Header.tsx b/apps/frontend/components/Header.tsx new file mode 100644 index 00000000..f6641cbe --- /dev/null +++ b/apps/frontend/components/Header.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Header = () => { + return

Securing Safe Food

; +}; + +export default Header; diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 47f54c98..ebde308b 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,7 +2,7 @@ - Frontend + SSF diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico index 317ebcb2..c4f6a143 100644 Binary files a/apps/frontend/public/favicon.ico and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/src/app.spec.tsx b/apps/frontend/src/app.spec.tsx deleted file mode 100644 index 95caf44d..00000000 --- a/apps/frontend/src/app.spec.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render } from '@testing-library/react'; - -import App from './app'; - -describe('App', () => { - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); - }); - - it('should have a greeting as the title', () => { - const { getByText } = render(); - expect(getByText(/Welcome frontend/gi)).toBeTruthy(); - }); -}); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index a51df65b..b79f6d46 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -4,22 +4,60 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; -import Test from '@containers/test'; +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([ { path: '/', element: , errorElement: , - }, - { - path: '/test', - element: , + children: [ + { + path: '/landing-page', + element: , + }, + { + path: '/pantry-overview', + element: , + }, + { + path: '/pantry-past-orders', + element: , + }, + { + path: '/pantries', + element: , + }, + { + path: '/orders', + element: , + }, + { + path: '/request-form/:pantryId', + element: , + }, + { + path: '/food-request', + action: submitFoodRequestFormModal, + }, + { + path: '/confirm-delivery', + action: submitDeliveryConfirmationFormModal, + }, + ], }, ]); export const App: React.FC = () => { useEffect(() => { + document.title = 'SSF'; apiClient.getHello().then((res) => console.log(res)); }, []); 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 + +