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;