Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,469 changes: 3,585 additions & 1,884 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,26 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.0",
"@types/ejs": "^3.1.5",
"@types/multer": "^2.1.0",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"ejs": "^4.0.1",
"joi": "^18.0.2",
"multer": "^2.1.1",
"nestjs-pino": "^4.6.0",
"nodemailer": "^8.0.1",
"passport": "^0.7.0",
Expand All @@ -44,7 +49,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.28"
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand Down
31 changes: 31 additions & 0 deletions src/common/guards/file-upload.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable, ExecutionContext, BadRequestException } from '@nestjs/common';

@Injectable()
export class FileUploadGuard {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const file = request.file;

if (!file) {
throw new BadRequestException('No file uploaded');
}

// Check file type
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
`Invalid file type. Allowed types: ${allowedMimeTypes.join(', ')}`,
);
}

// Check file size (5MB max)
const maxSize = 5 * 1024 * 1024; // 5MB in bytes
if (file.size > maxSize) {
throw new BadRequestException(
`File too large. Maximum size is ${maxSize / (1024 * 1024)}MB`,
);
}

return true;
}
}
77 changes: 77 additions & 0 deletions src/common/services/file-upload.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';

@Injectable()
export class FileUploadService {
private readonly logger = new Logger(FileUploadService.name);
private readonly s3Client: S3Client;

constructor(private configService: ConfigService) {
this.s3Client = new S3Client({
region: this.configService.get<string>('AWS_REGION', 'us-east-1'),
credentials: {
accessKeyId: this.configService.getOrThrow<string>('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.getOrThrow<string>('AWS_SECRET_ACCESS_KEY'),
},
});
}

async uploadFile(file: Express.Multer.File, projectId: string): Promise<string> {
const fileExtension = file.originalname.split('.').pop();
const fileName = `${randomUUID()}.${fileExtension}`;
const key = `projects/${projectId}/images/${fileName}`;

try {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.configService.getOrThrow<string>('AWS_S3_BUCKET'),
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read',
}),
);

const publicUrl = `https://${this.configService.getOrThrow<string>('AWS_S3_BUCKET')}.s3.${this.configService.get<string>('AWS_REGION', 'us-east-1')}.amazonaws.com/${key}`;

this.logger.log(`File uploaded successfully: ${fileName}`);
return publicUrl;
} catch (error) {
this.logger.error(`Failed to upload file: ${error.message}`);
throw error;
}
}

async deleteFile(imageUrl: string): Promise<void> {
try {
const url = new URL(imageUrl);
const key = url.pathname.substring(1); // Remove leading '/'

await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.configService.getOrThrow<string>('AWS_S3_BUCKET'),
Key: key,
}),
);

this.logger.log(`File deleted successfully: ${key}`);
} catch (error) {
this.logger.error(`Failed to delete file: ${error.message}`);
throw error;
}
}

generatePresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
return getSignedUrl(
this.s3Client,
new PutObjectCommand({
Bucket: this.configService.getOrThrow<string>('AWS_S3_BUCKET'),
Key: key,
}),
{ expiresIn },
);
}
}
25 changes: 25 additions & 0 deletions src/projects/dto/upload-image.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class UploadImageDto {
@IsArray()
files: Express.Multer.File[];
}

export class ImageFileDto {
@IsString()
@MaxLength(255)
filename: string;

@IsString()
@MaxLength(100)
mimeType: string;

@Transform(({ value }) => parseInt(value))
size: number;
}

export class DeleteImageDto {
@IsString()
imageId: string;
}
40 changes: 40 additions & 0 deletions src/projects/entities/project-image.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Project } from './project.entity';

