diff --git a/backend/package-lock.json b/backend/package-lock.json index 298d74e1..dbe8c949 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.4", "@nestjs/typeorm": "^11.0.0", "@types/multer": "^2.0.0", @@ -34,6 +35,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/jest": "^30.0.0", "@types/node": "^22.0.0", "@types/passport-jwt": "^4.0.1", "@types/streamifier": "^0.1.2", @@ -1991,6 +1993,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", + "integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.9", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", @@ -2439,6 +2454,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2456,6 +2482,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -4086,6 +4118,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6401,6 +6450,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/backend/package.json b/backend/package.json index 2d656b49..a7131530 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,8 @@ "start": "node dist/main", "start:dev": "ts-node -r tsconfig-paths/register src/main.ts", "start:debug": "ts-node --inspect -r tsconfig-paths/register src/main.ts", - "start:prod": "node dist/main" + "start:prod": "node dist/main", + "test": "jest" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -17,6 +18,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.4", "@nestjs/typeorm": "^11.0.0", "@types/multer": "^2.0.0", @@ -37,6 +39,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/jest": "^30.0.0", "@types/node": "^22.0.0", "@types/passport-jwt": "^4.0.1", "@types/streamifier": "^0.1.2", @@ -45,5 +48,22 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts index d22f3890..e33062c0 100644 --- a/backend/src/app.controller.spec.ts +++ b/backend/src/app.controller.spec.ts @@ -15,8 +15,8 @@ describe('AppController', () => { }); describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + it('should return "Food Redistribution Platform API"', () => { + expect(appController.getHello()).toBe('Food Redistribution Platform API'); }); }); }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a5a8c165..00878678 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,19 +1,24 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { DonationsModule } from './donations/donations.module'; import { User } from './auth/entities/user.entity'; import { Donation } from './donations/entities/donation.entity'; +import { LoadResetService } from './tasks/load-reset.service'; @Module({ imports: [ // 1. Load .env variables ConfigModule.forRoot({ isGlobal: true }), - // 2. Connect to Postgres (using variables from docker-compose) + // 2. Enable Cron Jobs + ScheduleModule.forRoot(), + + // 3. Connect to Postgres (using variables from docker-compose) TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DATABASE_HOST || 'postgres', @@ -29,6 +34,6 @@ import { Donation } from './donations/entities/donation.entity'; DonationsModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, LoadResetService], }) -export class AppModule {} \ No newline at end of file +export class AppModule { } \ No newline at end of file diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..a399091c --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../entities/user.entity'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 00000000..49676131 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,26 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '../entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) { } + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + + if (!user || !requiredRoles.includes(user.role)) { + throw new ForbiddenException('You do not have the required permissions to access this resource'); + } + + return true; + } +} diff --git a/backend/src/donations/donations.controller.ts b/backend/src/donations/donations.controller.ts index 08188265..ab0609ea 100644 --- a/backend/src/donations/donations.controller.ts +++ b/backend/src/donations/donations.controller.ts @@ -1,13 +1,18 @@ -import { Controller, Get, Post, Patch, Param, Body, Query, UseInterceptors, UploadedFiles, UseGuards, Req } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, Query, UseInterceptors, UploadedFiles, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { DonationsService } from './donations.service'; import { CloudinaryService } from '../common/cloudinary.service'; -import { CreateDonationDto, ClaimDonationDto, UpdateDonationStatusDto } from './dto/donations.dto'; +import { CreateDonationDto, ClaimDonationDto, UpdateDonationStatusDto, UpdateDonationDto } from './dto/donations.dto'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiConsumes, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/entities/user.entity'; @ApiTags('Donations') @Controller('donations') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() export class DonationsController { constructor( private readonly donationsService: DonationsService, @@ -15,19 +20,12 @@ export class DonationsController { ) { } @Post() - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() + @Roles(UserRole.DONOR, UserRole.ADMIN) @ApiOperation({ summary: 'Create a new food donation' }) - @ApiResponse({ - status: 201, - description: 'Donation created successfully' - }) - @ApiResponse({ - status: 400, - description: 'Validation error' - }) + @ApiResponse({ status: 201, description: 'Donation created successfully' }) + @ApiResponse({ status: 400, description: 'Validation error' }) @ApiConsumes('multipart/form-data') - @UseInterceptors(FilesInterceptor('images', 5)) // Allow up to 5 images + @UseInterceptors(FilesInterceptor('images', 5)) async create( @Body() createDonationDto: CreateDonationDto, @UploadedFiles() files: Array, @@ -42,28 +40,10 @@ export class DonationsController { @Get() @ApiOperation({ summary: 'Get all available food donations' }) - @ApiQuery({ - name: 'latitude', - required: false, - type: Number, - description: 'NGO latitude for distance filtering' - }) - @ApiQuery({ - name: 'longitude', - required: false, - type: Number, - description: 'NGO longitude for distance filtering' - }) - @ApiQuery({ - name: 'radius', - required: false, - type: Number, - description: 'Search radius in km (default: 5)' - }) - @ApiResponse({ - status: 200, - description: 'List of available donations' - }) + @ApiQuery({ name: 'latitude', required: false, type: Number }) + @ApiQuery({ name: 'longitude', required: false, type: Number }) + @ApiQuery({ name: 'radius', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'List of available donations' }) findAll( @Query('latitude') latitude?: number, @Query('longitude') longitude?: number, @@ -73,21 +53,11 @@ export class DonationsController { } @Patch(':id/claim') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() + @Roles(UserRole.NGO) @ApiOperation({ summary: 'Claim a food donation (NGO only)' }) - @ApiResponse({ - status: 200, - description: 'Donation claimed successfully' - }) - @ApiResponse({ - status: 400, - description: 'Donation already claimed' - }) - @ApiResponse({ - status: 404, - description: 'Donation not found' - }) + @ApiResponse({ status: 200, description: 'Donation claimed successfully' }) + @ApiResponse({ status: 400, description: 'Donation already claimed' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) claim( @Param('id') id: string, @Body() claimDto: ClaimDonationDto, @@ -96,51 +66,78 @@ export class DonationsController { return this.donationsService.claim(id, claimDto, req.user.userId); } - @Patch(':id/status') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ summary: 'Update food donation status' }) - @ApiResponse({ - status: 200, - description: 'Donation status updated successfully' - }) - @ApiResponse({ - status: 400, - description: 'Invalid status or unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'Donation not found' - }) - updateStatus( + @Patch(':id/pickup') + @Roles(UserRole.VOLUNTEER) + @ApiOperation({ summary: 'Confirm food donation pickup (Volunteer only)' }) + @ApiResponse({ status: 200, description: 'Donation picked up successfully' }) + @ApiResponse({ status: 400, description: 'Invalid state or unauthorized' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + pickup( @Param('id') id: string, - @Body() updateDto: UpdateDonationStatusDto, @Req() req: any, ) { - return this.donationsService.updateStatus(id, updateDto.status, req.user.userId); + return this.donationsService.pickup(id, req.user.userId); } @Patch(':id/deliver') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ summary: 'Mark a food donation as delivered' }) - @ApiResponse({ - status: 200, - description: 'Donation marked as delivered successfully' - }) - @ApiResponse({ - status: 400, - description: 'Donation already delivered or mismatch' - }) - @ApiResponse({ - status: 404, - description: 'Donation not found' - }) - markAsDelivered( + @Roles(UserRole.VOLUNTEER) + @ApiOperation({ summary: 'Confirm food donation delivery (Volunteer only)' }) + @ApiResponse({ status: 200, description: 'Donation delivered successfully' }) + @ApiResponse({ status: 400, description: 'Invalid state or unauthorized' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + deliver( + @Param('id') id: string, + @Req() req: any, + ) { + return this.donationsService.deliver(id, req.user.userId); + } + + @Post(':id/image') + @Roles(UserRole.DONOR) + @ApiOperation({ summary: 'Upload an image for a food donation (Donor only)' }) + @ApiResponse({ status: 201, description: 'Image uploaded successfully' }) + @ApiResponse({ status: 403, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FilesInterceptor('image', 1)) + async uploadImage( + @Param('id') id: string, + @UploadedFiles() files: Array, + @Req() req: any, + ) { + if (!files || files.length === 0) { + throw new BadRequestException('No image file provided'); + } + const imageUrls = await this.cloudinaryService.uploadImages(files); + return this.donationsService.updateImage(id, imageUrls[0], req.user.userId); + } + + @Patch(':id') + @Roles(UserRole.DONOR) + @ApiOperation({ summary: 'Update food donation details (Donor only)' }) + @ApiResponse({ status: 200, description: 'Donation updated successfully' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + update( @Param('id') id: string, + @Body() updateDto: UpdateDonationDto, @Req() req: any, ) { - return this.donationsService.markAsDelivered(id, req.user.userId); + return this.donationsService.update(id, updateDto, req.user.userId); + } + + @Patch(':id/status') + @Roles(UserRole.ADMIN) // Restrict general status updates to ADMIN at controller level + @ApiOperation({ summary: 'Update food donation status (Admin only)' }) + @ApiResponse({ status: 200, description: 'Donation status updated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + updateStatus( + @Param('id') id: string, + @Body() updateDto: UpdateDonationStatusDto, + @Req() req: any, + ) { + return this.donationsService.updateStatus(id, updateDto.status, req.user.userId); } } \ No newline at end of file diff --git a/backend/src/donations/donations.service.spec.ts b/backend/src/donations/donations.service.spec.ts new file mode 100644 index 00000000..1d6e9801 --- /dev/null +++ b/backend/src/donations/donations.service.spec.ts @@ -0,0 +1,145 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DonationsService } from './donations.service'; +import { Donation, DonationStatus } from './entities/donation.entity'; +import { User, UserRole } from '../auth/entities/user.entity'; +import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; + +describe('DonationsService', () => { + let service: DonationsService; + let donationRepository; + let userRepository; + + const mockDonationRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + manager: { + transaction: jest.fn(), + }, + }; + + const mockUserRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DonationsService, + { + provide: getRepositoryToken(Donation), + useValue: mockDonationRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(DonationsService); + donationRepository = module.get(getRepositoryToken(Donation)); + userRepository = module.get(getRepositoryToken(User)); + }); + + describe('validateFoodSafety', () => { + it('should throw BadRequestException if expiry time is in the past', () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 1000).toISOString(); + const dto: any = { + expiryTime: pastDate, + preparationTime: now.toISOString(), + foodType: 'cooked', + }; + + expect(() => service['validateFoodSafety'](dto)).toThrow(BadRequestException); + expect(() => service['validateFoodSafety'](dto)).toThrow('Donation expiry time cannot be in the past'); + }); + + it('should throw BadRequestException if preparation time is in the future', () => { + const now = new Date(); + const futureDate = new Date(now.getTime() + 10000).toISOString(); + const dto: any = { + expiryTime: new Date(now.getTime() + 100000).toISOString(), + preparationTime: futureDate, + foodType: 'cooked', + }; + + expect(() => service['validateFoodSafety'](dto)).toThrow(BadRequestException); + expect(() => service['validateFoodSafety'](dto)).toThrow('Preparation time cannot be in the future'); + }); + + it('should throw BadRequestException for high-risk food with < 2 hours shelf life', () => { + const now = new Date(); + const soonExpiry = new Date(now.getTime() + 1.5 * 60 * 60 * 1000).toISOString(); + const dto: any = { + expiryTime: soonExpiry, + preparationTime: now.toISOString(), + foodType: 'cooked', // high risk + }; + + expect(() => service['validateFoodSafety'](dto)).toThrow(BadRequestException); + expect(() => service['validateFoodSafety'](dto)).toThrow('High-risk food (cooked) must have at least 2 hours of safe consumption window remaining.'); + }); + + it('should allow high-risk food with >= 2 hours shelf life', () => { + const now = new Date(); + const safeExpiry = new Date(now.getTime() + 2.5 * 60 * 60 * 1000).toISOString(); + const dto: any = { + expiryTime: safeExpiry, + preparationTime: now.toISOString(), + foodType: 'cooked', + }; + + expect(() => service['validateFoodSafety'](dto)).not.toThrow(); + }); + + it('should allow low-risk food with >= 1 hour shelf life', () => { + const now = new Date(); + const safeExpiry = new Date(now.getTime() + 1.5 * 60 * 60 * 1000).toISOString(); + const dto: any = { + expiryTime: safeExpiry, + preparationTime: now.toISOString(), + foodType: 'bread', + }; + + expect(() => service['validateFoodSafety'](dto)).not.toThrow(); + }); + }); + + describe('update', () => { + it('should throw ForbiddenException if user is not the donor', async () => { + const donation = { id: '1', donorId: 'user1', status: DonationStatus.AVAILABLE }; + donationRepository.findOne.mockResolvedValue(donation); + + await expect(service.update('1', {}, 'user2')).rejects.toThrow(ForbiddenException); + }); + + it('should throw BadRequestException if donation is not AVAILABLE', async () => { + const donation = { id: '1', donorId: 'user1', status: DonationStatus.CLAIMED }; + donationRepository.findOne.mockResolvedValue(donation); + + await expect(service.update('1', {}, 'user1')).rejects.toThrow(BadRequestException); + }); + + it('should re-validate food safety when expiryTime is updated', async () => { + const now = new Date(); + const donation = { + id: '1', + donorId: 'user1', + status: DonationStatus.AVAILABLE, + foodType: 'cooked', + preparationTime: now.toISOString() + }; + donationRepository.findOne.mockResolvedValue(donation); + + const pastExpiry = new Date(now.getTime() - 10000).toISOString(); + const updateDto = { expiryTime: pastExpiry }; + + await expect(service.update('1', updateDto, 'user1')).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/donations/donations.service.ts b/backend/src/donations/donations.service.ts index f6f1d7b4..ea8a59d7 100644 --- a/backend/src/donations/donations.service.ts +++ b/backend/src/donations/donations.service.ts @@ -1,7 +1,7 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { CreateDonationDto, ClaimDonationDto } from './dto/donations.dto'; +import { CreateDonationDto, ClaimDonationDto, UpdateDonationDto } from './dto/donations.dto'; import { Donation, DonationStatus } from './entities/donation.entity'; import { User, UserRole } from '../auth/entities/user.entity'; @@ -29,6 +29,33 @@ export class DonationsService { return await this.donationsRepository.save(donation); } + async update(id: string, updateDto: UpdateDonationDto, userId: string) { + const donation = await this.donationsRepository.findOne({ where: { id } }); + + if (!donation) { + throw new NotFoundException('Donation not found'); + } + + if (donation.donorId !== userId) { + throw new ForbiddenException('Only the donor can update this donation'); + } + + if (donation.status !== DonationStatus.AVAILABLE) { + throw new BadRequestException('Cannot update a donation that has already been claimed or processed'); + } + + // Merge changes for validation + const updatedDonation = Object.assign({}, donation, updateDto); + + // Re-validate food safety if preparationTime, expiryTime, or foodType is being changed + if (updateDto.preparationTime || updateDto.expiryTime || updateDto.foodType) { + this.validateFoodSafety(updatedDonation as any); + } + + Object.assign(donation, updateDto); + return await this.donationsRepository.save(donation); + } + private validateFoodSafety(createDonationDto: CreateDonationDto) { const now = new Date(); const expiryDate = new Date(createDonationDto.expiryTime); @@ -152,34 +179,60 @@ export class DonationsService { }); } - async markAsDelivered(id: string, userId: string) { + async pickup(id: string, userId: string) { return await this.donationsRepository.manager.transaction(async transactionalEntityManager => { const donation = await transactionalEntityManager.findOne(Donation, { where: { id } }); + const user = await transactionalEntityManager.findOne(User, { where: { id: userId } }); if (!donation) { throw new NotFoundException('Donation not found'); } - - if (donation.claimedById !== userId) { - throw new BadRequestException('You can only mark your claimed donations as delivered'); + if (!user) { + throw new NotFoundException('User not found'); } - - if (donation.status === DonationStatus.DELIVERED) { - throw new BadRequestException('Donation already marked as delivered'); + if (user.role !== UserRole.VOLUNTEER) { + throw new BadRequestException('Only volunteers can confirm pickups'); } + if (donation.status !== DonationStatus.CLAIMED) { + throw new BadRequestException('Donation must be claimed before pickup'); + } + + donation.status = DonationStatus.PICKED_UP; + donation.volunteerId = userId; + donation.pickedUpAt = new Date(); + + return await transactionalEntityManager.save(donation); + }); + } + async deliver(id: string, userId: string) { + return await this.donationsRepository.manager.transaction(async transactionalEntityManager => { + const donation = await transactionalEntityManager.findOne(Donation, { where: { id } }); const user = await transactionalEntityManager.findOne(User, { where: { id: userId } }); + + if (!donation) { + throw new NotFoundException('Donation not found'); + } if (!user) { throw new NotFoundException('User not found'); } + if (user.role !== UserRole.VOLUNTEER) { + throw new BadRequestException('Only volunteers can confirm deliveries'); + } + if (donation.status !== DonationStatus.PICKED_UP) { + throw new BadRequestException('Donation must be picked up before delivery'); + } - // Update status to DELIVERED donation.status = DonationStatus.DELIVERED; + donation.deliveredAt = new Date(); - // Decrement current intake load - user.currentIntakeLoad = Math.max(0, user.currentIntakeLoad - donation.quantity); + // Also finalize the NGO's intake load + const ngo = await transactionalEntityManager.findOne(User, { where: { id: donation.claimedById } }); + if (ngo) { + ngo.currentIntakeLoad = Math.max(0, ngo.currentIntakeLoad - donation.quantity); + await transactionalEntityManager.save(ngo); + } - await transactionalEntityManager.save(user); return await transactionalEntityManager.save(donation); }); } @@ -193,42 +246,49 @@ export class DonationsService { throw new NotFoundException('Donation not found'); } - // Authorization check - const isAuthorized = - donation.donorId === userId || - donation.claimedById === userId || - (user?.role === UserRole.VOLUNTEER && donation.claimedById !== null); // Volunteers can update if donation is claimed - - if (!isAuthorized) { + // Authorization check: Allow donor, claimant, or ADMIN + const isAdmin = user && user.role === UserRole.ADMIN; + if (donation.donorId !== userId && donation.claimedById !== userId && !isAdmin) { throw new BadRequestException('You are not authorized to update this donation status'); } const oldStatus = donation.status; - // If setting to DELIVERED, use the existing logic (which also decrements load) + // Prevent invalid state transitions + if (status === DonationStatus.PICKED_UP && oldStatus !== DonationStatus.CLAIMED) { + throw new BadRequestException('Donation must be CLAIMED before it can be PICKED_UP'); + } + if (status === DonationStatus.DELIVERED && oldStatus !== DonationStatus.PICKED_UP) { + throw new BadRequestException('Donation must be PICKED_UP before it can be DELIVERED'); + } + if (status === DonationStatus.CLAIMED && oldStatus !== DonationStatus.AVAILABLE) { + throw new BadRequestException('Donation must be AVAILABLE before it can be CLAIMED'); + } + + // If setting to DELIVERED, decrement NGO intake load if (status === DonationStatus.DELIVERED) { if (oldStatus === DonationStatus.DELIVERED) { throw new BadRequestException('Donation already marked as delivered'); } - const user = await transactionalEntityManager.findOne(User, { where: { id: donation.claimedById } }); - if (user) { - user.currentIntakeLoad = Math.max(0, user.currentIntakeLoad - donation.quantity); - await transactionalEntityManager.save(user); + const claimant = await transactionalEntityManager.findOne(User, { where: { id: donation.claimedById } }); + if (claimant) { + claimant.currentIntakeLoad = Math.max(0, claimant.currentIntakeLoad - donation.quantity); + await transactionalEntityManager.save(claimant); } - - donation.status = DonationStatus.DELIVERED; - return await transactionalEntityManager.save(donation); } - // If reversing a claim (CLAIMED/PICKED_UP -> AVAILABLE), decrement load + // If reversing a claim (CLAIMED/PICKED_UP -> AVAILABLE), decrement load and clear tracking fields if (status === DonationStatus.AVAILABLE && (oldStatus === DonationStatus.CLAIMED || oldStatus === DonationStatus.PICKED_UP)) { - const user = await transactionalEntityManager.findOne(User, { where: { id: donation.claimedById } }); - if (user) { - user.currentIntakeLoad = Math.max(0, user.currentIntakeLoad - donation.quantity); - await transactionalEntityManager.save(user); + const claimant = await transactionalEntityManager.findOne(User, { where: { id: donation.claimedById } }); + if (claimant) { + claimant.currentIntakeLoad = Math.max(0, claimant.currentIntakeLoad - donation.quantity); + await transactionalEntityManager.save(claimant); } donation.claimedById = null; + donation.volunteerId = null; + donation.pickedUpAt = null; + donation.deliveredAt = null; } donation.status = status; @@ -236,4 +296,18 @@ export class DonationsService { }); } + async updateImage(id: string, imageUrl: string, userId: string) { + const donation = await this.donationsRepository.findOne({ where: { id } }); + + if (!donation) { + throw new NotFoundException('Donation not found'); + } + + if (donation.donorId !== userId) { + throw new ForbiddenException('Only the donation owner can upload images'); + } + + donation.imageUrl = imageUrl; + return await this.donationsRepository.save(donation); + } } \ No newline at end of file diff --git a/backend/src/donations/dto/donations.dto.ts b/backend/src/donations/dto/donations.dto.ts index e0f5a1a5..6c3d1806 100644 --- a/backend/src/donations/dto/donations.dto.ts +++ b/backend/src/donations/dto/donations.dto.ts @@ -76,6 +76,11 @@ export class CreateDonationDto { @IsString({ each: true }) imageUrls?: string[]; + @ApiProperty({ required: false, description: 'Single image URL' }) + @IsOptional() + @IsString() + imageUrl?: string; + @ApiProperty({ example: '2025-01-31T10:00:00Z', description: 'Expiry time' }) @IsDateString() expiryTime: string; @@ -96,4 +101,48 @@ export class UpdateDonationStatusDto { @ApiProperty({ enum: DonationStatus, example: DonationStatus.PICKED_UP }) @IsEnum(DonationStatus) status: DonationStatus; +} + +export class UpdateDonationDto { + @ApiProperty({ example: 'Vegetable Biryani', required: false }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ example: 'cooked', required: false }) + @IsOptional() + @IsString() + foodType?: string; + + @ApiProperty({ example: 50, required: false }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.1) + quantity?: number; + + @ApiProperty({ example: 'kg', required: false }) + @IsOptional() + @IsString() + unit?: string; + + @ApiProperty({ example: '2025-01-30T10:00:00Z', required: false }) + @IsOptional() + @IsDateString() + preparationTime?: string; + + @ApiProperty({ example: '2025-01-31T10:00:00Z', required: false }) + @IsOptional() + @IsDateString() + expiryTime?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + imageUrl?: string; } \ No newline at end of file diff --git a/backend/src/donations/entities/donation.entity.ts b/backend/src/donations/entities/donation.entity.ts index ff2042e1..7d09748d 100644 --- a/backend/src/donations/entities/donation.entity.ts +++ b/backend/src/donations/entities/donation.entity.ts @@ -49,6 +49,9 @@ export class Donation { @Column({ nullable: true }) address: string; + @Column({ nullable: true }) + imageUrl: string; + @Column('simple-array', { nullable: true }) imageUrls: string[]; @@ -64,6 +67,15 @@ export class Donation { @Column({ nullable: true }) claimedById: string; + @Column({ nullable: true }) + volunteerId: string; + + @Column({ type: 'timestamp', nullable: true }) + pickedUpAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + deliveredAt: Date; + @Column({ type: 'timestamp', nullable: true }) expiryTime: Date; diff --git a/backend/src/tasks/load-reset.service.ts b/backend/src/tasks/load-reset.service.ts new file mode 100644 index 00000000..03abc088 --- /dev/null +++ b/backend/src/tasks/load-reset.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User, UserRole } from '../auth/entities/user.entity'; + +@Injectable() +export class LoadResetService { + private readonly logger = new Logger(LoadResetService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) { } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async handleDailyReset() { + this.logger.log('Starting daily NGO intake load reset...'); + + try { + await this.userRepository + .createQueryBuilder() + .update(User) + .set({ currentIntakeLoad: 0 }) + .where('role = :role', { role: UserRole.NGO }) + .execute(); + + this.logger.log('Successfully reset currentIntakeLoad for all NGOs.'); + } catch (error) { + this.logger.error('Failed to reset NGO intake load:', error); + } + } +} diff --git a/backend/test_output.txt b/backend/test_output.txt new file mode 100644 index 00000000..959c9f4b Binary files /dev/null and b/backend/test_output.txt differ diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 67b38844..78dfd62b 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -20,6 +20,12 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "test" + ] } \ No newline at end of file diff --git a/frontend/build_errors_full.txt b/frontend/build_errors_full.txt new file mode 100644 index 00000000..7363087b Binary files /dev/null and b/frontend/build_errors_full.txt differ diff --git a/frontend/frontend_errors.txt b/frontend/frontend_errors.txt new file mode 100644 index 00000000..7363087b Binary files /dev/null and b/frontend/frontend_errors.txt differ diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 6c10eae7..aec75bcb 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useNavigate, Link } from 'react-router-dom' import { registerUser, type UserRole } from '../services/api' -import { Utensils, Store, Building2, Car, ChevronRight, AlertCircle } from 'lucide-react' +import { Store, Building2, Car, ChevronRight, AlertCircle } from 'lucide-react' export default function Register() { const [formData, setFormData] = useState({ @@ -49,10 +49,7 @@ export default function Register() { { id: 'volunteer', label: 'Volunteer', desc: 'Help with transport', icon: Car }, ] - const organizationTypes = { - donor: ['restaurant', 'hotel', 'canteen', 'event', 'catering', 'grocery', 'bakery'], - ngo: ['charity', 'shelter', 'community_kitchen', 'food_bank'], - } + const showOrganizationFields = formData.role === 'donor' || formData.role === 'ngo' @@ -82,8 +79,8 @@ export default function Register() { type="button" onClick={() => setFormData({ ...formData, role: role.id as UserRole })} className={`p-4 rounded-xl border-2 text-center transition-all ${formData.role === role.id - ? 'border-emerald-500 bg-emerald-500/10' - : 'border-slate-700 bg-slate-800/50' + ? 'border-emerald-500 bg-emerald-500/10' + : 'border-slate-700 bg-slate-800/50' }`} > diff --git a/frontend/src/pages/dashboard/DonorHome.tsx b/frontend/src/pages/dashboard/DonorHome.tsx index 77b5d6dc..22faf7be 100644 --- a/frontend/src/pages/dashboard/DonorHome.tsx +++ b/frontend/src/pages/dashboard/DonorHome.tsx @@ -14,9 +14,9 @@ export default function DonorHome() { const load = async () => { setLoading(true) try { - const data = await getDonations({ + const data = await getDonations({ role: 'donor', - userId: user.id + userId: user.id }) setDonations(data) } finally { @@ -45,44 +45,7 @@ export default function DonorHome() { return getTimeRemaining(d.expiryTime).urgent }) - const handleClaim = async (donationId: string) => { - setClaiming(donationId) - try { - await claimDonation(donationId) - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to claim donation') - } finally { - setClaiming(null) - } - } - - const handleConfirmPickup = async (id: string) => { - setProcessingId(id) - try { - await updateDonationStatus(id, 'PICKED_UP') - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to confirm pickup') - } finally { - setProcessingId(null) - } - } - const handleConfirmDelivery = async (id: string) => { - setProcessingId(id) - try { - await updateDonationStatus(id, 'DELIVERED') - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to confirm delivery') - } finally { - setProcessingId(null) - } - } const getStatusStyle = (status: string) => { const styles: Record = { @@ -241,10 +204,9 @@ export default function DonorHome() {
-
+
{donation.foodType === 'cooked' && '🍛'} {donation.foodType === 'raw' && '🥬'} {donation.foodType === 'packaged' && '📦'} @@ -289,14 +251,14 @@ export default function DonorHome() { {selectedDonation && (
- + {/* LEFT: Image Section */}
{selectedDonation.imageUrls && selectedDonation.imageUrls.length > 0 ? ( <> - {selectedDonation.name} {/* Image Counter */} @@ -389,7 +351,7 @@ export default function DonorHome() { {/* Time */}
- Prepared: {new Date(selectedDonation.preparationTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + Prepared: {new Date(selectedDonation.preparationTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
diff --git a/frontend/src/pages/dashboard/History.tsx b/frontend/src/pages/dashboard/History.tsx index 03a95d75..762cf360 100644 --- a/frontend/src/pages/dashboard/History.tsx +++ b/frontend/src/pages/dashboard/History.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { getDonations } from '../../services/api' import type { Donation } from '../../services/api' @@ -60,8 +60,8 @@ export default function History() { key={status} onClick={() => setFilter(status)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors capitalize ${filter === status - ? 'bg-emerald-500 text-white' - : 'bg-slate-900 text-slate-400 hover:text-white' + ? 'bg-emerald-500 text-white' + : 'bg-slate-900 text-slate-400 hover:text-white' }`} > {status === 'all' ? 'All' : status.toLowerCase()} diff --git a/frontend/src/pages/dashboard/Impact.tsx b/frontend/src/pages/dashboard/Impact.tsx index ebc60e14..5104a546 100644 --- a/frontend/src/pages/dashboard/Impact.tsx +++ b/frontend/src/pages/dashboard/Impact.tsx @@ -147,22 +147,19 @@ export default function Impact() { {badges.map((badge) => (
{badge.icon}

{badge.name}

{badge.description}

- {!badge.earned && ( -

- {badge.requirement - stats.totalDonations} more needed -

- )} +

+ {(badge.requirement || 0) - stats.totalDonations} more needed +

))}
@@ -180,7 +177,7 @@ export default function Impact() {

- {Math.max(0, nextBadge.requirement - stats.totalDonations)} + {Math.max(0, (nextBadge.requirement || 0) - stats.totalDonations)}

more to go

@@ -304,11 +301,10 @@ export default function Impact() { {badges.map((badge) => (
{badge.icon}

@@ -428,11 +424,10 @@ export default function Impact() { {badges.map((badge) => (

{badge.icon}

diff --git a/frontend/src/pages/dashboard/NGODashboard.tsx b/frontend/src/pages/dashboard/NGODashboard.tsx index f6c66fec..1a747400 100644 --- a/frontend/src/pages/dashboard/NGODashboard.tsx +++ b/frontend/src/pages/dashboard/NGODashboard.tsx @@ -15,9 +15,9 @@ export default function NGODashboard() { const load = async () => { setLoading(true) try { - const data = await getDonations({ + const data = await getDonations({ role: 'ngo', - userId: user.id + userId: user.id }) setDonations(data) } finally { @@ -100,7 +100,7 @@ export default function NGODashboard() {

-
@@ -208,9 +208,8 @@ export default function NGODashboard() {
-
+
{donation.foodType === 'cooked' && '🍛'} {donation.foodType === 'raw' && '🥬'} {donation.foodType === 'packaged' && '📦'} @@ -306,14 +305,14 @@ export default function NGODashboard() { {selectedDonation && (
- + {/* LEFT: Image Section */}
{selectedDonation.imageUrls && selectedDonation.imageUrls.length > 0 ? ( <> - {selectedDonation.name} {/* Image Counter */} @@ -406,13 +405,13 @@ export default function NGODashboard() { {/* Location */}

Pickup Location

-

📍 {selectedDonation.address || 'Location not specified'}

+

📍 {selectedDonation.location?.address || 'Location not specified'}

{/* Time */}
- Prepared: {new Date(selectedDonation.preparationTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + Prepared: {new Date(selectedDonation.preparationTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 775e3240..eaa996c2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -73,6 +73,7 @@ export interface User { export type Notification = { id: string; + type: 'food_claimed' | 'pickup_assigned' | 'delivery_confirmed' | 'near_expiry' | 'new_food_nearby'; title: string; message: string; read: boolean; @@ -143,13 +144,17 @@ export const createDonation = async (data: any, images: File[] = []) => { formData.append('quantity', data.quantity.toString()); formData.append('unit', data.unit); formData.append('description', data.description || ''); - - const prepTime = data.preparationTime instanceof Date ? data.preparationTime.toISOString() : data.preparationTime; - formData.append('preparationTime', prepTime); - - const expTime = data.expiryTime instanceof Date ? data.expiryTime.toISOString() : data.expiryTime; - formData.append('expiryTime', expTime); - + + if (data.preparationTime) { + const prepTime = data.preparationTime instanceof Date ? data.preparationTime.toISOString() : data.preparationTime; + formData.append('preparationTime', (prepTime as string)); + } + + if (data.expiryTime) { + const expTime = data.expiryTime instanceof Date ? data.expiryTime.toISOString() : data.expiryTime; + formData.append('expiryTime', (expTime as string)); + } + if (data.donorId) formData.append('donorId', data.donorId); if (data.donorName) formData.append('donorName', data.donorName); if (data.donorTrustScore) formData.append('donorTrustScore', data.donorTrustScore.toString()); @@ -164,7 +169,7 @@ export const createDonation = async (data: any, images: File[] = []) => { } images.forEach((file) => { - formData.append('images', file); + formData.append('images', file); }); const response = await api.post('/donations', formData, { @@ -189,7 +194,7 @@ export const updateDonationStatus = async (id: string, status: string) => { // MOCK HELPERS export const getNotifications = async (_userId: string): Promise => { - return [{ id: '1', title: 'Welcome!', message: 'Welcome to SurplusSync.', read: false, createdAt: new Date() }]; + return [{ id: '1', type: 'food_claimed', title: 'Welcome!', message: 'Welcome to SurplusSync.', read: false, createdAt: new Date() }]; }; export const markNotificationRead = async (_id: string) => { return; }; export const checkExpiringDonations = () => { return; }; diff --git a/frontend/tsc_clean.txt b/frontend/tsc_clean.txt new file mode 100644 index 00000000..720d1002 --- /dev/null +++ b/frontend/tsc_clean.txt @@ -0,0 +1,21 @@ +src/pages/dashboard/DonorHome.tsx(48,9): error TS6133: 'handleClaim' is declared but its value is never read. +src/pages/dashboard/DonorHome.tsx(49,5): error TS2304: Cannot find name 'setClaiming'. +src/pages/dashboard/DonorHome.tsx(51,13): error TS2304: Cannot find name 'claimDonation'. +src/pages/dashboard/DonorHome.tsx(57,7): error TS2304: Cannot find name 'setClaiming'. +src/pages/dashboard/DonorHome.tsx(61,9): error TS6133: 'handleConfirmPickup' is declared but its value is never read. +src/pages/dashboard/DonorHome.tsx(62,5): error TS2304: Cannot find name 'setProcessingId'. +src/pages/dashboard/DonorHome.tsx(64,13): error TS2304: Cannot find name 'updateDonationStatus'. +src/pages/dashboard/DonorHome.tsx(70,7): error TS2304: Cannot find name 'setProcessingId'. +src/pages/dashboard/DonorHome.tsx(74,9): error TS6133: 'handleConfirmDelivery' is declared but its value is never read. +src/pages/dashboard/DonorHome.tsx(75,5): error TS2304: Cannot find name 'setProcessingId'. +src/pages/dashboard/DonorHome.tsx(77,13): error TS2304: Cannot find name 'updateDonationStatus'. +src/pages/dashboard/DonorHome.tsx(83,7): error TS2304: Cannot find name 'setProcessingId'. +src/pages/dashboard/History.tsx(1,8): error TS6133: 'React' is declared but its value is never read. +src/pages/dashboard/Impact.tsx(163,42): error TS18048: 'badge.requirement' is possibly 'undefined'. +src/pages/dashboard/Impact.tsx(183,54): error TS18048: 'nextBadge.requirement' is possibly 'undefined'. +src/pages/dashboard/NGODashboard.tsx(409,78): error TS2339: Property 'address' does not exist on type 'Donation'. +src/pages/dashboard/Notifications.tsx(45,41): error TS2339: Property 'type' does not exist on type 'Notification'. +src/pages/dashboard/Notifications.tsx(53,16): error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ food_claimed: Element; pickup_assigned: Element; delivery_confirmed: Element; near_expiry: Element; new_food_nearby: Element; }'. +src/pages/dashboard/Notifications.tsx(136,59): error TS2339: Property 'type' does not exist on type 'Notification'. +src/pages/Register.tsx(4,10): error TS6133: 'Utensils' is declared but its value is never read. +src/pages/Register.tsx(52,11): error TS6133: 'organizationTypes' is declared but its value is never read. diff --git a/frontend/tsc_output.txt b/frontend/tsc_output.txt new file mode 100644 index 00000000..e69de29b