Commit 2698094f by Augusto

photo Endpoints

parent 877f3f7a
......@@ -27,7 +27,7 @@
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^11.1.1",
"@prisma/client": "^6.6.0",
"@types/nodemailer": "^6.4.17",
......@@ -36,11 +36,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"date-fns": "^4.1.0",
"jimp": "^0.22.12",
"multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0"
},
......@@ -55,6 +58,7 @@
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@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
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
enum Role {
SUPERADMIN
ADMIN
MANAGER
OPERATOR
VIEWER
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String // This will store the hashed password
role Role @default(VIEWER)
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
refreshTokens RefreshToken[]
resetToken String? // For password reset
resetTokenExpiry DateTime? // Expiry time for reset token
Comment Comment[]
@@index([email])
@@index([role])
id Int @id @default(autoincrement())
email String @unique
name String
password String
role Role @default(VIEWER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
@@index([email])
@@index([role])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
@@index([token])
@@index([userId])
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
updatedById Int?
@@index([siteCode])
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
sites CandidateSite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
updatedById Int?
comments Comment[]
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
id Int @id @default(autoincrement())
candidateId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int
createdBy User? @relation(fields: [createdById], references: [id])
createdById Int?
@@index([candidateId])
@@index([createdById])
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId])
@@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';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import * as express from 'express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
......@@ -21,6 +23,9 @@ async function bootstrap() {
}),
);
// Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads'));
// Swagger configuration
const config = new DocumentBuilder()
.setTitle('Cellnex API')
......
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
......@@ -11,6 +11,9 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
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')
@Controller('candidates')
......@@ -110,4 +113,54 @@ export class CandidatesController {
) {
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';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-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';
@Injectable()
......@@ -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