@Entity('project_images')
export class ProjectImage {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
url: string;

@Column()
filename: string;

@Column()
mimeType: string;

@Column({ type: 'int' })
size: number;

@Column({ default: 0 })
order: number;

@Column()
projectId: string;

@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project: Project;

@CreateDateColumn()
createdAt: Date;
}
4 changes: 4 additions & 0 deletions src/projects/entities/project.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Donation } from './donation.entity';
import { ProjectImage } from './project-image.entity';
import { ProjectHistory } from './project-history.entity';
import { ProjectCategory } from 'src/common/enums/project-category.enum';
import { ProjectStatus } from 'src/common/enums/project-status.enum';
Expand Down Expand Up @@ -71,6 +72,9 @@ export class Project {
@JoinColumn({ name: 'creatorId' })
creator: User;

@OneToMany(() => ProjectImage, (image) => image.project, { cascade: true })
images: ProjectImage[];

@OneToMany(() => Donation, (donation) => donation.project, { cascade: true })
donations: Donation[];

Expand Down
95 changes: 41 additions & 54 deletions src/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
HttpCode,
HttpStatus,
Query,
Param,
UseGuards,
Request,
UseInterceptors,
UploadedFiles,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -22,22 +24,28 @@ import {
ApiUnauthorizedResponse,
ApiNotFoundResponse,
ApiForbiddenResponse,
ApiConsumes,
} from '@nestjs/swagger';
import { FilesInterceptor } from '@nestjs/platform-express';
import { GetProjectsQueryDto } from './dto/get-projects-query.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectStatusDto } from './dto/update-project-status.dto';
import { DeleteImageDto } from './dto/upload-image.dto';
import { Public } from '../common/decorators/public.decorator';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from 'src/common/enums/user-role.enum';
import { ProjectsService } from './providers/projects.service';
import { ImageUploadService } from './services/image-upload.service';

@ApiTags('projects')
@ApiBearerAuth()
@Controller('projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
constructor(
private readonly projectsService: ProjectsService,
private readonly imageUploadService: ImageUploadService,
) {}

//______________________ Endpoint to create a new project (CREATOR role required)
@Get()
Expand Down Expand Up @@ -86,78 +94,57 @@ export class ProjectsController {
return project;
}

//_____________________ Endpoint to pause a project
@Patch(':id/pause')
//_____________________ Endpoint to upload project images
@Post(':id/images')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CREATOR, UserRole.ADMIN)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Pause a project (CREATOR or ADMIN required)' })
@ApiOkResponse({ description: 'Project paused successfully' })
@UseInterceptors(FilesInterceptor('images'))
@HttpCode(HttpStatus.CREATED)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload images to project (CREATOR or ADMIN required)' })
@ApiCreatedResponse({ description: 'Images uploaded successfully' })
@ApiNotFoundResponse({ description: 'Project not found' })
@ApiForbiddenResponse({ description: 'Only creator or admin can pause project' })
async pauseProject(
@ApiForbiddenResponse({ description: 'Only creator or admin can upload images' })
async uploadImages(
@Param('id') id: string,
@Body() updateStatusDto: UpdateProjectStatusDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req,
) {
const userId = req.user.sub;
const userRole = req.user.role;
const project = await this.projectsService.updateStatus(
id,
{ status: 'paused' as any, reason: updateStatusDto.reason },
userId,
userRole,
);
return project;
const images = await this.imageUploadService.uploadImages(id, files, userId, userRole);
return { images };
}

//_____________________ Endpoint to resume a project
@Patch(':id/resume')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CREATOR, UserRole.ADMIN)
//_____________________ Endpoint to get project images
@Get(':id/images')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resume a project (CREATOR or ADMIN required)' })
@ApiOkResponse({ description: 'Project resumed successfully' })
@ApiOperation({ summary: 'Get project images' })
@ApiOkResponse({ description: 'Images retrieved successfully' })
@ApiNotFoundResponse({ description: 'Project not found' })
@ApiForbiddenResponse({ description: 'Only creator or admin can resume project' })
async resumeProject(
@Param('id') id: string,
@Body() updateStatusDto: UpdateProjectStatusDto,
@Request() req,
) {
const userId = req.user.sub;
const userRole = req.user.role;
const project = await this.projectsService.updateStatus(
id,
{ status: 'active' as any, reason: updateStatusDto.reason },
userId,
userRole,
);
return project;
async getProjectImages(@Param('id') id: string) {
const images = await this.imageUploadService.getProjectImages(id);
return { images };
}

//_____________________ Endpoint to complete a project
@Post(':id/complete')
//_____________________ Endpoint to delete project image
@Delete(':id/images/:imageId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CREATOR, UserRole.ADMIN)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Complete a project (CREATOR or ADMIN required)' })
@ApiOkResponse({ description: 'Project completed successfully' })
@ApiNotFoundResponse({ description: 'Project not found' })
@ApiForbiddenResponse({ description: 'Only creator or admin can complete project' })
async completeProject(
@ApiOperation({ summary: 'Delete project image (CREATOR or ADMIN required)' })
@ApiOkResponse({ description: 'Image deleted successfully' })
@ApiNotFoundResponse({ description: 'Image not found' })
@ApiForbiddenResponse({ description: 'Only creator or admin can delete images' })
async deleteImage(
@Param('id') id: string,
@Body() updateStatusDto: UpdateProjectStatusDto,
@Param('imageId') imageId: string,
@Request() req,
) {
const userId = req.user.sub;
const userRole = req.user.role;
const project = await this.projectsService.updateStatus(
id,
{ status: 'completed' as any, reason: updateStatusDto.reason },
userId,
userRole,
);
return project;
await this.imageUploadService.deleteImage(imageId, userId, userRole);
return { message: 'Image deleted successfully' };
}
}
Loading
Loading