diff --git a/src/controllers/collaborationController.js b/src/controllers/collaborationController.js index 518e880cf4..4673494e90 100644 --- a/src/controllers/collaborationController.js +++ b/src/controllers/collaborationController.js @@ -1,3 +1,6 @@ +const { ObjectId } = require('mongoose').Types; +// const express = require('express'); +// const axios = require('axios'); const Form = require('../models/JobFormsModel'); const Response = require('../models/jobApplicationsModel'); @@ -88,6 +91,343 @@ exports.getFormResponses = async (req, res) => { res.status(500).json({ message: 'Error fetching form responses.', error }); } }; +// +function tryParseJSON(str) { + try { + return JSON.parse(str); + } catch { + return null; + } +} +// post all responses of a form +exports.postFormResponseUpload = async (req, res) => { + console.log('inside postFormResponseUpload'); + console.log(req.body); + console.log(req.file); + const uploadFile = req.file; + + console.log('uploadFile.size'); + console.log(uploadFile.size); + + const errorMap = { + expired_access_token: 'Dropbox access token expired. Please reconnect Dropbox.', + shared_link_already_exists: 'A shared link already exists for this file.', + path_conflict: 'A file with this name already exists in Dropbox.', + }; + + const accessTokenResponse = await fetch('https://api.dropboxapi.com/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: process.env.DROPBOX_REFRESH_TOKEN, + client_id: process.env.DROPBOX_APP_KEY, + client_secret: process.env.DROPBOX_APP_SECRET, + }), + }); + + try { + // 18 and 19 Software Developers + // need to be changed for different jobForms + // const resumeFile = req.answers[18]; + // if (!uploadFile) return res.status (400, 'No file uploaded'); + if (!uploadFile) return res.status(400).json({ message: `No file uploaded` }); + + if (uploadFile.size > 5 * 1024 * 1024) { + uploadFile.value = null; + return res.status(500).json({ message: `File size should be less than or equal to 5MB` }); + } + console.log('resumeFile'); + console.log(uploadFile.originalname); + + if ( + ![ + 'application/pdf', + 'application/doc', + 'application/docx', + 'image/jpeg', + 'image/png', + 'image/bmp', + ].includes(uploadFile.mimetype) + ) { + return res.status(500).json({ + message: `Invalid file type. Please upload a PDF, DOC, DOCX, JPG, PNG, or BMP file.`, + }); + } + + const dropboxPath = `${process.env.DROPBOX_PATH}/${uploadFile.originalname}`; + + // const dropboxPath = `/resumes-upload/${uploadFile.originalname}`; + console.log(dropboxPath); + + // to upload the files + // const dropboxAccessToken = process.env.DROPBOX_ACCESS_TOKEN; + + const tokenData = await accessTokenResponse.json(); + console.log('New access token:', tokenData); + console.log('New access token:', tokenData.access_token); + + const uploadFileResponse = await fetch('https://content.dropboxapi.com/2/files/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + + 'Dropbox-API-Arg': JSON.stringify({ + autorename: true, + mode: 'add', + path: dropboxPath, + }), + 'Content-Type': 'application/octet-stream', + }, + body: uploadFile.buffer, + }); + // Dropbox returns JSON *if* successful + if (!uploadFileResponse.ok) { + const text = await uploadFileResponse.text(); + console.error('Dropbox error:', text); + // throw new Error(`Dropbox upload failed: ${text}`); + let shortMessage = 'Dropbox upload failed'; + const errorObj = tryParseJSON(text); + // JSON.parse(text); + if (errorObj) { + const tag = errorObj?.error?.['.tag']; + const summary = errorObj?.error_summary; + + shortMessage = errorMap[tag] || `Dropbox error: ${summary || tag || 'Unknown error'}`; + } + // else { + // Not JSON — plain text error (e.g., malformed token) + else if (text.includes('malformed')) { + shortMessage = 'Dropbox access token is malformed. Please re-authenticate.'; + } else if (text.includes('expired_access_token')) { + shortMessage = 'Dropbox access token expired. Please reconnect Dropbox.'; + } else { + shortMessage = `Dropbox upload failed: ${text}`; + } + + console.log(`shortMessage: ${shortMessage}`); + return res.status(500).json({ message: `${shortMessage}` }); + } + const uploadFileResponseData = await uploadFileResponse.json(); + + console.log('Dropbox Response'); + console.log(uploadFileResponseData); + + const uploadFileSharedLinkRes = await fetch( + 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings', + { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'Content-Type': 'application/json', + }, + settings: { + access: 'viewer', + allow_download: true, + audience: 'public', + requested_visibility: 'public', + }, + body: JSON.stringify({ + path: dropboxPath, + }), + }, + ); + console.log(uploadFileSharedLinkRes); + + if (!uploadFileSharedLinkRes.ok) { + const uploadFileSharedLinkResText = await uploadFileSharedLinkRes.text(); + console.error(uploadFileSharedLinkResText); + console.error('Dropbox Shared File Link error:', uploadFileSharedLinkResText); + // throw new Error(`Dropbox Shared File Link failed: ${uploadFileSharedLinkResText}`); + let sharedShortMessage = 'Dropbox upload failed'; + const sharedErrorObj = tryParseJSON(uploadFileSharedLinkResText); + if (sharedErrorObj) { + const sharedTag = sharedErrorObj?.error?.['.tag']; + const summary = sharedErrorObj?.error_summary; + + sharedShortMessage = + errorMap[sharedTag] || `Dropbox error: ${summary || sharedTag || 'Unknown error'}`; + } + // else { + // Not JSON — plain text error (e.g., malformed token) + else if (uploadFileSharedLinkResText.includes('malformed')) { + sharedShortMessage = 'Dropbox access token is malformed. Please re-authenticate.'; + } else if (uploadFileSharedLinkResText.includes('expired_access_token')) { + sharedShortMessage = 'Dropbox access token expired. Please reconnect Dropbox.'; + } else { + sharedShortMessage = `Dropbox upload failed: ${uploadFileSharedLinkResText}`; + } + + console.log(`shortMessage: ${sharedShortMessage}`); + + return res.status(500).json({ message: `${sharedShortMessage}` }); + } + const uploadFileSharedLinkResData = await uploadFileSharedLinkRes.json(); + console.log('uploadFileSharedLinkResData'); + console.log(uploadFileSharedLinkResData); + + res.status(200).json({ data: uploadFileSharedLinkResData }); + } catch (error) { + console.log(error); + res.status(500).json({ message: 'Error Uploading', error }); + } +}; + +// post all responses of a form +exports.postFormResponses = async (req, res) => { + console.log('inside postFormResponses'); + try { + const { answers } = req.body; + const formId = new ObjectId(req.body.formId); + console.log('formId'); + console.log(formId); + console.log('req.body.formId'); + console.log(req.body.formId); + // console.log(answers); + + const respondent = answers[1]?.answer; + console.log('respondent'); + console.log(respondent); + + // Check if form exists + // const form = await Form.findById(formId); + const form = await Form.findById(formId); + if (!form) { + return res.status(404).json({ message: 'Form not found.' }); + } + + if (!form || !Array.isArray(form.questions)) { + console.error('jobform not found or has no questions'); + return; + } + + // const questionMap = Object.fromEntries( + + const formMap = Object.fromEntries( + form.questions.map((q) => [ + q._id.toString(), + { + questionText: q._doc?.questionText || q.questionText, + isRequired: q._doc?.isRequired || q.isRequired, + answer: null, + }, + ]), + ); + console.log(`formMap: ${JSON.stringify(formMap)}`); + let answerError = null; + answers.some((ans) => { + const qid = ans.questionId.toString(); + console.log(`qid: ${qid}`); + if (!formMap[qid]) { + // Return a special error object + answerError = { + status: 404, + message: `Invalid question ID: ${qid}`, + }; + console.log('before return true'); + return true; // stop iteration + } + + formMap[qid].answer = ans.answer; + console.log(`formMap[qid].answer: ${formMap[qid].answer}`); + console.log('before return false'); + return false; + }); + console.log(`answerError: ${JSON.stringify(answerError)}`); + if (answerError) { + return res.status(answerError.status).json({ + message: answerError.message, + }); + } + + let requiredError = null; + Object.entries(formMap).some(([item]) => { + const empty = item.answer === '' || item.answer === null || item.answer === undefined; + + if (item.isRequired && empty) { + requiredError = { + status: 400, + message: `Answer required for question: ${item.questionText}`, + }; + return true; // stop iteration + } + + return false; + }); + + if (requiredError) { + return res.status(requiredError.status).json({ + message: requiredError.message, + }); + } + + // Create and save the form + const response = new Response({ + formId, + answers, + respondent, + }); + console.log(response); + // Save the responses for the form + + await response.save(); + + // Send email to oneCommunity Email Address + const emailSender = require('../utilities/emailSender'); + const emailBody = ` + subject: ${form.title} Application Received from ${respondent}!, + html: +
+

Application Received ${respondent}!

+

We’ve successfully received your application. Below are your responses:

+ +
+ ${answers + // Sort by _id ascending + // .sort((a, b) => (a._id > b._id ? 1 : -1)) + .map((answer) => { + console.log(`answer.questionId: ${answer.questionId}`); + + const computedQuestionId = answer.questionId?.toString?.() ?? String(answer.questionId); + console.log(`computedQuestionId: ${computedQuestionId}`); + const questionText = formMap[computedQuestionId].questionText || 'Unknown Question'; + console.log(`questionText: ${questionText}`); + return ` +
+ + ${questionText} +
+ ${answer.answer} +
+ `; + }) + .join('')} +
+ +
+

Regards,
Software Team HGN

+
+`; + console.log(`JOB_APPLICATION_RECIPENT_EMAIL is process.env.JOB_APPLICATION_RECIPENT_EMAIL`); + emailSender( + process.env.JOB_APPLICATION_RECIPIENT_EMAIL, // recipents , + `${form.title} Application Received from ${respondent}!`, // subject + // 'Application Received!!!!!!!!!!', // subject + emailBody, // message + null, // attachments + null, // cc + 'jae@onecommunityglobal.org', // reply to + ); + console.log('email sent'); + + res.status(201).json({ message: 'Responses submitted successfully.', response }); + } catch (error) { + console.log(error); + res.status(500).json({ message: 'Error Saving form responses.', error }); + } +}; // Get formats of all forms exports.getAllFormsFormat = async (req, res) => { diff --git a/src/controllers/jobsController.js b/src/controllers/jobsController.js index 6c052a9016..afd74af426 100644 --- a/src/controllers/jobsController.js +++ b/src/controllers/jobsController.js @@ -1,8 +1,27 @@ const Job = require('../models/jobs'); // Import the Job model const JobPositionCategory = require('../models/jobPositionCategory'); +const JobForms = require('../models/JobFormsModel'); +// const { hasPermission, canRequestorUpdateUser } = require('../utilities/permissions'); +const helper = require('../utilities/permissions'); + +function getWordCount(input) { + const excludedSymbols = new Set(['.', '#', '$', '*', '-', '–', '—', '_']); + + const wordCount = input + .trim() + .split(/\s+/) // still use this to handle any whitespace + .filter((word) => { + // Remove empty strings and standalone symbols + const cleanedWord = word.trim(); + return cleanedWord && !excludedSymbols.has(cleanedWord); + }).length; + + return wordCount; +} +const EIGHTEEN = 18; // Controller to fetch all jobs with pagination, search, and filtering const paginationForJobs = async (req, res) => { - const { page = 1, limit = 18, search = '', category = '', position = '' } = req.query; + const { page = 1, limit = EIGHTEEN, search = '', category = '', position = '' } = req.query; try { // Validate query parameters @@ -140,7 +159,7 @@ const getJobTitleSuggestions = async (req, res) => { }; const resetJobsFilters = async (req, res) => { - const { page = 1, limit = 18 } = req.query; + const { page = 1, limit = EIGHTEEN } = req.query; try { // Validate pagination parameters @@ -194,7 +213,10 @@ const getCategories = async (req, res) => { }; const getPositions = async (req, res) => { try { - const positions = await JobPositionCategory.distinct('position', {}); + const categoryIn = req?.query?.category || ''; + const filterCategory = categoryIn ? { category: categoryIn } : {}; + + const positions = await JobPositionCategory.distinct('position', filterCategory); // Sort categories alphabetically positions.sort((a, b) => a.localeCompare(b)); @@ -220,9 +242,113 @@ const getJobById = async (req, res) => { } }; +const MIN_WORDS = 30; + +// Need to check permision here +const checkPermission = async function (req, permission) { + return helper.hasPermission(req.body.requestor, permission); +}; + +const validateCategory = async function (category) { + const result = await JobPositionCategory.find({ category }); + if (!result || result.length === 0) { + return { error: 'Category not found' }; + } + return null; +}; +const validateTitle = async function (title) { + const jobPosition = await JobPositionCategory.find({ position: title }); + if (!jobPosition || jobPosition.length === 0) { + return { error: 'Title not found' }; + } + return null; +}; +const validateTitleCategoryMatch = async function (title, category) { + const jobPositionCategory = await JobPositionCategory.find({ position: title, category }); + if (!jobPositionCategory || jobPositionCategory.length === 0) { + return { error: 'Title and Category not matched' }; + } + return null; +}; +const validateApplyLink = async function (applyLink) { + const formId = applyLink.split('jobforms/')[1]; + + const jobForms = await JobForms.find({ _id: formId }); + if (!jobForms || jobForms.length === 0) { + return { error: 'Mismatched ApplyLink' }; + } + return null; +}; + // Controller to create a new job const createJob = async (req, res) => { - const { title, category, description, imageUrl, location, applyLink, jobDetailsLink } = req.body; + const { + title, + category, + description, + imageUrl, + // location, + applyLink, + // jobDetailsLink, + requirements, + projects, + ourCommunity, + } = req.body; + if (!(await checkPermission(req, 'createCollabJobAds'))) + return res.status(403).json({ error: 'Forbidden: Insufficient permissions' }); + // Validate Category and Position + + const categoryValidation = await validateCategory(category); + if (categoryValidation) { + res.status(500).json(categoryValidation); + } + + const titleValidation = await validateTitle(title); + if (titleValidation) { + res.status(500).json(titleValidation); + } + + const titleCategoryMatch = await validateTitleCategoryMatch(title, category); + if (titleCategoryMatch) { + return res.status(404).json(titleCategoryMatch); + } + + const applyLinkValidation = await validateApplyLink(applyLink); + if (applyLinkValidation) { + return res.status(404).json(applyLinkValidation); + } + + // desctiption word count should be >= 30 + const descriptionWordCount = getWordCount(description); + + if (descriptionWordCount < MIN_WORDS) { + return res.status(403).json({ + error: `Please enter a minimum of ${MIN_WORDS} words for description`, + }); + } + + // requirements word count should be >= 30 + const requirementsWordCount = getWordCount(requirements); + + if (requirementsWordCount < MIN_WORDS) { + return res.status(403).json({ + error: `Please enter a minimum of ${MIN_WORDS} words for Requireents`, + }); + } + + // projects is required + if (!projects || projects.length === 0) { + return res.status(403).json({ + error: 'Please enter at least one project', + }); + } + const ourCommunityWordCount = getWordCount(ourCommunity); + + if (ourCommunityWordCount < MIN_WORDS) { + return res.status(403).json({ + error: `Please enter a minimum of ${MIN_WORDS} words for ourCommunity`, + }); + } try { // Find the highest displayOrder value currently in use @@ -231,14 +357,16 @@ const createJob = async (req, res) => { const newJob = new Job({ title, - // summaries, category, description, imageUrl, - location, + location: 'remote', applyLink, - jobDetailsLink, + // jobDetailsLink, displayOrder: newDisplayOrder, + requirements, + projects, + ourCommunity, }); const savedJob = await newJob.save(); diff --git a/src/models/JobFormsModel.js b/src/models/JobFormsModel.js index 8eb3b3e1f5..044d6259a3 100644 --- a/src/models/JobFormsModel.js +++ b/src/models/JobFormsModel.js @@ -7,13 +7,13 @@ const formSchema = new mongoose.Schema( questions: [ { questionText: { type: String, required: true }, - visible: { type: Boolean, required: true }, + visible: { type: Boolean, required: true, default: true }, questionType: { type: String, required: true, - enum: ['textbox', 'textarea', 'checkbox', 'radio', 'dropdown', 'date'], + enum: ['textbox', 'textarea', 'checkbox', 'radio', 'dropdown', 'date', 'file', 'email'], }, - isRequired: { type: Boolean, default: false }, + isRequired: { type: Boolean, default: true }, options: [String], }, ], diff --git a/src/models/jobApplicationsModel.js b/src/models/jobApplicationsModel.js index 0e5220dfb9..9bc261d312 100644 --- a/src/models/jobApplicationsModel.js +++ b/src/models/jobApplicationsModel.js @@ -1,32 +1,33 @@ -const mongoose = require("mongoose"); +const mongoose = require('mongoose'); const responseSchema = new mongoose.Schema( { - formId: { - type: mongoose.Schema.Types.ObjectId, - ref: "CollaborationForm", - required: true + formId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'CollaborationForm', + required: true, }, // Reference to the associated form answers: [ { - questionId: { - type: mongoose.Schema.Types.ObjectId, - required: true + questionText: { type: String, required: true }, + questionId: { + type: mongoose.Schema.Types.ObjectId, + required: true, }, // Reference to the associated question answer: mongoose.Schema.Types.Mixed, // Store the user's answer (String, Array, etc.) }, ], - respondent: { - type: String, - default: "Anonymous" + respondent: { + type: String, + default: 'Anonymous', }, // Optional: Name or ID of the respondent - submittedAt: { - type: Date, - default: Date.now + submittedAt: { + type: Date, + default: Date.now, }, // Timestamp for submission }, - { timestamps: true } + { timestamps: true }, ); -const Response = mongoose.model("JobApplications", responseSchema); +const Response = mongoose.model('JobApplications', responseSchema); module.exports = Response; diff --git a/src/models/jobs.js b/src/models/jobs.js index dd38c306f8..865f7d2327 100644 --- a/src/models/jobs.js +++ b/src/models/jobs.js @@ -6,6 +6,13 @@ const jobSchema = new Schema({ title: { type: String, required: true }, // Job title category: { type: String, required: true }, // General category (e.g., Engineering, Marketing) description: { type: String, required: true }, // Detailed job description + // about: { type: String, required: true }, // Detailed about section + requirements: { type: String, required: true }, // Detailed requirements section + // skills: { type: String, required: true }, // Detailed skills section + projects: { type: String, required: true }, // list of projects seperated by comma + // whoareyou: { type: String, required: true }, // Detailed who are you section + ourCommunity: { type: String, required: true }, // Detailed who we are section + // whoweare: { type: String, required: true }, // Detailed who we are section imageUrl: { type: String, required: true }, // URL of the job-related image location: { type: String, required: true }, // Job location (optional for remote jobs) applyLink: { type: String, required: true }, // URL for the application form @@ -15,4 +22,16 @@ const jobSchema = new Schema({ displayOrder: { type: Number, default: 0 }, // Order for displaying jobs on the landing page }); +jobSchema.pre('validate', function (next) { + if (!this.jobDetailsLink) { + const baseFrontendUrl = + `${process.env.BASE_FRONTEND_URL}/jobDetailsLink` || 'http://localhost:5173/jobDetailsLink'; + console.log(`baseFrontendUrl: ${baseFrontendUrl}`); + this.jobDetailsLink = `${baseFrontendUrl}/${this._id}`; + console.log(`this.jobDetailsLink: ${this.jobDetailsLink}`); + console.log(`baseFrontendUrl: ${baseFrontendUrl}`); + console.log(`this.jobDetailsLink: ${this.jobDetailsLink}`); + } + next(); +}); module.exports = mongoose.model('Job', jobSchema); diff --git a/src/routes/collaborationRouter.js b/src/routes/collaborationRouter.js index cf2c523720..aa647180f1 100644 --- a/src/routes/collaborationRouter.js +++ b/src/routes/collaborationRouter.js @@ -1,8 +1,9 @@ +const multer = require('multer'); const express = require('express'); const formController = require('../controllers/collaborationController'); const router = express.Router(); - +const upload = multer({ storage: multer.memoryStorage() }); // Create a new form router.post('/jobforms', formController.createForm); @@ -15,7 +16,17 @@ router.put('/jobforms', formController.updateFormFormat); router.get('/jobforms/:formId', formController.getFormFormat); // Get all responses of a form -router.get('/jobforms/:formId/responses', formController.getFormResponses); +router.get('/jobforms/responses/formId', formController.getFormResponses); + +// post the responseUpload of a form +router.post( + '/jobforms/responses/upload', + upload.single('file'), + formController.postFormResponseUpload, +); + +// post the responses of a form +router.post('/jobforms/responses', formController.postFormResponses); // Question management routes router.post('/jobforms/:formId/questions', formController.addQuestion); diff --git a/src/routes/jobsRouter.js b/src/routes/jobsRouter.js index 7eba92cc93..f2a84d2be3 100644 --- a/src/routes/jobsRouter.js +++ b/src/routes/jobsRouter.js @@ -9,7 +9,7 @@ router.get('/reset-filters', jobsController.resetJobsFilters); router.get('/summaries', jobsController.getJobSummaries); router.get('/', jobsController.getJobs); router.get('/categories', jobsController.getCategories); -router.get('/positions', jobsController.getPositions); +router.get('/positions/:category?', jobsController.getPositions); router.get('/:id', jobsController.getJobById); router.post('/', jobsController.createJob); router.put('/:id', jobsController.updateJob); diff --git a/src/startup/middleware.js b/src/startup/middleware.js index c4815fd794..cfd7d3c21a 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -68,6 +68,21 @@ module.exports = function (app) { return; } + if (req.originalUrl.startsWith('/api/jobforms') && req.method === 'GET') { + next(); + return; + } + + if (req.originalUrl.startsWith('/api/jobforms/responses') && req.method === 'POST') { + next(); + return; + } + + if (req.originalUrl.startsWith('/api/jobforms/responses/upload') && req.method === 'POST') { + next(); + return; + } + // Public analytics tracking endpoints (no auth required) if ( (req.originalUrl === '/api/applicant-analytics/track-interaction' ||