Commit 2698094f by Augusto

photo Endpoints

parent 877f3f7a
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^11.1.1", "@nestjs/swagger": "^11.1.1",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
...@@ -36,11 +36,14 @@ ...@@ -36,11 +36,14 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jimp": "^0.22.12",
"multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
...@@ -55,6 +58,7 @@ ...@@ -55,6 +58,7 @@
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
......
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL NOT NULL,
"url" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"candidateId" INTEGER NOT NULL,
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Photo_candidateId_idx" ON "Photo"("candidateId");
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
\ No newline at end of file
...@@ -9,31 +9,23 @@ datasource db { ...@@ -9,31 +9,23 @@ datasource db {
extensions = [postgis] extensions = [postgis]
} }
enum Role {
SUPERADMIN
ADMIN
MANAGER
OPERATOR
VIEWER
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String @unique
name String name String
password String // This will store the hashed password password String
role Role @default(VIEWER) role Role @default(VIEWER)
isActive Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sitesCreated Site[] @relation("SiteCreator") resetToken String?
sitesUpdated Site[] @relation("SiteUpdater") resetTokenExpiry DateTime?
isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator") candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater") candidatesUpdated Candidate[] @relation("CandidateUpdater")
refreshTokens RefreshToken[]
resetToken String? // For password reset
resetTokenExpiry DateTime? // Expiry time for reset token
Comment Comment[] Comment Comment[]
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
@@index([email]) @@index([email])
@@index([role]) @@index([role])
...@@ -42,10 +34,10 @@ model User { ...@@ -42,10 +34,10 @@ model User {
model RefreshToken { model RefreshToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
token String @unique token String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int userId Int
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token]) @@index([token])
@@index([userId]) @@index([userId])
...@@ -59,11 +51,11 @@ model Site { ...@@ -59,11 +51,11 @@ model Site {
longitude Float longitude Float
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[] candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id]) createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id]) updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
updatedById Int?
@@index([siteCode]) @@index([siteCode])
} }
...@@ -77,14 +69,15 @@ model Candidate { ...@@ -77,14 +69,15 @@ model Candidate {
address String address String
currentStatus String currentStatus String
onGoing Boolean @default(false) onGoing Boolean @default(false)
sites CandidateSite[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
createdById Int? createdById Int?
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
updatedById Int? updatedById Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[] comments Comment[]
photos Photo[]
@@index([candidateCode]) @@index([candidateCode])
@@index([currentStatus]) @@index([currentStatus])
...@@ -93,12 +86,12 @@ model Candidate { ...@@ -93,12 +86,12 @@ model Candidate {
model CandidateSite { model CandidateSite {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int candidateId Int
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
siteId Int siteId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId]) @@unique([candidateId, siteId])
@@index([candidateId]) @@index([candidateId])
...@@ -110,11 +103,42 @@ model Comment { ...@@ -110,11 +103,42 @@ model Comment {
content String content String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int candidateId Int
createdBy User? @relation(fields: [createdById], references: [id])
createdById Int? createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId]) @@index([candidateId])
@@index([createdById]) @@index([createdById])
} }
model Photo {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
@@index([candidateId])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model spatial_ref_sys {
srid Int @id
auth_name String? @db.VarChar(256)
auth_srid Int?
srtext String? @db.VarChar(2048)
proj4text String? @db.VarChar(2048)
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
}
#!/bin/bash
# Execute the truncate SQL script
psql -d cellnex -f prisma/truncate.sql
# Seed script removed as it's only for creating a superadmin
\ No newline at end of file
import { diskStorage, memoryStorage } from 'multer';
import { extname } from 'path';
export const multerConfig = {
storage: memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, callback) => {
try {
console.log('Checking file type:', file.mimetype);
// Accept only images
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
console.error('Invalid file type:', file.mimetype);
return callback(new Error('Only image files are allowed!'), false);
}
callback(null, true);
} catch (error) {
console.error('Error in file filter:', error);
callback(error, false);
}
},
preservePath: true
};
\ No newline at end of file
...@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core'; ...@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import * as express from 'express';
import { join } from 'path';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
...@@ -21,6 +23,9 @@ async function bootstrap() { ...@@ -21,6 +23,9 @@ async function bootstrap() {
}), }),
); );
// Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads'));
// Swagger configuration // Swagger configuration
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Cellnex API') .setTitle('Cellnex API')
......
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { CandidatesService } from './candidates.service'; import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto'; import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto'; import { UpdateCandidateDto } from './dto/update-candidate.dto';
...@@ -11,6 +11,9 @@ import { Roles } from '../auth/decorators/roles.decorator'; ...@@ -11,6 +11,9 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator'; import { User } from '../auth/decorators/user.decorator';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto'; import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadPhotoDto } from './dto/upload-photo.dto';
import { multerConfig } from '../../common/multer/multer.config';
@ApiTags('candidates') @ApiTags('candidates')
@Controller('candidates') @Controller('candidates')
...@@ -110,4 +113,54 @@ export class CandidatesController { ...@@ -110,4 +113,54 @@ export class CandidatesController {
) { ) {
return this.candidatesService.addSitesToCandidate(id, addSitesDto); return this.candidatesService.addSitesToCandidate(id, addSitesDto);
} }
@Post(':id/photos')
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'The image file to upload'
}
},
required: ['file']
}
})
@UseInterceptors(FileInterceptor('file', multerConfig))
async uploadPhoto(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
console.log('Received file:', {
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size
});
return this.candidatesService.uploadPhoto(id, file, {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size
});
}
@Get(':id/photos')
async getCandidatePhotos(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.getCandidatePhotos(id);
}
@Delete('photos/:photoId')
@ApiOperation({ summary: 'Delete a photo by its ID' })
@ApiResponse({ status: 200, description: 'Photo deleted successfully' })
@ApiResponse({ status: 404, description: 'Photo not found' })
async deletePhoto(@Param('photoId', ParseIntPipe) photoId: number) {
return this.candidatesService.deletePhoto(photoId);
}
} }
\ No newline at end of file
...@@ -4,6 +4,10 @@ import { CreateCandidateDto } from './dto/create-candidate.dto'; ...@@ -4,6 +4,10 @@ import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto'; import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto'; import { QueryCandidateDto } from './dto/query-candidate.dto';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto'; import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { UploadPhotoDto } from './dto/upload-photo.dto';
import * as fs from 'fs';
import * as path from 'path';
import Jimp from 'jimp';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
...@@ -337,4 +341,129 @@ export class CandidatesService { ...@@ -337,4 +341,129 @@ export class CandidatesService {
}, },
}); });
} }
async uploadPhoto(candidateId: number, file: Express.Multer.File, dto: UploadPhotoDto) {
try {
console.log('Starting photo upload process...');
console.log('File details:', {
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
buffer: file?.buffer ? 'Buffer exists' : 'No buffer'
});
const candidate = await this.prisma.candidate.findUnique({
where: { id: candidateId },
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${candidateId} not found`);
}
// Create uploads directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'uploads', 'candidates', candidateId.toString());
console.log('Upload directory:', uploadDir);
if (!fs.existsSync(uploadDir)) {
console.log('Creating upload directory...');
fs.mkdirSync(uploadDir, { recursive: true });
}
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const filename = `${uniqueSuffix}-${file.originalname}`;
const filePath = path.join(uploadDir, filename);
console.log('File path:', filePath);
// Initialize variables for image processing
let fileBuffer = file.buffer;
let fileSize = file.size;
const maxSize = 2 * 1024 * 1024; // 2MB
const mimeType = file.mimetype;
const isImage = mimeType.startsWith('image/');
if (isImage && fileSize > maxSize) {
try {
console.log('Compressing image...');
const image = await Jimp.read(fileBuffer);
let quality = 80;
// Reduce quality until under 2MB or minimum quality
while (fileSize > maxSize && quality > 30) {
const tempBuffer = await image.quality(quality).getBufferAsync(mimeType);
if (tempBuffer.length <= maxSize) {
fileBuffer = tempBuffer;
fileSize = tempBuffer.length;
break;
}
quality -= 10;
}
} catch (error) {
console.error('Error processing image:', error);
// If image processing fails, continue with original file
}
}
// Save file
console.log('Saving file...');
if (!fileBuffer) {
throw new Error('File buffer is missing');
}
fs.writeFileSync(filePath, fileBuffer);
// Create photo record in database with relative URL
console.log('Creating database record...');
const photo = await this.prisma.photo.create({
data: {
url: `/uploads/candidates/${candidateId}/${filename}`,
filename: dto.filename || file.originalname,
mimeType: dto.mimeType || file.mimetype,
size: dto.size || fileSize,
candidateId: candidateId,
},
});
console.log('Photo upload completed successfully');
return photo;
} catch (error) {
console.error('Error in uploadPhoto:', error);
throw error;
}
}
async getCandidatePhotos(candidateId: number) {
const candidate = await this.prisma.candidate.findUnique({
where: { id: candidateId },
include: { photos: true },
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${candidateId} not found`);
}
return candidate.photos;
}
async deletePhoto(photoId: number) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId },
});
if (!photo) {
throw new NotFoundException(`Photo with ID ${photoId} not found`);
}
// Delete file from disk
const filePath = path.join(process.cwd(), photo.url);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Delete photo record from database
await this.prisma.photo.delete({
where: { id: photoId },
});
return { message: 'Photo deleted successfully' };
}
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional } from 'class-validator';
export class UploadPhotoDto {
@ApiProperty({
required: false,
description: 'Optional: The filename to use'
})
@IsOptional()
@IsString()
filename?: string;
@ApiProperty({
required: false,
description: 'Optional: The MIME type of the file'
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiProperty({
required: false,
description: 'Optional: The size of the file in bytes'
})
@IsOptional()
@IsNumber()
size?: number;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment