diff --git a/src/controllers/collaborationController.js b/src/controllers/collaborationController.js index de133da8a..e526149b5 100644 --- a/src/controllers/collaborationController.js +++ b/src/controllers/collaborationController.js @@ -3,6 +3,8 @@ const Form = require('../models/JobFormsModel'); const Response = require('../models/jobApplicationsModel'); const upload = require('../middleware/multerMiddleware'); const QuestionSet = require('../models/questionSet'); +const { uploadFileToAzureBlobStorage } = require('../utilities/AzureBlobImages'); +const emailSender = require('../utilities/emailSender'); const { canManageJobForms, canCreateFormQuestions, @@ -66,6 +68,179 @@ function ensureFormMetadata(form, requestor) { form.lastModifiedBy = requestorId; } +function sanitizeBlobPart(value, fallback = 'file') { + const cleaned = String(value || '') + .replace(/[^a-zA-Z0-9]/g, '_') + .toLowerCase(); + return cleaned || fallback; +} + +function filesByField(req) { + const map = {}; + if (req.file) { + map[req.file.fieldname || 'resume'] = req.file; + } + (req.files || []).forEach((file) => { + map[file.fieldname] = file; + }); + return map; +} + +function fileExtension(file) { + if (file?.originalname?.includes('.')) { + return file.originalname.split('.').pop(); + } + return 'bin'; +} + +async function uploadOptionalFile(file, blobName) { + if (!file) { + return ''; + } + try { + return await uploadFileToAzureBlobStorage(file, blobName); + } catch (uploadErr) { + console.error('Application file upload failed (non-fatal):', uploadErr.message); + return ''; + } +} + +function parseJsonField(value, errorMessage) { + if (typeof value !== 'string') { + return { value, error: null }; + } + try { + return { value: JSON.parse(value), error: null }; + } catch { + return { value: null, error: errorMessage }; + } +} + +function resolveApplicationInput(body) { + if (body.payload) { + const parsed = parseJsonField(body.payload, 'Invalid application payload.'); + if (parsed.error) { + return { error: parsed.error }; + } + const payload = parsed.value || {}; + return { + respondent: payload.applicantName || body.respondent, + email: payload.applicantEmail || body.email, + answers: payload.answers || [], + profile: payload.profile || {}, + }; + } + + const parsedAnswers = parseJsonField(body.answers, 'answers must be a valid JSON array.'); + if (parsedAnswers.error) { + return { error: parsedAnswers.error }; + } + + return { + respondent: body.respondent, + email: body.email, + answers: parsedAnswers.value, + profile: {}, + }; +} + +async function uploadResumeIfPresent(resumeFile, safeFormTitle, safeEmail) { + if (!resumeFile) { + return ''; + } + return uploadOptionalFile( + resumeFile, + `resumes/${safeFormTitle}_${safeEmail}_${Date.now()}.${fileExtension(resumeFile)}`, + ); +} + +async function buildAnswerEntry(item, fileMap, safeFormTitle, safeEmail) { + const qIdStr = item.questionId ? String(item.questionId) : ''; + const uploaded = qIdStr ? fileMap[`questionFile_${qIdStr}`] : null; + const questionId = item.questionId || new mongoose.Types.ObjectId(); + + if (!uploaded) { + return { questionId, answer: item.answer }; + } + + const fileUrl = await uploadOptionalFile( + uploaded, + `resumes/${safeFormTitle}_${safeEmail}_q_${sanitizeBlobPart(qIdStr)}_${Date.now()}.${fileExtension(uploaded)}`, + ); + + return { + questionId, + answer: { + fileName: uploaded.originalname, + mimeType: uploaded.mimetype, + size: uploaded.size, + ...(fileUrl ? { url: fileUrl } : {}), + }, + }; +} + +async function buildAnswersFromSubmission(answers, fileMap, safeFormTitle, safeEmail) { + const answersList = Array.isArray(answers) ? answers : []; + const builtAnswers = []; + for (const item of answersList) { + builtAnswers.push(await buildAnswerEntry(item, fileMap, safeFormTitle, safeEmail)); + } + return builtAnswers; +} + +function appendResumeAndProfileAnswers( + builtAnswers, + resumeFile, + resumeUrl, + respondent, + normalizedEmail, + profile, +) { + if (resumeFile) { + builtAnswers.push({ + questionId: new mongoose.Types.ObjectId(), + answer: { + type: 'resume', + fileName: resumeFile.originalname, + mimeType: resumeFile.mimetype, + size: resumeFile.size, + ...(resumeUrl ? { url: resumeUrl } : {}), + }, + }); + } + + if (respondent || Object.keys(profile || {}).length > 0) { + builtAnswers.push({ + questionId: new mongoose.Types.ObjectId(), + answer: { + type: 'applicantProfile', + applicantName: respondent, + applicantEmail: normalizedEmail, + ...profile, + }, + }); + } +} + +async function sendApplicationConfirmationEmail(form, respondent, normalizedEmail) { + try { + const displayName = String(respondent || normalizedEmail).trim(); + const emailBody = ` +
+

Application Received — ${form.title}

+

Hi ${displayName},

+

Thank you for applying for ${form.title}. We have received your application and will be in touch shortly.

+

If you have any questions, feel free to reach out.

+
+

Best regards,
One Community

+
+ `; + await emailSender([normalizedEmail], `Application Received — ${form.title}`, emailBody); + } catch (emailErr) { + console.error('Confirmation email failed (non-fatal):', emailErr.message); + } +} + // Create a new form exports.createForm = async (req, res) => { try { @@ -251,6 +426,91 @@ exports.submitJobApplication = async (req, res) => { exports.submitJobApplicationMiddleware = upload.any(); +/** + * POST /api/jobforms/:formId/responses + * Submit an application response to a job form (public — paired with frontend PR 5469). + * + * Body (JSON or multipart/form-data): + * respondent {string} - applicant full name (required) + * email {string} - applicant email (required) + * answers {Array} - [{ questionId, answer }] (array or JSON string) + * + * Optional multipart field: + * resume {file} - resume file (stored in Azure; URL saved on the response) + * + * Responses: + * 201 - { message, response } + * 400 - missing required fields + * 409 - duplicate application + * 404 - form not found + * 500 - server error + */ +exports.submitFormResponse = async (req, res) => { + try { + const { formId } = req.params; + const resolved = resolveApplicationInput(req.body || {}); + if (resolved.error) { + return res.status(400).json({ error: resolved.error }); + } + + const { respondent, email, answers, profile } = resolved; + if (!email || answers == null) { + return res.status(400).json({ error: 'respondent, email, and answers are required.' }); + } + + const form = await Form.findById(formId); + if (!form) { + return res.status(404).json({ error: 'Form not found.' }); + } + + const normalizedEmail = String(email).trim().toLowerCase(); + const existing = await Response.findOne({ + formId, + $or: [{ email: normalizedEmail }, { respondent: normalizedEmail }], + }); + if (existing) { + return res.status(409).json({ error: 'Application already submitted.' }); + } + + const fileMap = filesByField(req); + const safeFormTitle = sanitizeBlobPart(form.title, 'form'); + const safeEmail = sanitizeBlobPart(normalizedEmail, 'applicant'); + const resumeFile = fileMap.resume; + const resumeUrl = await uploadResumeIfPresent(resumeFile, safeFormTitle, safeEmail); + const builtAnswers = await buildAnswersFromSubmission( + answers, + fileMap, + safeFormTitle, + safeEmail, + ); + + appendResumeAndProfileAnswers( + builtAnswers, + resumeFile, + resumeUrl, + respondent, + normalizedEmail, + profile, + ); + + const response = new Response({ + formId, + respondent: String(respondent || normalizedEmail).trim(), + email: normalizedEmail, + answers: builtAnswers, + resumeUrl, + }); + + await response.save(); + await sendApplicationConfirmationEmail(form, respondent, normalizedEmail); + + return res.status(201).json({ message: 'Application submitted successfully.', response }); + } catch (error) { + console.error('Error submitting form response:', error); + return res.status(500).json({ message: 'Error submitting application.', error: error.message }); + } +}; + // Get all responses of a form exports.getFormResponses = async (req, res) => { try { diff --git a/src/controllers/collaborationController.test.js b/src/controllers/collaborationController.test.js new file mode 100644 index 000000000..8f0755f75 --- /dev/null +++ b/src/controllers/collaborationController.test.js @@ -0,0 +1,291 @@ +jest.mock('../models/JobFormsModel'); +jest.mock('../models/jobApplicationsModel'); +jest.mock('../models/questionSet'); +jest.mock('../utilities/AzureBlobImages'); +jest.mock('../utilities/emailSender'); +jest.mock('../utilities/jobFormPermissions', () => ({ + canManageJobForms: jest.fn(), + canCreateFormQuestions: jest.fn(), + canEditFormQuestions: jest.fn(), + canDeleteFormQuestions: jest.fn(), +})); +jest.mock('../middleware/multerMiddleware', () => ({ + single: jest.fn(() => (req, res, next) => next && next()), + any: jest.fn(() => (req, res, next) => next && next()), +})); + +const Form = require('../models/JobFormsModel'); +const Response = require('../models/jobApplicationsModel'); +const { uploadFileToAzureBlobStorage } = require('../utilities/AzureBlobImages'); +const emailSender = require('../utilities/emailSender'); +const { submitFormResponse } = require('./collaborationController'); + +const createMockRes = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +describe('submitFormResponse', () => { + const formId = '507f1f77bcf86cd799439011'; + let res; + let saveMock; + + beforeEach(() => { + jest.clearAllMocks(); + res = createMockRes(); + saveMock = jest.fn().mockResolvedValue(true); + Form.findById = jest.fn(); + Response.findOne = jest.fn(); + Response.mockImplementation((data) => ({ ...data, save: saveMock })); + emailSender.mockResolvedValue('ok'); + uploadFileToAzureBlobStorage.mockResolvedValue('https://blob/resume.pdf'); + }); + + const validBody = { + respondent: 'Ada Lovelace', + email: 'Ada@Example.com', + answers: [{ questionId: 'q1', answer: 'yes' }], + }; + + it('returns 400 when required fields are missing', async () => { + await submitFormResponse({ params: { formId }, body: { respondent: 'Ada' } }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'respondent, email, and answers are required.', + }); + }); + + it('returns 400 for invalid payload JSON', async () => { + await submitFormResponse({ params: { formId }, body: { payload: '{bad json' } }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid application payload.' }); + }); + + it('returns 400 for invalid answers JSON string', async () => { + await submitFormResponse( + { + params: { formId }, + body: { respondent: 'Ada', email: 'ada@example.com', answers: '{bad json' }, + }, + res, + ); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'answers must be a valid JSON array.' }); + }); + + it('returns 500 when saving the response fails', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + saveMock.mockRejectedValue(new Error('db down')); + + await submitFormResponse({ params: { formId }, body: validBody }, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Error submitting application.' }), + ); + }); + + it('returns 404 when the form does not exist', async () => { + Form.findById.mockResolvedValue(null); + await submitFormResponse({ params: { formId }, body: validBody }, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Form not found.' }); + }); + + it('returns 409 when the email already applied to this form', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue({ _id: 'existing' }); + await submitFormResponse({ params: { formId }, body: validBody }, res); + expect(Response.findOne).toHaveBeenCalledWith({ + formId, + $or: [{ email: 'ada@example.com' }, { respondent: 'ada@example.com' }], + }); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ error: 'Application already submitted.' }); + }); + + it('returns 201, saves email, and sends a confirmation email', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + + await submitFormResponse({ params: { formId }, body: validBody }, res); + + expect(saveMock).toHaveBeenCalled(); + expect(emailSender).toHaveBeenCalledWith( + ['ada@example.com'], + 'Application Received — Software Developer', + expect.stringContaining('Ada Lovelace'), + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Application submitted successfully.', + }), + ); + }); + + it('parses answers JSON strings and stores resumeUrl when a file is uploaded', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + + await submitFormResponse( + { + params: { formId }, + body: { + ...validBody, + answers: JSON.stringify(validBody.answers), + }, + file: { + originalname: 'resume.pdf', + buffer: Buffer.from('pdf'), + mimetype: 'application/pdf', + }, + }, + res, + ); + + expect(uploadFileToAzureBlobStorage).toHaveBeenCalled(); + expect(saveMock).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('still returns 201 if resume upload or email sending fails', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + uploadFileToAzureBlobStorage.mockRejectedValue(new Error('azure down')); + emailSender.mockRejectedValue(new Error('smtp down')); + + await submitFormResponse( + { + params: { formId }, + body: validBody, + file: { + originalname: 'resume.pdf', + buffer: Buffer.from('pdf'), + mimetype: 'application/pdf', + }, + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('accepts the site payload format and stores question-file uploads', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + uploadFileToAzureBlobStorage + .mockResolvedValueOnce('https://blob/resume.pdf') + .mockResolvedValueOnce('https://blob/cover.pdf'); + + let savedDoc; + Response.mockImplementation((data) => { + savedDoc = data; + return { ...data, save: saveMock }; + }); + + const questionId = '6a4f01a854e483075a73a6a9'; + await submitFormResponse( + { + params: { formId }, + body: { + payload: JSON.stringify({ + applicantName: 'Purav Jignesh Patel', + applicantEmail: 'purav13pat@gmail.com', + profile: { + locationTimezone: 'Houston, TX, US | America/New_York', + phone: '+1 2813091557', + jobTitle: 'APPLIED THROUGH SITE - SEEKING SOFTWARE POSITION', + }, + answers: [ + { questionId: '6a4f01a854e483075a73a69e', answer: 'Individual' }, + { questionId, answer: { fileName: 'Cover_Letter.pdf', size: 12923 } }, + ], + }), + }, + files: [ + { + fieldname: 'resume', + originalname: 'Purav Patel_Resume.pdf', + mimetype: 'application/pdf', + size: 87900, + buffer: Buffer.from('resume'), + }, + { + fieldname: `questionFile_${questionId}`, + originalname: 'Cover_Letter_Purav Jignesh Patel.pdf', + mimetype: 'application/pdf', + size: 12923, + buffer: Buffer.from('cover'), + }, + ], + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(201); + expect(savedDoc.email).toBe('purav13pat@gmail.com'); + expect(savedDoc.resumeUrl).toBe('https://blob/resume.pdf'); + expect(savedDoc.answers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + questionId, + answer: expect.objectContaining({ + fileName: 'Cover_Letter_Purav Jignesh Patel.pdf', + url: 'https://blob/cover.pdf', + }), + }), + expect.objectContaining({ + answer: expect.objectContaining({ + type: 'applicantProfile', + applicantName: 'Purav Jignesh Patel', + jobTitle: 'APPLIED THROUGH SITE - SEEKING SOFTWARE POSITION', + }), + }), + ]), + ); + }); + + it('stores question-file metadata without url when upload fails', async () => { + Form.findById.mockResolvedValue({ _id: formId, title: 'Software Developer' }); + Response.findOne.mockResolvedValue(null); + uploadFileToAzureBlobStorage.mockRejectedValue(new Error('azure down')); + + let savedDoc; + Response.mockImplementation((data) => { + savedDoc = data; + return { ...data, save: saveMock }; + }); + + const questionId = '6a4f01a854e483075a73a6a9'; + await submitFormResponse( + { + params: { formId }, + body: { + respondent: 'Ada Lovelace', + email: 'ada@example.com', + answers: [{ questionId, answer: 'cover letter' }], + }, + files: [ + { + fieldname: `questionFile_${questionId}`, + originalname: 'cover.pdf', + mimetype: 'application/pdf', + size: 100, + buffer: Buffer.from('cover'), + }, + ], + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(201); + expect(savedDoc.answers[0].answer).toEqual( + expect.objectContaining({ fileName: 'cover.pdf', mimeType: 'application/pdf' }), + ); + expect(savedDoc.answers[0].answer.url).toBeUndefined(); + }); +}); diff --git a/src/controllers/jobsController.js b/src/controllers/jobsController.js index f50877246..8656ce2f5 100644 --- a/src/controllers/jobsController.js +++ b/src/controllers/jobsController.js @@ -205,6 +205,20 @@ const getPositions = async (req, res) => { } }; +/** + * GET /api/jobs/positions + * Distinct job titles from the jobs collection for the job application form dropdown. + */ +const getActiveJobPositions = async (req, res) => { + try { + const positions = (await Job.distinct('title', {})).filter(Boolean); + positions.sort((a, b) => a.localeCompare(b)); + res.status(200).json({ positions }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch active job positions', details: error.message }); + } +}; + const getJobById = async (req, res) => { const { id } = req.params; @@ -329,4 +343,5 @@ module.exports = { getCategories, reorderJobs, getPositions, + getActiveJobPositions, }; diff --git a/src/controllers/jobsController.test.js b/src/controllers/jobsController.test.js index a968d4c8a..3b6b9b23d 100644 --- a/src/controllers/jobsController.test.js +++ b/src/controllers/jobsController.test.js @@ -7,6 +7,7 @@ const { resetJobsFilters, getCategories, getPositions, + getActiveJobPositions, getJobById, createJob, updateJob, @@ -26,6 +27,7 @@ Job.findByIdAndUpdate = jest.fn(); Job.findByIdAndDelete = jest.fn(); Job.countDocuments = jest.fn(); Job.bulkWrite = jest.fn(); +Job.distinct = jest.fn(); JobPositionCategory.distinct = jest.fn(); // --- HELPER FACTORIES --- @@ -133,6 +135,16 @@ describe('jobsController', () => { await getPositions({}, res); expect(res.json).toHaveBeenCalledWith({ positions: ['Pos1'] }); }); + + it('should return distinct job titles for the application form dropdown', async () => { + Job.distinct.mockResolvedValue(['Writer', 'Software Developer', '', 'Analyst']); + await getActiveJobPositions({}, res); + expect(Job.distinct).toHaveBeenCalledWith('title', {}); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + positions: ['Analyst', 'Software Developer', 'Writer'], + }); + }); }); describe('CRUD Operations', () => { diff --git a/src/models/jobApplicationsModel.js b/src/models/jobApplicationsModel.js index 0e5220dfb..c400e85b6 100644 --- a/src/models/jobApplicationsModel.js +++ b/src/models/jobApplicationsModel.js @@ -1,32 +1,40 @@ -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 + 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 + email: { + type: String, + default: '', + }, // Applicant email — used for duplicate detection + resumeUrl: { + type: String, + default: '', + }, // Azure Blob URL for optional resume upload + 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/routes/collaborationRouter.js b/src/routes/collaborationRouter.js index 914300e21..3ac258ed6 100644 --- a/src/routes/collaborationRouter.js +++ b/src/routes/collaborationRouter.js @@ -1,5 +1,6 @@ const express = require('express'); const formController = require('../controllers/collaborationController'); +const upload = require('../middleware/multerMiddleware'); const router = express.Router(); @@ -17,12 +18,8 @@ router.get('/jobforms/:formId', formController.getFormFormat); // Get all responses of a form router.get('/jobforms/:formId/responses', formController.getFormResponses); -// Submit a job application (public) -router.post( - '/jobforms/:formId/responses', - formController.submitJobApplicationMiddleware, - formController.submitJobApplication, -); +// Submit a job application (public — resume + per-question file uploads) +router.post('/jobforms/:formId/responses', upload.any(), formController.submitFormResponse); // Question management routes router.post('/jobforms/:formId/questions', formController.addQuestion); diff --git a/src/routes/jobsRouter.js b/src/routes/jobsRouter.js index 7eba92cc9..01c89a372 100644 --- a/src/routes/jobsRouter.js +++ b/src/routes/jobsRouter.js @@ -7,9 +7,12 @@ const router = express.Router(); router.get('/suggestions', jobsController.getJobTitleSuggestions); 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); +// Distinct active job titles from the jobs collection — used by the job application form +router.get('/positions', jobsController.getActiveJobPositions); +// Position names from JobPositionCategory (filter/search use) +router.get('/position-categories', jobsController.getPositions); +router.get('/', jobsController.getJobs); 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 565b99673..44fafa7b3 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -91,6 +91,23 @@ module.exports = function (app) { return; } + const pathOnly = req.originalUrl.split('?')[0]; + + // Public: load job application forms (applicants are not logged in) + if ( + req.method === 'GET' && + (pathOnly === '/api/jobforms/all' || /^\/api\/jobforms\/[^/]+$/.test(pathOnly)) + ) { + next(); + return; + } + + // Public: job application form submission (no auth required — external applicants) + if (pathOnly.match(/^\/api\/jobforms\/[^/]+\/responses$/) && req.method === 'POST') { + next(); + return; + } + if (req.originalUrl.startsWith('/api/bluesky')) { next(); return;