diff --git a/apps/backend/src/allocations/allocations.entity.ts b/apps/backend/src/allocations/allocations.entity.ts index 26985283..5bcb6eec 100644 --- a/apps/backend/src/allocations/allocations.entity.ts +++ b/apps/backend/src/allocations/allocations.entity.ts @@ -11,28 +11,28 @@ import { Order } from '../orders/order.entity'; @Entity('allocations') export class Allocation { @PrimaryGeneratedColumn({ name: 'allocation_id' }) - allocationId: number; + allocationId!: number; - @Column({ name: 'order_id', type: 'int', nullable: false }) - orderId: number; + @Column({ name: 'order_id', type: 'int' }) + orderId!: number; @ManyToOne(() => Order, (order) => order.allocations) @JoinColumn({ name: 'order_id' }) - order: Order; + order!: Order; - @Column({ name: 'item_id', type: 'int', nullable: false }) - itemId: number; + @Column({ name: 'item_id', type: 'int' }) + itemId!: number; @ManyToOne(() => DonationItem, (item) => item.allocations) @JoinColumn({ name: 'item_id' }) - item: DonationItem; + item!: DonationItem; @Column({ name: 'allocated_quantity', type: 'int' }) - allocatedQuantity: number; + allocatedQuantity!: number; @Column({ name: 'reserved_at', type: 'timestamp' }) - reservedAt: Date; + reservedAt!: Date; - @Column({ name: 'fulfilled_at', type: 'timestamp' }) - fulfilledAt: Date; + @Column({ name: 'fulfilled_at', type: 'timestamp', nullable: true }) + fulfilledAt!: Date | null; } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 7fe3ff82..880c102c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -31,7 +31,7 @@ import { ScheduleModule } from '@nestjs/schedule'; TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => - configService.get('typeorm'), + configService.getOrThrow('typeorm'), }), ScheduleModule.forRoot(), UsersModule, diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index ec741008..c083b9b9 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -25,8 +25,12 @@ export class AuthController { // By default, creates a standard user try { await this.authService.signup(signUpDto); - } catch (e) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + const message = + e instanceof Error + ? e.message + : 'Unexpected error occurred when signing up user'; + throw new BadRequestException(message); } const user = await this.usersService.create( @@ -45,8 +49,12 @@ export class AuthController { verifyUser(@Body() body: VerifyUserDto): void { try { this.authService.verifyUser(body.email, body.verificationCode); - } catch (e) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + const message = + e instanceof Error + ? e.message + : 'Unexpected error occurred when verifying user'; + throw new BadRequestException(message); } } @@ -76,8 +84,12 @@ export class AuthController { try { await this.authService.deleteUser(user.email); - } catch (e) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + const message = + e instanceof Error + ? e.message + : 'Unexpected error occurred when deleting user'; + throw new BadRequestException(message); } this.usersService.remove(user.id); diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 800ab662..248f642d 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -5,6 +5,11 @@ describe('AuthService', () => { let service: AuthService; beforeEach(async () => { + process.env.AWS_ACCESS_KEY_ID = 'test'; + process.env.AWS_SECRET_ACCESS_KEY = 'test'; + process.env.COGNITO_CLIENT_SECRET = 'test'; + process.env.AWS_REGION = 'us-east-1'; + const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], }).compile(); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 8c38f366..84f89e35 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,12 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { AdminDeleteUserCommand, AdminInitiateAuthCommand, + AdminInitiateAuthCommandOutput, CognitoIdentityProviderClient, ConfirmForgotPasswordCommand, ConfirmSignUpCommand, ForgotPasswordCommand, SignUpCommand, + AuthenticationResultType, } from '@aws-sdk/client-cognito-identity-provider'; import CognitoAuthConfig from './aws-exports'; @@ -17,6 +23,18 @@ import { createHmac } from 'crypto'; import { RefreshTokenDto } from './dtos/refresh-token.dto'; import { Role } from '../users/types'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; +import { validateEnv } from '../utils/validation.utils'; + +type SignInAuthResult = AuthenticationResultType & { + AccessToken: string; + RefreshToken: string; + IdToken: string; +}; + +type RefreshAuthResult = AuthenticationResultType & { + AccessToken: string; + IdToken: string; +}; @Injectable() export class AuthService { @@ -27,12 +45,12 @@ export class AuthService { this.providerClient = new CognitoIdentityProviderClient({ region: CognitoAuthConfig.region, credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + accessKeyId: validateEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey: validateEnv('AWS_SECRET_ACCESS_KEY'), }, }); - this.clientSecret = process.env.COGNITO_CLIENT_SECRET; + this.clientSecret = validateEnv('COGNITO_CLIENT_SECRET'); } // Computes secret hash to authenticate this backend to Cognito @@ -69,8 +87,19 @@ export class AuthService { ], }); - const response = await this.providerClient.send(signUpCommand); - return response.UserConfirmed; + try { + const response = await this.providerClient.send(signUpCommand); + + if (response.UserConfirmed == null) { + throw new InternalServerErrorException( + 'Missing UserConfirmed from Cognito', + ); + } + + return response.UserConfirmed; + } catch (err: unknown) { + throw new InternalServerErrorException('Failed to sign up user'); + } } async verifyUser(email: string, verificationCode: string): Promise { @@ -98,10 +127,13 @@ export class AuthService { const response = await this.providerClient.send(signInCommand); + const authResult = + this.validateAuthenticationResultTokensForSignIn(response); + return { - accessToken: response.AuthenticationResult.AccessToken, - refreshToken: response.AuthenticationResult.RefreshToken, - idToken: response.AuthenticationResult.IdToken, + accessToken: authResult.AccessToken, + refreshToken: authResult.RefreshToken, + idToken: authResult.IdToken, }; } @@ -122,10 +154,13 @@ export class AuthService { const response = await this.providerClient.send(refreshCommand); + const authResult = + this.validateAuthenticationResultTokensForRefresh(response); + return { - accessToken: response.AuthenticationResult.AccessToken, + accessToken: authResult.AccessToken, refreshToken: refreshToken, - idToken: response.AuthenticationResult.IdToken, + idToken: authResult.IdToken, }; } @@ -163,4 +198,48 @@ export class AuthService { await this.providerClient.send(adminDeleteUserCommand); } + + private validateAuthenticationResultTokensForSignIn( + commandOutput: AdminInitiateAuthCommandOutput, + ): SignInAuthResult { + const result = commandOutput.AuthenticationResult; + + if (result == null) { + throw new NotFoundException( + 'No associated authentication result for sign in', + ); + } + + if ( + result.AccessToken == null || + result.RefreshToken == null || + result.IdToken == null + ) { + throw new NotFoundException( + 'Necessary Authentication Result tokens not found for sign in ', + ); + } + + return result as SignInAuthResult; + } + + private validateAuthenticationResultTokensForRefresh( + commandOutput: AdminInitiateAuthCommandOutput, + ): RefreshAuthResult { + const result = commandOutput.AuthenticationResult; + + if (result == null) { + throw new NotFoundException( + 'No associated authentication result for refresh', + ); + } + + if (result.AccessToken == null || result.IdToken == null) { + throw new NotFoundException( + 'Necessary Authentication Result tokens not found for refresh', + ); + } + + return result as RefreshAuthResult; + } } diff --git a/apps/backend/src/auth/authenticated-request.ts b/apps/backend/src/auth/authenticated-request.ts new file mode 100644 index 00000000..d8c0673f --- /dev/null +++ b/apps/backend/src/auth/authenticated-request.ts @@ -0,0 +1,7 @@ +import { Request } from 'express'; +import { User } from '../users/user.entity'; + +// user does not have to be provided by the client but is added automatically by the auth backend +export interface AuthenticatedRequest extends Request { + user: User; +} diff --git a/apps/backend/src/auth/dtos/confirm-password.dto.ts b/apps/backend/src/auth/dtos/confirm-password.dto.ts index ec1d63bb..921deff6 100644 --- a/apps/backend/src/auth/dtos/confirm-password.dto.ts +++ b/apps/backend/src/auth/dtos/confirm-password.dto.ts @@ -2,11 +2,11 @@ import { IsEmail, IsString } from 'class-validator'; export class ConfirmPasswordDto { @IsEmail() - email: string; + email!: string; @IsString() - newPassword: string; + newPassword!: string; @IsString() - confirmationCode: string; + confirmationCode!: string; } diff --git a/apps/backend/src/auth/dtos/delete-user.dto.ts b/apps/backend/src/auth/dtos/delete-user.dto.ts index 1a616376..78c4a7fa 100644 --- a/apps/backend/src/auth/dtos/delete-user.dto.ts +++ b/apps/backend/src/auth/dtos/delete-user.dto.ts @@ -2,5 +2,5 @@ import { IsPositive } from 'class-validator'; export class DeleteUserDto { @IsPositive() - userId: number; + userId!: number; } diff --git a/apps/backend/src/auth/dtos/forgot-password.dto.ts b/apps/backend/src/auth/dtos/forgot-password.dto.ts index bbedf083..e65f7d5b 100644 --- a/apps/backend/src/auth/dtos/forgot-password.dto.ts +++ b/apps/backend/src/auth/dtos/forgot-password.dto.ts @@ -2,5 +2,5 @@ import { IsEmail } from 'class-validator'; export class ForgotPasswordDto { @IsEmail() - email: string; + email!: string; } diff --git a/apps/backend/src/auth/dtos/refresh-token.dto.ts b/apps/backend/src/auth/dtos/refresh-token.dto.ts index f67905d3..47feef29 100644 --- a/apps/backend/src/auth/dtos/refresh-token.dto.ts +++ b/apps/backend/src/auth/dtos/refresh-token.dto.ts @@ -2,8 +2,8 @@ import { IsString } from 'class-validator'; export class RefreshTokenDto { @IsString() - refreshToken: string; + refreshToken!: string; @IsString() - userSub: string; + userSub!: string; } diff --git a/apps/backend/src/auth/dtos/sign-in-response.dto.ts b/apps/backend/src/auth/dtos/sign-in-response.dto.ts index b22f6c88..24fc0899 100644 --- a/apps/backend/src/auth/dtos/sign-in-response.dto.ts +++ b/apps/backend/src/auth/dtos/sign-in-response.dto.ts @@ -1,7 +1,7 @@ export class SignInResponseDto { - accessToken: string; + accessToken!: string; - refreshToken: string; + refreshToken!: string; - idToken: string; + idToken!: string; } diff --git a/apps/backend/src/auth/dtos/sign-in.dto.ts b/apps/backend/src/auth/dtos/sign-in.dto.ts index 51cd9c95..0be7d513 100644 --- a/apps/backend/src/auth/dtos/sign-in.dto.ts +++ b/apps/backend/src/auth/dtos/sign-in.dto.ts @@ -2,8 +2,8 @@ import { IsEmail, IsString } from 'class-validator'; export class SignInDto { @IsEmail() - email: string; + email!: string; @IsString() - password: string; + password!: string; } diff --git a/apps/backend/src/auth/dtos/sign-up.dto.ts b/apps/backend/src/auth/dtos/sign-up.dto.ts index 258690eb..6fa066a3 100644 --- a/apps/backend/src/auth/dtos/sign-up.dto.ts +++ b/apps/backend/src/auth/dtos/sign-up.dto.ts @@ -2,16 +2,16 @@ import { IsEmail, IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator'; export class SignUpDto { @IsString() - firstName: string; + firstName!: string; @IsString() - lastName: string; + lastName!: string; @IsEmail() - email: string; + email!: string; @IsString() - password: string; + password!: string; @IsString() @IsNotEmpty() @@ -19,5 +19,5 @@ export class SignUpDto { message: 'phone must be a valid phone number (make sure all the digits are correct)', }) - phone: string; + phone!: string; } diff --git a/apps/backend/src/auth/dtos/verify-user.dto.ts b/apps/backend/src/auth/dtos/verify-user.dto.ts index 66391605..b7f300fc 100644 --- a/apps/backend/src/auth/dtos/verify-user.dto.ts +++ b/apps/backend/src/auth/dtos/verify-user.dto.ts @@ -2,8 +2,8 @@ import { IsEmail, IsString } from 'class-validator'; export class VerifyUserDto { @IsEmail() - email: string; + email!: string; @IsString() - verificationCode: string; + verificationCode!: string; } diff --git a/apps/backend/src/aws/aws-s3.service.ts b/apps/backend/src/aws/aws-s3.service.ts index 045038d5..1035558e 100644 --- a/apps/backend/src/aws/aws-s3.service.ts +++ b/apps/backend/src/aws/aws-s3.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { validateEnv } from '../utils/validation.utils'; @Injectable() export class AWSS3Service { @@ -9,15 +10,15 @@ export class AWSS3Service { constructor() { this.region = process.env.AWS_REGION || 'us-east-2'; - this.bucket = process.env.AWS_BUCKET_NAME; + this.bucket = validateEnv('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, + accessKeyId: validateEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey: validateEnv('AWS_SECRET_ACCESS_KEY'), }, }); } diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 9e322b74..85684cd7 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -30,6 +30,7 @@ import { AddFoodRescueToDonationItems1770679339809 } from '../migrations/1770679 import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity'; +import { DonationItemFoodTypeNotNull1771524930613 } from '../migrations/1771524930613-DonationItemFoodTypeNotNull'; import { MoveRequestFieldsToOrders1770571145350 } from '../migrations/1770571145350-MoveRequestFieldsToOrders'; import { RenameDonationMatchingStatus1771260403657 } from '../migrations/1771260403657-RenameDonationMatchingStatus'; @@ -66,6 +67,7 @@ const schemaMigrations = [ UpdateManufacturerEntity1768680807820, AddUserPoolId1769189327767, UpdateOrderEntity1769990652833, + DonationItemFoodTypeNotNull1771524930613, MoveRequestFieldsToOrders1770571145350, RenameDonationMatchingStatus1771260403657, ]; diff --git a/apps/backend/src/donationItems/donationItems.entity.ts b/apps/backend/src/donationItems/donationItems.entity.ts index 852df755..a99bfb36 100644 --- a/apps/backend/src/donationItems/donationItems.entity.ts +++ b/apps/backend/src/donationItems/donationItems.entity.ts @@ -32,10 +32,10 @@ export class DonationItem { reservedQuantity!: number; @Column({ name: 'oz_per_item', type: 'numeric', nullable: true }) - ozPerItem?: number; + ozPerItem!: number | null; @Column({ name: 'estimated_value', type: 'numeric', nullable: true }) - estimatedValue?: number; + estimatedValue!: number | null; @Column({ name: 'food_type', @@ -49,5 +49,5 @@ export class DonationItem { allocations!: Allocation[]; @Column({ name: 'food_rescue', type: 'boolean', nullable: true }) - foodRescue?: boolean; + foodRescue!: boolean | null; } diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 25820f1c..f95e5fdb 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -52,7 +52,7 @@ export class DonationItemsService { reservedQuantity: number; ozPerItem?: number; estimatedValue?: number; - foodType?: FoodType; + foodType: FoodType; }[], ): Promise { validateId(donationId, 'Donation'); diff --git a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts index 8beaccce..0e4b04b3 100644 --- a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts +++ b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts @@ -26,14 +26,14 @@ export class CreateDonationItemDto { @Min(0) reservedQuantity!: number; - @IsOptional() @IsNumber() @Min(0.01) + @IsOptional() ozPerItem?: number; - @IsOptional() @IsNumber() @Min(0.01) + @IsOptional() estimatedValue?: number; @IsEnum(FoodType) diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 854401b3..daa5659c 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -37,13 +37,13 @@ export class Donation { status!: DonationStatus; @Column({ name: 'total_items', type: 'int', nullable: true }) - totalItems?: number; + totalItems!: number | null; @Column({ name: 'total_oz', type: 'numeric', nullable: true }) - totalOz?: number; + totalOz!: number | null; @Column({ name: 'total_estimated_value', type: 'numeric', nullable: true }) - totalEstimatedValue?: number; + totalEstimatedValue!: number | null; @Column({ name: 'recurrence', @@ -55,7 +55,7 @@ export class Donation { recurrence!: RecurrenceEnum; @Column({ name: 'recurrence_freq', type: 'int', nullable: true }) - recurrenceFreq?: number; + recurrenceFreq!: number | null; @Column({ name: 'next_donation_dates', @@ -63,8 +63,8 @@ export class Donation { array: true, nullable: true, }) - nextDonationDates?: Date[]; + nextDonationDates!: Date[] | null; @Column({ name: 'occurrences_remaining', type: 'int', nullable: true }) - occurrencesRemaining?: number; + occurrencesRemaining!: number | null; } diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 18e389b4..a4fa539f 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; @@ -50,14 +54,21 @@ export class DonationService { ); } - const nextDonationDates = - donationData.recurrence !== RecurrenceEnum.NONE - ? await this.generateNextDonationDates( - donationData.recurrenceFreq, - donationData.recurrence, - donationData.repeatOnDays ?? null, - ) - : null; + let nextDonationDates = null; + + if (donationData.recurrence !== RecurrenceEnum.NONE) { + if (donationData.recurrenceFreq == null) { + throw new BadRequestException( + 'recurrenceFreq is required for recurring donations', + ); + } + + nextDonationDates = await this.generateNextDonationDates( + donationData.recurrenceFreq, + donationData.recurrence, + donationData.repeatOnDays ?? null, + ); + } const donation = this.repo.create({ foodManufacturer: manufacturer, diff --git a/apps/backend/src/emails/types.ts b/apps/backend/src/emails/dto/send-email.dto.ts similarity index 75% rename from apps/backend/src/emails/types.ts rename to apps/backend/src/emails/dto/send-email.dto.ts index 34ffb865..4639790d 100644 --- a/apps/backend/src/emails/types.ts +++ b/apps/backend/src/emails/dto/send-email.dto.ts @@ -2,21 +2,21 @@ import { IsString, IsOptional, IsNotEmpty, - MaxLength, IsEmail, IsArray, + Length, } from 'class-validator'; -import { EmailAttachment } from './awsSes.wrapper'; +import { EmailAttachment } from '../awsSes.wrapper'; export class SendEmailDTO { @IsArray() @IsEmail({}, { each: true }) - @MaxLength(255, { each: true }) + @Length(1, 255, { each: true }) toEmails!: string[]; @IsString() @IsNotEmpty() - @MaxLength(255) + @Length(1, 255) subject!: string; @IsString() diff --git a/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts index a6d4bbba..556e6910 100644 --- a/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts @@ -16,27 +16,27 @@ export class FoodManufacturerApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - foodManufacturerName: string; + foodManufacturerName!: string; @IsString() @IsNotEmpty() @Length(1, 255) - foodManufacturerWebsite: string; + foodManufacturerWebsite!: string; @IsString() @IsNotEmpty() @Length(1, 255) - contactFirstName: string; + contactFirstName!: string; @IsString() @IsNotEmpty() @Length(1, 255) - contactLastName: string; + contactLastName!: string; @IsEmail() @IsNotEmpty() @Length(1, 255) - contactEmail: string; + contactEmail!: string; @IsString() @IsNotEmpty() @@ -44,7 +44,7 @@ export class FoodManufacturerApplicationDto { message: 'contactPhone must be a valid phone number (make sure all the digits are correct)', }) - contactPhone: string; + contactPhone!: string; @IsOptional() @IsString() @@ -75,27 +75,27 @@ export class FoodManufacturerApplicationDto { @ArrayNotEmpty() @IsEnum(Allergen, { each: true }) - unlistedProductAllergens: Allergen[]; + unlistedProductAllergens!: Allergen[]; @ArrayNotEmpty() @IsEnum(Allergen, { each: true }) - facilityFreeAllergens: Allergen[]; + facilityFreeAllergens!: Allergen[]; @IsBoolean() - productsGlutenFree: boolean; + productsGlutenFree!: boolean; @IsBoolean() - productsContainSulfites: boolean; + productsContainSulfites!: boolean; @IsString() @IsNotEmpty() - productsSustainableExplanation: string; + productsSustainableExplanation!: string; @IsBoolean() - inKindDonations: boolean; + inKindDonations!: boolean; @IsEnum(DonateWastedFood) - donateWastedFood: DonateWastedFood; + donateWastedFood!: DonateWastedFood; @IsOptional() @IsEnum(ManufacturerAttribute) diff --git a/apps/backend/src/foodManufacturers/manufacturers.entity.ts b/apps/backend/src/foodManufacturers/manufacturers.entity.ts index aa52ce89..8d351052 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.entity.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.entity.ts @@ -39,7 +39,7 @@ export class FoodManufacturer { length: 255, nullable: true, }) - secondaryContactFirstName?: string; + secondaryContactFirstName!: string | null; @Column({ name: 'secondary_contact_last_name', @@ -47,7 +47,7 @@ export class FoodManufacturer { length: 255, nullable: true, }) - secondaryContactLastName?: string; + secondaryContactLastName!: string | null; @Column({ name: 'secondary_contact_email', @@ -55,7 +55,7 @@ export class FoodManufacturer { length: 255, nullable: true, }) - secondaryContactEmail?: string; + secondaryContactEmail!: string | null; @Column({ name: 'secondary_contact_phone', @@ -63,7 +63,7 @@ export class FoodManufacturer { length: 20, nullable: true, }) - secondaryContactPhone?: string; + secondaryContactPhone!: string | null; @Column({ name: 'unlisted_product_allergens', @@ -113,17 +113,17 @@ export class FoodManufacturer { enumName: 'manufacturer_attribute_enum', nullable: true, }) - manufacturerAttribute?: ManufacturerAttribute; + manufacturerAttribute!: ManufacturerAttribute | null; @Column({ name: 'additional_comments', type: 'text', nullable: true, }) - additionalComments?: string; + additionalComments!: string | null; @Column({ name: 'newsletter_subscription', type: 'boolean', nullable: true }) - newsletterSubscription?: boolean; + newsletterSubscription!: boolean | null; @OneToMany(() => Donation, (donation) => donation.foodManufacturer) donations!: Donation[]; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 95999256..54c44323 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -76,13 +76,13 @@ export class FoodManufacturersService { // secondary contact information foodManufacturer.secondaryContactFirstName = - foodManufacturerData.secondaryContactFirstName; + foodManufacturerData.secondaryContactFirstName ?? null; foodManufacturer.secondaryContactLastName = - foodManufacturerData.secondaryContactLastName; + foodManufacturerData.secondaryContactLastName ?? null; foodManufacturer.secondaryContactEmail = - foodManufacturerData.secondaryContactEmail; + foodManufacturerData.secondaryContactEmail ?? null; foodManufacturer.secondaryContactPhone = - foodManufacturerData.secondaryContactPhone; + foodManufacturerData.secondaryContactPhone ?? null; // food manufacturer details information foodManufacturer.foodManufacturerName = @@ -102,11 +102,11 @@ export class FoodManufacturersService { foodManufacturer.inKindDonations = foodManufacturerData.inKindDonations; foodManufacturer.donateWastedFood = foodManufacturerData.donateWastedFood; foodManufacturer.manufacturerAttribute = - foodManufacturerData.manufacturerAttribute; + foodManufacturerData.manufacturerAttribute ?? null; foodManufacturer.additionalComments = - foodManufacturerData.additionalComments; + foodManufacturerData.additionalComments ?? null; foodManufacturer.newsletterSubscription = - foodManufacturerData.newsletterSubscription; + foodManufacturerData.newsletterSubscription ?? null; await this.repo.save(foodManufacturer); } diff --git a/apps/backend/src/foodRequests/dtos/create-request.dto.ts b/apps/backend/src/foodRequests/dtos/create-request.dto.ts new file mode 100644 index 00000000..9515bfcd --- /dev/null +++ b/apps/backend/src/foodRequests/dtos/create-request.dto.ts @@ -0,0 +1,27 @@ +import { + ArrayNotEmpty, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { RequestSize } from '../types'; +import { FoodType } from '../../donationItems/types'; + +export class CreateRequestDto { + @IsNumber() + pantryId!: number; + + @IsEnum(RequestSize) + requestedSize!: RequestSize; + + @ArrayNotEmpty() + @IsEnum(FoodType, { each: true }) + requestedItems!: FoodType[]; + + @IsOptional() + @IsString() + @IsNotEmpty() + additionalInformation?: string; +} diff --git a/apps/backend/src/foodRequests/dtos/order-details.dto.ts b/apps/backend/src/foodRequests/dtos/order-details.dto.ts index 21d360ec..392c5e42 100644 --- a/apps/backend/src/foodRequests/dtos/order-details.dto.ts +++ b/apps/backend/src/foodRequests/dtos/order-details.dto.ts @@ -2,14 +2,14 @@ import { FoodType } from '../../donationItems/types'; import { OrderStatus } from '../../orders/types'; export class OrderItemDetailsDto { - name: string; - quantity: number; - foodType: FoodType; + name!: string; + quantity!: number; + foodType!: FoodType; } export class OrderDetailsDto { - orderId: number; - status: OrderStatus; - foodManufacturerName: string; - items: OrderItemDetailsDto[]; + orderId!: number; + status!: OrderStatus; + foodManufacturerName!: string; + items!: OrderItemDetailsDto[]; } diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 46772a4c..ada54836 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -7,6 +7,8 @@ import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { FoodType } from '../donationItems/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { CreateRequestDto } from './dtos/create-request.dto'; +import { Order } from '../orders/order.entity'; const mockRequestsService = mock(); @@ -129,10 +131,13 @@ describe('RequestsController', () => { describe('POST /create', () => { it('should call requestsService.create and return the created food request', async () => { - const createBody: Partial = { + const createBody: Partial = { pantryId: 1, requestedSize: RequestSize.MEDIUM, - requestedItems: ['Test item 1', 'Test item 2'], + requestedItems: [ + FoodType.DAIRY_FREE_ALTERNATIVES, + FoodType.DRIED_BEANS, + ], additionalInformation: 'Test information.', }; @@ -147,7 +152,9 @@ describe('RequestsController', () => { createdRequest as FoodRequest, ); - const result = await controller.createRequest(createBody as FoodRequest); + const result = await controller.createRequest( + createBody as CreateRequestDto, + ); expect(result).toEqual(createdRequest); expect(mockRequestsService.create).toHaveBeenCalledWith( diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 665f7e57..9d2bcd01 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -5,6 +5,10 @@ import { ParseIntPipe, Post, Body, + UploadedFiles, + UseInterceptors, + NotFoundException, + ValidationPipe, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; @@ -14,6 +18,7 @@ import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; import { RequestSize } from './types'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { CreateRequestDto } from './dtos/create-request.dto'; @Controller('requests') export class RequestsController { @@ -68,24 +73,14 @@ export class RequestsController { }, }) async createRequest( - @Body() - body: { - pantryId: number; - requestedSize: RequestSize; - requestedItems: string[]; - additionalInformation: string; - }, + @Body(new ValidationPipe()) + requestData: CreateRequestDto, ): Promise { - if ( - !Object.values(RequestSize).includes(body.requestedSize as RequestSize) - ) { - throw new BadRequestException('Invalid request size'); - } return this.requestsService.create( - body.pantryId, - body.requestedSize, - body.requestedItems, - body.additionalInformation, + requestData.pantryId, + requestData.requestedSize, + requestData.requestedItems, + requestData.additionalInformation, ); } } diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts index 8014c04a..efa66802 100644 --- a/apps/backend/src/foodRequests/request.entity.ts +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -14,14 +14,14 @@ import { Pantry } from '../pantries/pantries.entity'; @Entity('food_requests') export class FoodRequest { @PrimaryGeneratedColumn({ name: 'request_id' }) - requestId: number; + requestId!: number; @Column({ name: 'pantry_id', type: 'int' }) - pantryId: number; + pantryId!: number; @ManyToOne(() => Pantry, { nullable: false }) @JoinColumn({ name: 'pantry_id', referencedColumnName: 'pantryId' }) - pantry: Pantry; + pantry!: Pantry; @Column({ name: 'requested_size', @@ -29,20 +29,20 @@ export class FoodRequest { enum: RequestSize, enumName: 'request_size_enum', }) - requestedSize: RequestSize; + requestedSize!: RequestSize; @Column({ name: 'requested_items', type: 'text', array: true }) - requestedItems: string[]; + requestedItems!: string[]; @Column({ name: 'additional_information', type: 'text', nullable: true }) - additionalInformation: string; + additionalInformation!: string | null; @CreateDateColumn({ name: 'requested_at', type: 'timestamp', default: () => 'NOW()', }) - requestedAt: Date; + requestedAt!: Date; @Column({ name: 'status', @@ -51,8 +51,8 @@ export class FoodRequest { enum: FoodRequestStatus, default: FoodRequestStatus.ACTIVE, }) - status: FoodRequestStatus; + status!: FoodRequestStatus; @OneToMany(() => Order, (order) => order.request, { nullable: true }) - orders: Order[]; + orders!: Order[] | null; } diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 7072157b..c8971250 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -17,14 +17,14 @@ const mockRequestsRepository = mock>(); const mockPantryRepository = mock>(); const mockOrdersRepository = mock>(); -const mockRequest: Partial = { +const mockRequest = { requestId: 1, pantryId: 1, requestedItems: ['Canned Goods', 'Vegetables'], additionalInformation: 'No onions, please.', - requestedAt: null, + requestedAt: new Date(), orders: null, -}; +} as FoodRequest; describe('RequestsService', () => { let service: RequestsService; @@ -246,7 +246,7 @@ describe('RequestsService', () => { mockRequest.pantryId, mockRequest.requestedSize, mockRequest.requestedItems, - mockRequest.additionalInformation, + mockRequest.additionalInformation ?? undefined, ); expect(result).toEqual(mockRequest); @@ -286,7 +286,7 @@ describe('RequestsService', () => { requestedSize: RequestSize.LARGE, requestedItems: ['Rice', 'Beans'], additionalInformation: 'Gluten-free items only.', - requestedAt: null, + requestedAt: new Date(), orders: null, }, { @@ -295,7 +295,7 @@ describe('RequestsService', () => { requestedSize: RequestSize.SMALL, requestedItems: ['Fruits', 'Snacks'], additionalInformation: 'No nuts, please.', - requestedAt: null, + requestedAt: new Date(), orders: null, }, ]; diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 0deee010..a5dc7ea7 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -78,7 +78,7 @@ export class RequestsService { pantryId: number, requestedSize: RequestSize, requestedItems: string[], - additionalInformation: string | undefined, + additionalInformation?: string, ): Promise { validateId(pantryId, 'Pantry'); diff --git a/apps/backend/src/migrations/1771524930613-DonationItemFoodTypeNotNull.ts b/apps/backend/src/migrations/1771524930613-DonationItemFoodTypeNotNull.ts new file mode 100644 index 00000000..5041c50a --- /dev/null +++ b/apps/backend/src/migrations/1771524930613-DonationItemFoodTypeNotNull.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DonationItemFoodTypeNotNull1771524930613 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + ALTER COLUMN food_type SET NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + ALTER COLUMN food_type DROP NOT NULL; + `); + } +} diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index a672cd29..33eff085 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -428,6 +428,8 @@ describe('OrdersService', () => { expect(shippedOrder).toBeDefined(); + if (!shippedOrder) throw new Error('Missing shipped order test object'); + const dateReceived = new Date().toISOString(); const feedback = 'Perfect delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; @@ -450,6 +452,9 @@ describe('OrdersService', () => { relations: ['orders'], }); + if (!updatedRequest) + throw new Error('Missing updatedRequest test object'); + expect(updatedRequest.status).toBe(FoodRequestStatus.CLOSED); }); @@ -465,6 +470,9 @@ describe('OrdersService', () => { expect(existingShippedOrder).toBeDefined(); + if (!existingShippedOrder) + throw new Error('Missing existingShippedOrder test object'); + // Add a second shipped order to the same request so it stays active after delivery const secondOrder = orderRepo.create({ requestId: existingShippedOrder.requestId, @@ -495,6 +503,9 @@ describe('OrdersService', () => { relations: ['orders'], }); + if (!updatedRequest) + throw new Error('Missing updatedRequest test object'); + expect(updatedRequest.status).toBe(FoodRequestStatus.ACTIVE); }); @@ -521,6 +532,8 @@ describe('OrdersService', () => { expect(pendingOrder).toBeDefined(); + if (!pendingOrder) throw new Error('Missing pendingOrder test object'); + await expect( service.confirmDelivery( pendingOrder.orderId, @@ -541,6 +554,9 @@ describe('OrdersService', () => { expect(deliveredOrder).toBeDefined(); + if (!deliveredOrder) + throw new Error('Missing deliveredOrder test object'); + await expect( service.confirmDelivery( deliveredOrder.orderId, diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 8b62a51a..17aeffd8 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -106,6 +106,10 @@ export class OrdersService { pantryId: request.pantryId, }); + if (!pantry) { + throw new NotFoundException(`Pantry ${request.pantryId} not found`); + } + return pantry; } @@ -180,7 +184,7 @@ export class OrdersService { } order.dateReceived = formattedDate; - order.feedback = dto.feedback; + order.feedback = dto.feedback ?? null; order.photos = photos; order.status = OrderStatus.DELIVERED; diff --git a/apps/backend/src/pantries/dtos/pantry-application.dto.ts b/apps/backend/src/pantries/dtos/pantry-application.dto.ts index 09943e60..3a71c176 100644 --- a/apps/backend/src/pantries/dtos/pantry-application.dto.ts +++ b/apps/backend/src/pantries/dtos/pantry-application.dto.ts @@ -23,17 +23,17 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - contactFirstName: string; + contactFirstName!: string; @IsString() @IsNotEmpty() @Length(1, 255) - contactLastName: string; + contactLastName!: string; @IsEmail() @IsNotEmpty() @Length(1, 255) - contactEmail: string; + contactEmail!: string; // This validation is very strict and won't accept phone numbers // that look right but aren't actually possible phone numbers @@ -43,10 +43,10 @@ export class PantryApplicationDto { message: 'contactPhone must be a valid phone number (make sure all the digits are correct)', }) - contactPhone: string; + contactPhone!: string; @IsBoolean() - hasEmailContact: boolean; + hasEmailContact!: boolean; @IsOptional() @IsString() @@ -83,12 +83,12 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - pantryName: string; + pantryName!: string; @IsString() @IsNotEmpty() @Length(1, 255) - shipmentAddressLine1: string; + shipmentAddressLine1!: string; @IsOptional() @IsString() @@ -99,17 +99,17 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - shipmentAddressCity: string; + shipmentAddressCity!: string; @IsString() @IsNotEmpty() @Length(1, 255) - shipmentAddressState: string; + shipmentAddressState!: string; @IsString() @IsNotEmpty() @Length(1, 255) - shipmentAddressZip: string; + shipmentAddressZip!: string; @IsOptional() @IsString() @@ -120,7 +120,7 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - mailingAddressLine1: string; + mailingAddressLine1!: string; @IsOptional() @IsString() @@ -131,17 +131,17 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - mailingAddressCity: string; + mailingAddressCity!: string; @IsString() @IsNotEmpty() @Length(1, 255) - mailingAddressState: string; + mailingAddressState!: string; @IsString() @IsNotEmpty() @Length(1, 255) - mailingAddressZip: string; + mailingAddressZip!: string; @IsOptional() @IsString() @@ -152,19 +152,19 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 25) - allergenClients: string; + allergenClients!: string; - @IsOptional() + @ArrayNotEmpty() @IsString({ each: true }) @IsNotEmpty({ each: true }) @MaxLength(255, { each: true }) - restrictions?: string[]; + restrictions!: string[]; @IsEnum(RefrigeratedDonation) - refrigeratedDonation: RefrigeratedDonation; + refrigeratedDonation!: RefrigeratedDonation; @IsBoolean() - acceptFoodDeliveries: boolean; + acceptFoodDeliveries!: boolean; @IsOptional() @IsString() @@ -172,7 +172,7 @@ export class PantryApplicationDto { deliveryWindowInstructions?: string; @IsEnum(ReserveFoodForAllergic) - reserveFoodForAllergic: ReserveFoodForAllergic; + reserveFoodForAllergic!: ReserveFoodForAllergic; // TODO: Really, this validation should be different depending on the value of reserveFoodForAllergic @IsOptional() @@ -181,7 +181,7 @@ export class PantryApplicationDto { reservationExplanation?: string; @IsBoolean() - dedicatedAllergyFriendly: boolean; + dedicatedAllergyFriendly!: boolean; @IsOptional() @IsEnum(ClientVisitFrequency) @@ -197,7 +197,7 @@ export class PantryApplicationDto { @ArrayNotEmpty() @IsEnum(Activity, { each: true }) - activities: Activity[]; + activities!: Activity[]; @IsOptional() @IsString() @@ -208,12 +208,12 @@ export class PantryApplicationDto { @IsString() @IsNotEmpty() @Length(1, 255) - itemsInStock: string; + itemsInStock!: string; @IsString() @IsNotEmpty() @Length(1, 255) - needMoreOptions: string; + needMoreOptions!: string; @IsOptional() @IsBoolean() diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index baeb8766..5e1f3bc9 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -18,6 +18,7 @@ import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { User } from '../users/user.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockPantriesService = mock(); const mockOrdersService = mock(); @@ -40,7 +41,7 @@ describe('PantriesController', () => { contactEmail: 'jane.smith@example.com', contactPhone: '(508) 222-2222', hasEmailContact: true, - emailContactOther: null, + emailContactOther: undefined, secondaryContactFirstName: 'John', secondaryContactLastName: 'Doe', secondaryContactEmail: 'john.doe@example.com', @@ -259,25 +260,23 @@ describe('PantriesController', () => { const pantry: Partial = { pantryId: 10 }; mockPantriesService.findByUserId.mockResolvedValueOnce(pantry as Pantry); - const result = await controller.getCurrentUserPantryId(req); + const result = await controller.getCurrentUserPantryId( + req as AuthenticatedRequest, + ); expect(result).toEqual(10); expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(1); }); - it('throws UnauthorizedException when unauthenticated', async () => { - await expect(controller.getCurrentUserPantryId({})).rejects.toThrow( - new UnauthorizedException('Not authenticated'), - ); - }); - it('propagates NotFoundException from service', async () => { const req = { user: { id: 999 } }; mockPantriesService.findByUserId.mockRejectedValueOnce( new NotFoundException('Pantry for User 999 not found'), ); - const promise = controller.getCurrentUserPantryId(req); + const promise = controller.getCurrentUserPantryId( + req as AuthenticatedRequest, + ); await expect(promise).rejects.toBeInstanceOf(NotFoundException); await expect(promise).rejects.toThrow('Pantry for User 999 not found'); expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(999); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index d8b4276f..746e67aa 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -7,7 +7,6 @@ import { Patch, Post, Req, - UnauthorizedException, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; @@ -27,8 +26,9 @@ import { import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { EmailsService } from '../emails/email.service'; -import { SendEmailDTO } from '../emails/types'; +import { SendEmailDTO } from '../emails/dto/send-email.dto'; import { Public } from '../auth/public.decorator'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; @Controller('pantries') export class PantriesController { @@ -40,11 +40,10 @@ export class PantriesController { @Roles(Role.PANTRY) @Get('/my-id') - async getCurrentUserPantryId(@Req() req): Promise { + async getCurrentUserPantryId( + @Req() req: AuthenticatedRequest, + ): Promise { const currentUser = req.user; - if (!currentUser) { - throw new UnauthorizedException('Not authenticated'); - } const pantry = await this.pantriesService.findByUserId(currentUser.id); return pantry.pantryId; diff --git a/apps/backend/src/pantries/pantries.entity.ts b/apps/backend/src/pantries/pantries.entity.ts index 08767f2c..395f14a0 100644 --- a/apps/backend/src/pantries/pantries.entity.ts +++ b/apps/backend/src/pantries/pantries.entity.ts @@ -20,13 +20,13 @@ import { ApplicationStatus } from '../shared/types'; @Entity('pantries') export class Pantry { @PrimaryGeneratedColumn({ name: 'pantry_id' }) - pantryId: number; + pantryId!: number; @Column({ name: 'pantry_name', type: 'varchar', length: 255 }) - pantryName: string; + pantryName!: string; @Column({ name: 'shipment_address_line_1', type: 'varchar', length: 255 }) - shipmentAddressLine1: string; + shipmentAddressLine1!: string; @Column({ name: 'shipment_address_line_2', @@ -34,16 +34,16 @@ export class Pantry { length: 255, nullable: true, }) - shipmentAddressLine2?: string; + shipmentAddressLine2!: string | null; @Column({ name: 'shipment_address_city', type: 'varchar', length: 255 }) - shipmentAddressCity: string; + shipmentAddressCity!: string; @Column({ name: 'shipment_address_state', type: 'varchar', length: 255 }) - shipmentAddressState: string; + shipmentAddressState!: string; @Column({ name: 'shipment_address_zip', type: 'varchar', length: 255 }) - shipmentAddressZip: string; + shipmentAddressZip!: string; @Column({ name: 'shipment_address_country', @@ -51,10 +51,10 @@ export class Pantry { length: 255, nullable: true, }) - shipmentAddressCountry?: string; + shipmentAddressCountry!: string | null; @Column({ name: 'mailing_address_line_1', type: 'varchar', length: 255 }) - mailingAddressLine1: string; + mailingAddressLine1!: string; @Column({ name: 'mailing_address_line_2', @@ -62,16 +62,16 @@ export class Pantry { length: 255, nullable: true, }) - mailingAddressLine2?: string; + mailingAddressLine2!: string | null; @Column({ name: 'mailing_address_city', type: 'varchar', length: 255 }) - mailingAddressCity: string; + mailingAddressCity!: string; @Column({ name: 'mailing_address_state', type: 'varchar', length: 255 }) - mailingAddressState: string; + mailingAddressState!: string; @Column({ name: 'mailing_address_zip', type: 'varchar', length: 255 }) - mailingAddressZip: string; + mailingAddressZip!: string; @Column({ name: 'mailing_address_country', @@ -79,10 +79,10 @@ export class Pantry { length: 255, nullable: true, }) - mailingAddressCountry?: string; + mailingAddressCountry!: string | null; @Column({ name: 'allergen_clients', type: 'varchar', length: 25 }) - allergenClients: string; + allergenClients!: string; @Column({ name: 'refrigerated_donation', @@ -90,17 +90,17 @@ export class Pantry { enum: RefrigeratedDonation, enumName: 'refrigerated_donation_enum', }) - refrigeratedDonation: RefrigeratedDonation; + refrigeratedDonation!: RefrigeratedDonation; @Column({ name: 'accept_food_deliveries', type: 'boolean' }) - acceptFoodDeliveries: boolean; + acceptFoodDeliveries!: boolean; @Column({ name: 'delivery_window_instructions', type: 'text', nullable: true, }) - deliveryWindowInstructions?: string; + deliveryWindowInstructions!: string | null; @Column({ name: 'reserve_food_for_allergic', @@ -108,16 +108,16 @@ export class Pantry { enum: ReserveFoodForAllergic, enumName: 'reserve_food_for_allergic_enum', }) - reserveFoodForAllergic: string; + reserveFoodForAllergic!: ReserveFoodForAllergic; @Column({ name: 'reservation_explanation', type: 'text', nullable: true }) - reservationExplanation?: string; + reservationExplanation!: string | null; @Column({ name: 'dedicated_allergy_friendly', type: 'boolean', }) - dedicatedAllergyFriendly: boolean; + dedicatedAllergyFriendly!: boolean; @Column({ name: 'client_visit_frequency', @@ -126,7 +126,7 @@ export class Pantry { enumName: 'client_visit_frequency_enum', nullable: true, }) - clientVisitFrequency?: ClientVisitFrequency; + clientVisitFrequency!: ClientVisitFrequency | null; @Column({ name: 'identify_allergens_confidence', @@ -135,7 +135,7 @@ export class Pantry { enumName: 'allergens_confidence_enum', nullable: true, }) - identifyAllergensConfidence?: AllergensConfidence; + identifyAllergensConfidence!: AllergensConfidence | null; @Column({ name: 'serve_allergic_children', @@ -144,19 +144,19 @@ export class Pantry { enumName: 'serve_allergic_children_enum', nullable: true, }) - serveAllergicChildren?: ServeAllergicChildren; + serveAllergicChildren!: ServeAllergicChildren | null; @Column({ name: 'newsletter_subscription', type: 'boolean', nullable: true }) - newsletterSubscription?: boolean; + newsletterSubscription!: boolean | null; @Column({ name: 'restrictions', type: 'text', array: true }) - restrictions: string[]; + restrictions!: string[]; @Column({ name: 'has_email_contact', type: 'boolean' }) - hasEmailContact: boolean; + hasEmailContact!: boolean; @Column({ name: 'email_contact_other', type: 'text', nullable: true }) - emailContactOther?: string; + emailContactOther!: string | null; @Column({ name: 'secondary_contact_first_name', @@ -164,7 +164,7 @@ export class Pantry { length: 255, nullable: true, }) - secondaryContactFirstName?: string; + secondaryContactFirstName!: string | null; @Column({ name: 'secondary_contact_last_name', @@ -172,7 +172,7 @@ export class Pantry { length: 255, nullable: true, }) - secondaryContactLastName?: string; + secondaryContactLastName!: string | null; @Column({ name: 'secondary_contact_email', @@ -180,7 +180,7 @@ export class Pantry { length: 255, nullable: true, }) - secondaryContactEmail?: string; + secondaryContactEmail!: string | null; @Column({ name: 'secondary_contact_phone', @@ -188,7 +188,7 @@ export class Pantry { length: 20, nullable: true, }) - secondaryContactPhone?: string; + secondaryContactPhone!: string | null; // cascade: ['insert'] means that when we create a new // pantry, the pantry user will automatically be added @@ -202,7 +202,7 @@ export class Pantry { name: 'pantry_user_id', referencedColumnName: 'id', }) - pantryUser: User; + pantryUser!: User; @Column({ name: 'status', @@ -210,14 +210,14 @@ export class Pantry { enum: ApplicationStatus, enumName: 'application_status_enum', }) - status: ApplicationStatus; + status!: ApplicationStatus; @Column({ name: 'date_applied', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', }) - dateApplied: Date; + dateApplied!: Date; @Column({ name: 'activities', @@ -226,17 +226,17 @@ export class Pantry { enumName: 'activity_enum', array: true, }) - activities: Activity[]; + activities!: Activity[]; @Column({ name: 'activities_comments', type: 'text', nullable: true }) - activitiesComments?: string; + activitiesComments!: string | null; @Column({ name: 'items_in_stock', type: 'text' }) - itemsInStock: string; + itemsInStock!: string; @Column({ name: 'need_more_options', type: 'text' }) - needMoreOptions: string; + needMoreOptions!: string; @ManyToMany(() => User, (user) => user.pantries) - volunteers?: User[]; + volunteers!: User[] | null; } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 1b54f4f3..8163c1eb 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PantriesService } from './pantries.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Pantry } from './pantries.entity'; -import { Repository } from 'typeorm'; +import { Repository, UpdateResult } from 'typeorm'; import { NotFoundException } from '@nestjs/common'; import { mock } from 'jest-mock-extended'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; @@ -35,7 +35,7 @@ describe('PantriesService', () => { contactEmail: 'jane.smith@example.com', contactPhone: '(508) 222-2222', hasEmailContact: true, - emailContactOther: null, + emailContactOther: undefined, secondaryContactFirstName: 'John', secondaryContactLastName: 'Doe', secondaryContactEmail: 'john.doe@example.com', @@ -164,7 +164,7 @@ describe('PantriesService', () => { describe('approve', () => { it('should approve a pantry', async () => { mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); - mockRepository.update.mockResolvedValueOnce(undefined); + mockRepository.update.mockResolvedValueOnce({} as UpdateResult); await service.approve(1); @@ -191,7 +191,7 @@ describe('PantriesService', () => { describe('deny', () => { it('should deny a pantry', async () => { mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); - mockRepository.update.mockResolvedValueOnce(undefined); + mockRepository.update.mockResolvedValueOnce({} as UpdateResult); await service.deny(1); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 24238e92..8755c419 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -46,29 +46,31 @@ export class PantriesService { pantry.pantryUser = pantryContact; pantry.hasEmailContact = pantryData.hasEmailContact; - pantry.emailContactOther = pantryData.emailContactOther; + pantry.emailContactOther = pantryData.emailContactOther ?? null; // secondary contact information - pantry.secondaryContactFirstName = pantryData.secondaryContactFirstName; - pantry.secondaryContactLastName = pantryData.secondaryContactLastName; - pantry.secondaryContactEmail = pantryData.secondaryContactEmail; - pantry.secondaryContactPhone = pantryData.secondaryContactPhone; + pantry.secondaryContactFirstName = + pantryData.secondaryContactFirstName ?? null; + pantry.secondaryContactLastName = + pantryData.secondaryContactLastName ?? null; + pantry.secondaryContactEmail = pantryData.secondaryContactEmail ?? null; + pantry.secondaryContactPhone = pantryData.secondaryContactPhone ?? null; // food shipment address information pantry.shipmentAddressLine1 = pantryData.shipmentAddressLine1; - pantry.shipmentAddressLine2 = pantryData.shipmentAddressLine2; + pantry.shipmentAddressLine2 = pantryData.shipmentAddressLine2 ?? null; pantry.shipmentAddressCity = pantryData.shipmentAddressCity; pantry.shipmentAddressState = pantryData.shipmentAddressState; pantry.shipmentAddressZip = pantryData.shipmentAddressZip; - pantry.shipmentAddressCountry = pantryData.shipmentAddressCountry; + pantry.shipmentAddressCountry = pantryData.shipmentAddressCountry ?? null; // mailing address information pantry.mailingAddressLine1 = pantryData.mailingAddressLine1; - pantry.mailingAddressLine2 = pantryData.mailingAddressLine2; + pantry.mailingAddressLine2 = pantryData.mailingAddressLine2 ?? null; pantry.mailingAddressCity = pantryData.mailingAddressCity; pantry.mailingAddressState = pantryData.mailingAddressState; pantry.mailingAddressZip = pantryData.mailingAddressZip; - pantry.mailingAddressCountry = pantryData.mailingAddressCountry; + pantry.mailingAddressCountry = pantryData.mailingAddressCountry ?? null; // pantry details information pantry.pantryName = pantryData.pantryName; @@ -77,15 +79,16 @@ export class PantriesService { pantry.refrigeratedDonation = pantryData.refrigeratedDonation; pantry.dedicatedAllergyFriendly = pantryData.dedicatedAllergyFriendly; pantry.reserveFoodForAllergic = pantryData.reserveFoodForAllergic; - pantry.reservationExplanation = pantryData.reservationExplanation; - pantry.clientVisitFrequency = pantryData.clientVisitFrequency; - pantry.identifyAllergensConfidence = pantryData.identifyAllergensConfidence; - pantry.serveAllergicChildren = pantryData.serveAllergicChildren; + pantry.reservationExplanation = pantryData.reservationExplanation ?? null; + pantry.clientVisitFrequency = pantryData.clientVisitFrequency ?? null; + pantry.identifyAllergensConfidence = + pantryData.identifyAllergensConfidence ?? null; + pantry.serveAllergicChildren = pantryData.serveAllergicChildren ?? null; pantry.activities = pantryData.activities; - pantry.activitiesComments = pantryData.activitiesComments; + pantry.activitiesComments = pantryData.activitiesComments ?? null; pantry.itemsInStock = pantryData.itemsInStock; pantry.needMoreOptions = pantryData.needMoreOptions; - pantry.newsletterSubscription = pantryData.newsletterSubscription; + pantry.newsletterSubscription = pantryData.newsletterSubscription ?? null; // pantry contact is automatically added to User table await this.repo.save(pantry); diff --git a/apps/backend/src/users/dtos/userSchema.dto.ts b/apps/backend/src/users/dtos/userSchema.dto.ts index ac8cd1ec..2f110c0b 100644 --- a/apps/backend/src/users/dtos/userSchema.dto.ts +++ b/apps/backend/src/users/dtos/userSchema.dto.ts @@ -12,17 +12,17 @@ export class userSchemaDto { @IsEmail() @IsNotEmpty() @Length(1, 255) - email: string; + email!: string; @IsString() @IsNotEmpty() @Length(1, 255) - firstName: string; + firstName!: string; @IsString() @IsNotEmpty() @Length(1, 255) - lastName: string; + lastName!: string; @IsString() @IsNotEmpty() @@ -30,8 +30,8 @@ export class userSchemaDto { message: 'phone must be a valid phone number (make sure all the digits are correct)', }) - phone: string; + phone!: string; @IsEnum(Role) - role: Role; + role!: Role; } diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 4481b22d..14641579 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -12,7 +12,7 @@ import { Pantry } from '../pantries/pantries.entity'; @Entity() export class User { @PrimaryGeneratedColumn({ name: 'user_id' }) - id: number; + id!: number; @Column({ type: 'enum', @@ -21,22 +21,22 @@ export class User { enumName: 'users_role_enum', default: Role.VOLUNTEER, }) - role: Role; + role!: Role; @Column() - firstName: string; + firstName!: string; @Column() - lastName: string; + lastName!: string; @Column() - email: string; + email!: string; @Column({ type: 'varchar', length: 20, }) - phone: string; + phone!: string; @Column({ type: 'varchar', @@ -44,7 +44,7 @@ export class User { name: 'user_cognito_sub', default: '', }) - userCognitoSub: string; + userCognitoSub!: string; @ManyToMany(() => Pantry, (pantry) => pantry.volunteers) @JoinTable({ @@ -58,5 +58,5 @@ export class User { referencedColumnName: 'pantryId', }, }) - pantries?: Pantry[]; + pantries!: Pantry[] | null; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 97811a0e..f373f5cb 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -257,8 +257,8 @@ describe('UsersController', () => { expect(result).toEqual(updatedUser); expect(result.pantries).toHaveLength(2); - expect(result.pantries[0].pantryId).toBe(1); - expect(result.pantries[1].pantryId).toBe(3); + expect(result.pantries![0]!.pantryId).toBe(1); + expect(result.pantries![1]!.pantryId).toBe(3); expect(mockUserService.assignPantriesToVolunteer).toHaveBeenCalledWith( 3, pantryIds, diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 8432ba1a..bff9435e 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -115,14 +115,14 @@ export class UsersService { const { pantries, ...volunteerWithoutPantries } = v; return { ...volunteerWithoutPantries, - pantryIds: pantries.map((p) => p.pantryId), + pantryIds: (pantries ?? []).map((p) => p.pantryId), }; }); } async getVolunteerPantries(volunteerId: number): Promise { const volunteer = await this.findVolunteer(volunteerId); - return volunteer.pantries; + return volunteer.pantries ?? []; } async assignPantriesToVolunteer( @@ -134,12 +134,12 @@ export class UsersService { const volunteer = await this.findVolunteer(volunteerId); const pantries = await this.pantriesService.findByIds(pantryIds); - const existingPantryIds = volunteer.pantries.map((p) => p.pantryId); + const existingPantryIds = (volunteer.pantries ?? []).map((p) => p.pantryId); const newPantries = pantries.filter( (p) => !existingPantryIds.includes(p.pantryId), ); - volunteer.pantries = [...volunteer.pantries, ...newPantries]; + volunteer.pantries = [...(volunteer.pantries ?? []), ...newPantries]; return this.repo.save(volunteer); } diff --git a/apps/backend/src/utils/validation.utils.spec.ts b/apps/backend/src/utils/validation.utils.spec.ts index 6b390840..01c1f790 100644 --- a/apps/backend/src/utils/validation.utils.spec.ts +++ b/apps/backend/src/utils/validation.utils.spec.ts @@ -1,5 +1,8 @@ -import { BadRequestException } from '@nestjs/common'; -import { validateId } from './validation.utils'; +import { + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { validateEnv, validateId } from './validation.utils'; describe('validateId', () => { it('should not throw an error for a valid ID', () => { @@ -12,3 +15,27 @@ describe('validateId', () => { ); }); }); + +describe('validateEnv', () => { + const ENV_NAME = 'TEST_ENV_VAR'; + + afterEach(() => { + delete process.env[ENV_NAME]; + }); + + it('should return the env variable value if it exists', () => { + process.env[ENV_NAME] = 'some-value'; + + const result = validateEnv(ENV_NAME); + + expect(result).toBe('some-value'); + }); + + it('should throw InternalServerErrorException if env variable is missing', () => { + delete process.env[ENV_NAME]; + + expect(() => validateEnv(ENV_NAME)).toThrow( + new InternalServerErrorException(`Missing env var: ${ENV_NAME}`), + ); + }); +}); diff --git a/apps/backend/src/utils/validation.utils.ts b/apps/backend/src/utils/validation.utils.ts index 5c4f14a7..be7f141a 100644 --- a/apps/backend/src/utils/validation.utils.ts +++ b/apps/backend/src/utils/validation.utils.ts @@ -1,7 +1,20 @@ -import { BadRequestException } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; export function validateId(id: number, entityName: string): void { if (!id || id < 1) { throw new BadRequestException(`Invalid ${entityName} ID`); } } + +export function validateEnv(name: string): string { + const v = process.env[name]; + + if (!v) { + throw new InternalServerErrorException(`Missing env var: ${name}`); + } + + return v; +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index c1e2dd4e..0e3d25aa 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -11,6 +11,7 @@ } ], "compilerOptions": { - "esModuleInterop": true + "esModuleInterop": true, + "strict": true } } diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 96ae9b8c..07216c6a 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -64,7 +64,7 @@ const FoodRequestFormModal: React.FC = ({ const foodRequestData: CreateFoodRequestBody = { pantryId, requestedSize: requestedSize as RequestSize, - additionalInformation: additionalNotes || '', + additionalInformation: additionalNotes || undefined, requestedItems: selectedItems, }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index f5555dc0..7bceeff4 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -84,7 +84,7 @@ export interface PantryApplicationDto { mailingAddressZip: string; mailingAddressCountry?: string; allergenClients: string; - restrictions?: string[]; + restrictions: string[]; refrigeratedDonation: RefrigeratedDonation; acceptFoodDeliveries: boolean; deliveryWindowInstructions?: string; @@ -101,6 +101,13 @@ export interface PantryApplicationDto { newsletterSubscription?: string; } +export interface CreateRequestDto { + pantryId: number; + requestedSize: RequestSize; + requestedItems: FoodType[]; + additionalInformation?: string; +} + export enum DonationStatus { MATCHED = 'matched', AVAILABLE = 'available', @@ -198,7 +205,7 @@ export interface FoodRequest { pantry: Pantry; requestedSize: RequestSize; requestedItems: string[]; - additionalInformation: string | null; + additionalInformation?: string; requestedAt: string; status: FoodRequestStatus; orders?: Order[]; @@ -265,7 +272,7 @@ export interface CreateFoodRequestBody { pantryId: number; requestedSize: RequestSize; requestedItems: string[]; - additionalInformation?: string | null; + additionalInformation?: string; } export interface CreateMultipleDonationItemsBody {