diff --git a/.env.example b/.env.example index ad9692e..f04b419 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,7 @@ DB_USER=postgres DB_PASSWORD=your_password DB_NAME=skillnet JWT_SECRET=your_jwt_secret_key -PORT=5000 \ No newline at end of file +PORT=5000 +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret \ No newline at end of file diff --git a/config/config.js b/config/config.js index 1d01ed1..fe17fd4 100644 --- a/config/config.js +++ b/config/config.js @@ -1,5 +1,6 @@ import { DataSource } from 'typeorm'; import User from '../entities/user.entity.js'; +import Job from '../entities/job.entity.js'; const AppDataSource = new DataSource({ type: 'postgres', @@ -11,7 +12,7 @@ const AppDataSource = new DataSource({ synchronize: true, dropSchema: false, logging: false, - entities: [User], + entities: [User, Job], subscribers: [], migrations: [], }); diff --git a/controllers/job.controller.js b/controllers/job.controller.js index fa3106d..7ff9c7a 100644 --- a/controllers/job.controller.js +++ b/controllers/job.controller.js @@ -1 +1,70 @@ -//Static \ No newline at end of file +import JobService from "../services/jobServices.js"; + +class JobController { + constructor() { + this.jobService = JobService; + } + + async create(req, res) { + try { + const job = await this.jobService.create(req.body, req.file); + res.status(201).json(job); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } + + async findAll(req, res) { + try { + const jobs = await this.jobService.findAll(); + res.json(jobs); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } + + async findById(req, res) { + try { + const job = await this.jobService.findById(req.params.id); + if (!job) { + return res.status(404).json({ error: "Job not found" }); + } + res.json(job); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } + + async update(req, res) { + try { + const job = await this.jobService.update( + req.params.id, + req.body, + req.file + ); + res.json(job); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } + + async delete(req, res) { + try { + await this.jobService.delete(req.params.id); + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } + + async deleteBanner(req, res) { + try { + const job = await this.jobService.deleteBanner(req.params.id); + res.json(job); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } +} + +export default new JobController(); diff --git a/entities/job.entity.js b/entities/job.entity.js new file mode 100644 index 0000000..e154889 --- /dev/null +++ b/entities/job.entity.js @@ -0,0 +1,62 @@ +import { EntitySchema } from 'typeorm'; + +const Job = new EntitySchema({ + name: 'Job', + tableName: 'jobs', + columns: { + id: { + type: 'int', + primary: true, + generated: true + }, + title: { + type: 'varchar', + nullable: false + }, + description: { + type: 'text', + nullable: true + }, + company: { + type: 'varchar', + nullable: false + }, + location: { + type: 'varchar', + nullable: true + }, + salary: { + type: 'decimal', + nullable: true + }, + requiredSkills: { + type: 'simple-array', + nullable: true + }, + applicationLink: { + type: 'varchar', + nullable: true + }, + banner: { + type: 'varchar', + nullable: true + }, + bannerPublicId: { + type: 'varchar', + nullable: true + }, + createdAt: { + type: 'timestamp', + createDate: true + }, + updatedAt: { + type: 'timestamp', + updateDate: true + } + }, + uniques: [ + { columns: ['title', 'company'] } + ] +}); + +export default Job; \ No newline at end of file diff --git a/routes/jobRoutes.js b/routes/jobRoutes.js index e69de29..9c076b7 100644 --- a/routes/jobRoutes.js +++ b/routes/jobRoutes.js @@ -0,0 +1,58 @@ +import express from "express"; +import multer from "multer"; +import JobController from "../controllers/job.controller.js"; + +const router = express.Router(); + +// Configure multer for file upload +const storage = multer.diskStorage({ + destination: "./uploads/temp", + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); + cb( + null, + file.fieldname + + "-" + + uniqueSuffix + + "." + + file.originalname.split(".").pop() + ); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + }, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith("image/")) { + cb(null, true); + } else { + cb(new Error("Only image files are allowed!"), false); + } + } +}); + +router.post( + "/", + upload.single("banner"), + (req, res) => JobController.create(req, res) +); + +router.get("/", (req, res) => JobController.findAll(req, res)); +router.get("/:id", (req, res) => JobController.findById(req, res)); + +router.put( + "/:id", + upload.single("banner"), + (req, res) => JobController.update(req, res) +); + +router.delete("/:id", (req, res) => JobController.delete(req, res)); + +router.delete("/:id/banner", (req, res) => + JobController.deleteBanner(req, res) +); + +export default router; diff --git a/server.js b/server.js index 9497b09..1f0fb6e 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,8 @@ import 'dotenv/config' import AppDataSource from "./config/config.js"; import express, { json } from 'express'; import authRouter from './routes/auth.routes.js'; +import jobRoutes from './routes/jobRoutes.js'; + import helmet from 'helmet'; import cors from 'cors'; @@ -20,7 +22,8 @@ AppDataSource.initialize() // Routes app.use('/api/auth', authRouter); - + app.use('/api/jobs', jobRoutes); + // Error handling app.use((err, req, res, next) => { console.error(err.stack); diff --git a/services/jobServices.js b/services/jobServices.js index 0502ae5..f32d0e4 100644 --- a/services/jobServices.js +++ b/services/jobServices.js @@ -1 +1,108 @@ -//start \ No newline at end of file +import AppDataSource from "../config/config.js"; +import Job from "../entities/job.entity.js"; +import JobValidation from "../validation/job.validation.js"; +import UploadService from "./upload.service.js"; + +class JobService { + constructor() { + this.jobRepository = AppDataSource.getRepository(Job); + } + + async create(jobData, banner) { + const { error } = JobValidation.validate(jobData); + if (error) { + throw new Error(`Validation Error: ${error.details[0].message}`); + } + + const existingJob = await this.jobRepository.findOne({ + where: { + title: jobData.title, + company: jobData.company + } + }); + + if (existingJob) { + throw new Error("Job with this title and company already exists"); + } + + if (banner) { + const uploadResult = await UploadService.uploadFile(banner); + jobData.banner = uploadResult.url; + jobData.bannerPublicId = uploadResult.publicId; + } + + const job = this.jobRepository.create(jobData); + return await this.jobRepository.save(job); + } + + async findAll() { + return await this.jobRepository.find(); + } + + async findById(id) { + return await this.jobRepository.findOne({ where: { id } }); + } + + async update(id, jobData, banner) { + const { error } = JobValidation.validate(jobData); + if (error) { + throw new Error(`Validation Error: ${error.details[0].message}`); + } + + const existingJob = await this.findById(id); + if (!existingJob) { + throw new Error("Job not found"); + } + + if (banner) { + // Delete old banner if exists + if (existingJob.bannerPublicId) { + await UploadService.deleteFile(existingJob.bannerPublicId); + } + + // Upload new banner + const uploadResult = await UploadService.uploadFile(banner); + jobData.banner = uploadResult.url; + jobData.bannerPublicId = uploadResult.publicId; + } + + await this.jobRepository.update(id, jobData); + return this.findById(id); + } + + async delete(id) { + const job = await this.findById(id); + if (!job) { + throw new Error("Job not found"); + } + + // Delete banner if exists + if (job.bannerPublicId) { + await UploadService.deleteFile(job.bannerPublicId); + } + + return await this.jobRepository.delete(id); + } + + async deleteBanner(id) { + const job = await this.findById(id); + if (!job) { + throw new Error("Job not found"); + } + + if (!job.bannerPublicId) { + throw new Error("No banner exists for this job"); + } + + await UploadService.deleteFile(job.bannerPublicId); + + await this.jobRepository.update(id, { + banner: null, + bannerPublicId: null + }); + + return this.findById(id); + } +} + +export default new JobService(); diff --git a/services/upload.service.js b/services/upload.service.js new file mode 100644 index 0000000..dfe8b6b --- /dev/null +++ b/services/upload.service.js @@ -0,0 +1,49 @@ +import cloudinary from 'cloudinary'; +import { createWriteStream, unlink } from 'fs'; +import { promisify } from 'util'; + +const unlinkAsync = promisify(unlink); + +class UploadService { + constructor() { + cloudinary.v2.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET + }); + } + + async uploadFile(file, folder = 'job-banners') { + try { + const result = await cloudinary.v2.uploader.upload(file.path, { + folder: folder, + resource_type: 'auto' + }); + + // Clean up temporary file + await unlinkAsync(file.path); + + return { + url: result.secure_url, + publicId: result.public_id + }; + } catch (error) { + // Clean up temporary file in case of error + await unlinkAsync(file.path); + throw error; + } + } + + async deleteFile(publicId) { + if (!publicId) return; + + try { + await cloudinary.v2.uploader.destroy(publicId); + } catch (error) { + console.error('Error deleting file:', error); + throw error; + } + } +} + +export default new UploadService(); \ No newline at end of file diff --git a/validation/job.validation.js b/validation/job.validation.js new file mode 100644 index 0000000..8845bf8 --- /dev/null +++ b/validation/job.validation.js @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +class JobValidation { + constructor() { + this.schema = Joi.object({ + title: Joi.string().required().min(3).max(100), + description: Joi.string().optional().max(1000), + company: Joi.string().required().min(2).max(100), + location: Joi.string().optional().max(100), + salary: Joi.number().optional().min(0), + requiredSkills: Joi.array().items(Joi.string()).optional(), + applicationLink: Joi.string().uri().optional() + }); + } + + validate(jobData) { + return this.schema.validate(jobData); + } +} + +export default new JobValidation(); \ No newline at end of file