diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 5938297a..b4c465a2 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -25,6 +25,7 @@ import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-R import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders'; +import { AddDonationRecurrenceFields1770080947285 } from '../migrations/1770080947285-AddDonationRecurrenceFields'; import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity'; @@ -57,6 +58,7 @@ const schemaMigrations = [ RemoveUnusedStatuses1764816885341, PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, + AddDonationRecurrenceFields1770080947285, UpdateManufacturerEntity1768680807820, AddUserPoolId1769189327767, UpdateOrderEntity1769990652833, diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 6bcd2a7e..e3b0ffcb 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -12,7 +12,8 @@ import { import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; -import { DonationStatus } from './types'; +import { DonationStatus, RecurrenceEnum } from './types'; +import { CreateDonationDto } from './dtos/create-donation.dto'; @Controller('donations') export class DonationsController { @@ -52,36 +53,29 @@ export class DonationsController { example: DonationStatus.AVAILABLE, }, totalItems: { type: 'integer', example: 100 }, - totalOz: { type: 'integer', example: 500 }, - totalEstimatedValue: { type: 'integer', example: 1000 }, + totalOz: { type: 'number', example: 100.5 }, + totalEstimatedValue: { type: 'number', example: 100.5 }, + recurrence: { + type: 'string', + enum: Object.values(RecurrenceEnum), + example: RecurrenceEnum.NONE, + }, + recurrenceFreq: { type: 'integer', example: 1, nullable: true }, + nextDonationDates: { + type: 'array', + items: { type: 'string', format: 'date-time' }, + example: ['2024-07-01T00:00:00Z', '2024-08-01T00:00:00Z'], + nullable: true, + }, + occurrencesRemaining: { type: 'integer', example: 2, nullable: true }, }, }, }) async createDonation( @Body() - body: { - foodManufacturerId: number; - dateDonated: Date; - status: DonationStatus; - totalItems: number; - totalOz: number; - totalEstimatedValue: number; - }, + body: CreateDonationDto, ): Promise { - if ( - body.status && - !Object.values(DonationStatus).includes(body.status as DonationStatus) - ) { - throw new BadRequestException('Invalid status'); - } - return this.donationService.create( - body.foodManufacturerId, - body.dateDonated, - body.status ?? DonationStatus.AVAILABLE, - body.totalItems, - body.totalOz, - body.totalEstimatedValue, - ); + return this.donationService.create(body); } @Patch('/:donationId/fulfill') diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 3666c56b..854401b3 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -6,26 +6,26 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; +import { DonationStatus, RecurrenceEnum } from './types'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { DonationStatus } from './types'; @Entity('donations') export class Donation { @PrimaryGeneratedColumn({ name: 'donation_id' }) - donationId: number; + donationId!: number; @ManyToOne(() => FoodManufacturer, (manufacturer) => manufacturer.donations, { nullable: false, }) @JoinColumn({ name: 'food_manufacturer_id' }) - foodManufacturer: FoodManufacturer; + foodManufacturer!: FoodManufacturer; @CreateDateColumn({ name: 'date_donated', type: 'timestamp', default: () => 'NOW()', }) - dateDonated: Date; + dateDonated!: Date; @Column({ name: 'status', @@ -34,14 +34,37 @@ export class Donation { enumName: 'donations_status_enum', default: DonationStatus.AVAILABLE, }) - status: DonationStatus; + status!: DonationStatus; @Column({ name: 'total_items', type: 'int', nullable: true }) - totalItems: number; + totalItems?: number; - @Column({ name: 'total_oz', type: 'int', nullable: true }) - totalOz: number; + @Column({ name: 'total_oz', type: 'numeric', nullable: true }) + totalOz?: number; - @Column({ name: 'total_estimated_value', type: 'int', nullable: true }) - totalEstimatedValue: number; + @Column({ name: 'total_estimated_value', type: 'numeric', nullable: true }) + totalEstimatedValue?: number; + + @Column({ + name: 'recurrence', + type: 'enum', + enum: RecurrenceEnum, + enumName: 'donation_recurrence_enum', + default: RecurrenceEnum.NONE, + }) + recurrence!: RecurrenceEnum; + + @Column({ name: 'recurrence_freq', type: 'int', nullable: true }) + recurrenceFreq?: number; + + @Column({ + name: 'next_donation_dates', + type: 'timestamptz', + array: true, + nullable: true, + }) + nextDonationDates?: Date[]; + + @Column({ name: 'occurrences_remaining', type: 'int', nullable: true }) + occurrencesRemaining?: number; } diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 44793840..1dd719d5 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -3,8 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; -import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationStatus } from './types'; +import { CreateDonationDto } from './dtos/create-donation.dto'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Injectable() export class DonationService { @@ -38,31 +39,28 @@ export class DonationService { return this.repo.count(); } - async create( - foodManufacturerId: number, - dateDonated: Date, - status: DonationStatus, - totalItems: number, - totalOz: number, - totalEstimatedValue: number, - ) { - validateId(foodManufacturerId, 'Food Manufacturer'); + async create(donationData: CreateDonationDto): Promise { + validateId(donationData.foodManufacturerId, 'Food Manufacturer'); const manufacturer = await this.manufacturerRepo.findOne({ - where: { foodManufacturerId }, + where: { foodManufacturerId: donationData.foodManufacturerId }, }); if (!manufacturer) { throw new NotFoundException( - `Food Manufacturer ${foodManufacturerId} not found`, + `Food Manufacturer ${donationData.foodManufacturerId} not found`, ); } const donation = this.repo.create({ foodManufacturer: manufacturer, - dateDonated, - status, - totalItems, - totalOz, - totalEstimatedValue, + dateDonated: donationData.dateDonated, + status: donationData.status, + totalItems: donationData.totalItems, + totalOz: donationData.totalOz, + totalEstimatedValue: donationData.totalEstimatedValue, + recurrence: donationData.recurrence, + recurrenceFreq: donationData.recurrenceFreq, + nextDonationDates: donationData.nextDonationDates, + occurrencesRemaining: donationData.occurrencesRemaining, }); return this.repo.save(donation); diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts new file mode 100644 index 00000000..771ba2e6 --- /dev/null +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -0,0 +1,64 @@ +import { + ArrayNotEmpty, + IsArray, + IsDate, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + Min, + ValidateIf, +} from 'class-validator'; +import { DonationStatus, RecurrenceEnum } from '../types'; +import { Type } from 'class-transformer'; + +export class CreateDonationDto { + @IsNumber() + @Min(1) + foodManufacturerId!: number; + + @Type(() => Date) + @IsDate() + @IsNotEmpty() + dateDonated!: Date; + + @IsNotEmpty() + @IsEnum(DonationStatus) + status!: DonationStatus; + + @IsNumber() + @Min(1) + @IsOptional() + totalItems?: number; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @IsOptional() + totalOz?: number; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @IsOptional() + totalEstimatedValue?: number; + + @IsNotEmpty() + @IsEnum(RecurrenceEnum) + recurrence!: RecurrenceEnum; + + @IsNumber() + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + @Min(1) + recurrenceFreq?: number; + + @Type(() => Date) + @IsArray() + @ArrayNotEmpty() + @IsDate({ each: true }) + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + nextDonationDates?: Date[]; + + @IsNumber() + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + @Min(1) + occurrencesRemaining?: number; +} diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index 16387987..cb63fda3 100644 --- a/apps/backend/src/donations/types.ts +++ b/apps/backend/src/donations/types.ts @@ -3,3 +3,10 @@ export enum DonationStatus { FULFILLED = 'fulfilled', MATCHING = 'matching', } + +export enum RecurrenceEnum { + NONE = 'none', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + YEARLY = 'yearly', +} diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 45b0d2d2..de62ab82 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -192,8 +192,7 @@ describe('RequestsController', () => { }; // Mock Photos - const mockStream = new Readable(); - mockStream._read = () => {}; + const mockStream = {} as Readable; const photos: Express.Multer.File[] = [ { diff --git a/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts b/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts new file mode 100644 index 00000000..25c28622 --- /dev/null +++ b/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDonationRecurrenceFields1770080947285 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE donation_recurrence_enum AS ENUM ( + 'none', + 'weekly', + 'monthly', + 'yearly' + ); + `); + + await queryRunner.query(` + ALTER TABLE donations + ADD COLUMN recurrence donation_recurrence_enum NOT NULL DEFAULT 'none', + ADD COLUMN recurrence_freq INTEGER, + ADD COLUMN next_donation_dates TIMESTAMP WITH TIME ZONE[], + ADD COLUMN occurrences_remaining INTEGER; + `); + + await queryRunner.query(` + ALTER TABLE donations + ADD CONSTRAINT recurrence_fields_not_null CHECK ( + (recurrence = 'none' + AND recurrence_freq IS NULL + AND next_donation_dates IS NULL + AND occurrences_remaining IS NULL) + OR + (recurrence != 'none' + AND recurrence_freq IS NOT NULL + AND next_donation_dates IS NOT NULL + AND occurrences_remaining IS NOT NULL) + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donations + DROP CONSTRAINT recurrence_fields_not_null, + DROP COLUMN recurrence, + DROP COLUMN recurrence_freq, + DROP COLUMN next_donation_dates, + DROP COLUMN occurrences_remaining; + + DROP TYPE donation_recurrence_enum; + `); + } +} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 09a31745..df8e611e 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -177,9 +177,7 @@ export class ApiClient { userId: number, body: { role: string }, ): Promise { - return this.axiosInstance - .put(`/api/users/${userId}/role`, body) - .then(() => {}); + await this.axiosInstance.put(`/api/users/${userId}/role`, body); } public async getOrderFoodRequest(requestId: number): Promise { diff --git a/apps/frontend/src/components/forms/pantryApplicationForm.tsx b/apps/frontend/src/components/forms/pantryApplicationForm.tsx index a132376d..0c831b9d 100644 --- a/apps/frontend/src/components/forms/pantryApplicationForm.tsx +++ b/apps/frontend/src/components/forms/pantryApplicationForm.tsx @@ -70,7 +70,7 @@ const PantryApplicationForm: React.FC = () => { const [secondaryContactPhone, setSecondaryContactPhone] = useState(''); const [activities, setActivities] = useState([]); - const allergenClientsExactOption: string = 'I have an exact number'; + const allergenClientsExactOption = 'I have an exact number'; const [allergenClients, setAllergenClients] = useState(); const [restrictions, setRestrictions] = useState([]); @@ -1226,7 +1226,7 @@ export const submitPantryApplicationForm: ActionFunction = async ({ const data = Object.fromEntries(pantryApplicationData); - let submissionSuccessful: boolean = false; + let submissionSuccessful = false; await ApiClient.postPantry(data as PantryApplicationDto).then( () => (submissionSuccessful = true), diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 7fc911e6..76f6f3d8 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -102,6 +102,13 @@ export enum DonationStatus { MATCHING = 'matching', } +export enum RecurrenceEnum { + NONE = 'none', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + YEARLY = 'yearly', +} + export interface Donation { donationId: number; dateDonated: string; @@ -110,6 +117,10 @@ export interface Donation { totalOz: number; totalEstimatedValue: number; foodManufacturer?: FoodManufacturer; + recurrence: RecurrenceEnum; + recurrenceFreq?: number; + nextDonationDates?: string[]; + occurrencesRemaining?: number; } export interface DonationItem {