266 lines
9.6 KiB
JavaScript
266 lines
9.6 KiB
JavaScript
const express = require('express');
|
|
const db = require('../db/models');
|
|
const wrapAsync = require('../helpers').wrapAsync;
|
|
|
|
const router = express.Router();
|
|
|
|
const INTERVIEW_QUESTIONS = [
|
|
'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?',
|
|
'Describe training that is most effective for your learning and why.',
|
|
'What is a reasonable amount of time that you could dedicate to training?',
|
|
'What makes learning memorable for you? How can training be designed to help stick for you?',
|
|
'How do you learn about training opportunities? Is that method effective? What could make it more effective?',
|
|
];
|
|
|
|
const SECTION_BY_QUESTION = [
|
|
'Participant priorities and interests',
|
|
'Preferred learning methods',
|
|
'Time available for training',
|
|
'What makes training memorable and effective',
|
|
'How the participant hears about training opportunities',
|
|
];
|
|
|
|
const FINDING_TYPES = [
|
|
'priority_interest',
|
|
'preferred_method',
|
|
'time_constraint',
|
|
'implication',
|
|
'channel_preference',
|
|
];
|
|
|
|
const normalizeAnswers = (answers) => {
|
|
if (!Array.isArray(answers)) return [];
|
|
|
|
return INTERVIEW_QUESTIONS.map((question, index) => {
|
|
const answer = answers[index] || {};
|
|
return {
|
|
question,
|
|
answer: String(answer.answer || '').trim(),
|
|
};
|
|
});
|
|
};
|
|
|
|
const sentenceFrom = (text) => {
|
|
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
if (!clean) return 'No specific response captured.';
|
|
return clean.length > 220 ? `${clean.slice(0, 217)}...` : clean;
|
|
};
|
|
|
|
const extractTags = (answers) => {
|
|
const text = answers.map((item) => item.answer).join(' ').toLowerCase();
|
|
const tagRules = [
|
|
['hands-on learning', ['hands-on', 'practice', 'workshop', 'exercise', 'interactive']],
|
|
['short sessions', ['short', 'bite', 'micro', 'hour', '30', 'minutes']],
|
|
['peer learning', ['peer', 'colleague', 'mentor', 'community', 'team']],
|
|
['role-specific training', ['role', 'job', 'specific', 'relevant', 'real-world']],
|
|
['communication channels', ['email', 'newsletter', 'calendar', 'intranet', 'slack', 'teams']],
|
|
['time constraints', ['busy', 'time', 'schedule', 'workload', 'capacity']],
|
|
['follow-up support', ['follow-up', 'refresh', 'reinforce', 'coaching', 'support']],
|
|
];
|
|
|
|
const tags = tagRules
|
|
.filter(([, terms]) => terms.some((term) => text.includes(term)))
|
|
.map(([tag]) => tag);
|
|
|
|
return tags.length ? tags : ['training needs assessment'];
|
|
};
|
|
|
|
const buildSummary = (answers, mode) => {
|
|
const lines = SECTION_BY_QUESTION.map((section, index) => `- ${section}: ${sentenceFrom(answers[index]?.answer)}`);
|
|
const gaps = answers
|
|
.map((item) => item.answer)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
.match(/gap|need|hard|difficult|barrier|challenge|limited|lack/g);
|
|
|
|
lines.push(`- Notable training gaps, barriers, or unmet needs: ${gaps ? 'Potential unmet needs or barriers were mentioned and should be reviewed in context.' : 'No explicit gap or barrier language was captured in this first-pass summary.'}`);
|
|
lines.push(`- Recommended implications for future training design: Prioritize practical, easy-to-access training formats that reflect the participant's stated preferences and constraints.`);
|
|
lines.push(`- Interview mode: ${mode === 'voice' ? 'Voice or dictated responses' : mode === 'typed' ? 'Typed responses' : 'Mixed mode'}.`);
|
|
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const organizationWhere = (currentUser) => {
|
|
if (currentUser.app_role?.globalAccess) return {};
|
|
const organizationId = currentUser.organization?.id || currentUser.organizationId;
|
|
return organizationId ? { organizationId } : {};
|
|
};
|
|
|
|
router.get('/records', wrapAsync(async (req, res) => {
|
|
const limit = Math.min(Number(req.query.limit) || 20, 50);
|
|
const rows = await db.interviews.findAll({
|
|
where: organizationWhere(req.currentUser),
|
|
include: [
|
|
{ model: db.participants, as: 'participant' },
|
|
{ model: db.users, as: 'facilitator' },
|
|
],
|
|
order: [['createdAt', 'DESC']],
|
|
limit,
|
|
});
|
|
|
|
res.status(200).send({ rows });
|
|
}));
|
|
|
|
router.get('/analysis', wrapAsync(async (req, res) => {
|
|
const interviewWhere = organizationWhere(req.currentUser);
|
|
const interviews = await db.interviews.findAll({
|
|
where: interviewWhere,
|
|
include: [
|
|
{ model: db.participants, as: 'participant' },
|
|
{ model: db.interview_findings, as: 'interview_findings_interview' },
|
|
],
|
|
order: [['createdAt', 'DESC']],
|
|
limit: 100,
|
|
});
|
|
|
|
const findingCounts = {};
|
|
const tagCounts = {};
|
|
|
|
interviews.forEach((interview) => {
|
|
(interview.interview_findings_interview || []).forEach((finding) => {
|
|
const type = finding.finding_type || 'uncategorized';
|
|
findingCounts[type] = (findingCounts[type] || 0) + 1;
|
|
});
|
|
|
|
try {
|
|
const parsed = JSON.parse(interview.inference_notes || '{}');
|
|
(parsed.tags || []).forEach((tag) => {
|
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to parse interview inference notes', error);
|
|
}
|
|
});
|
|
|
|
const themes = Object.entries(tagCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8)
|
|
.map(([theme, count]) => ({ theme, count }));
|
|
|
|
res.status(200).send({
|
|
interviewCount: interviews.length,
|
|
findingCounts,
|
|
themes,
|
|
latestSummaries: interviews.slice(0, 5).map((interview) => ({
|
|
id: interview.id,
|
|
participant: interview.participant?.display_name || 'Unnamed participant',
|
|
final_summary: interview.final_summary,
|
|
createdAt: interview.createdAt,
|
|
})),
|
|
});
|
|
}));
|
|
|
|
router.get('/records/:id', wrapAsync(async (req, res) => {
|
|
const interview = await db.interviews.findOne({
|
|
where: { id: req.params.id, ...organizationWhere(req.currentUser) },
|
|
include: [
|
|
{ model: db.participants, as: 'participant' },
|
|
{ model: db.users, as: 'facilitator' },
|
|
{ model: db.interview_responses, as: 'interview_responses_interview' },
|
|
{ model: db.interview_findings, as: 'interview_findings_interview' },
|
|
],
|
|
});
|
|
|
|
if (!interview) {
|
|
return res.status(404).send('Interview record not found');
|
|
}
|
|
|
|
return res.status(200).send(interview);
|
|
}));
|
|
|
|
router.post('/records', wrapAsync(async (req, res) => {
|
|
const mode = req.body.mode;
|
|
const participant = req.body.participant || {};
|
|
const answers = normalizeAnswers(req.body.answers);
|
|
|
|
if (!['voice', 'typed', 'mixed'].includes(mode)) {
|
|
return res.status(400).send('Choose voice, typed, or mixed interview mode.');
|
|
}
|
|
|
|
const missingAnswer = answers.find((item) => !item.answer);
|
|
if (missingAnswer) {
|
|
return res.status(400).send('Please capture a response for every core interview question before saving.');
|
|
}
|
|
|
|
const organizationId = req.currentUser.organization?.id || req.currentUser.organizationId || null;
|
|
const transaction = await db.sequelize.transaction();
|
|
|
|
try {
|
|
const createdParticipant = await db.participants.create({
|
|
display_name: String(participant.display_name || '').trim() || `Participant ${new Date().toISOString().slice(0, 10)}`,
|
|
department: String(participant.department || '').trim() || null,
|
|
job_title: String(participant.job_title || '').trim() || null,
|
|
notes: String(participant.notes || '').trim() || null,
|
|
consent_to_store: true,
|
|
organizationId,
|
|
createdById: req.currentUser.id,
|
|
updatedById: req.currentUser.id,
|
|
}, { transaction });
|
|
|
|
const tags = extractTags(answers);
|
|
const finalSummary = buildSummary(answers, mode);
|
|
const inferenceNotes = JSON.stringify({
|
|
objective: 'Understand current practices, training gaps, and priority skill areas across workforce interviews.',
|
|
tags,
|
|
generated_by: 'rule_based_mvp_summary',
|
|
});
|
|
|
|
const interview = await db.interviews.create({
|
|
started_at: new Date(),
|
|
completed_at: new Date(),
|
|
mode,
|
|
status: 'completed',
|
|
context_notes: String(req.body.context_notes || '').trim() || 'Fixed five-question training needs assessment interview.',
|
|
final_summary: finalSummary,
|
|
inference_notes: inferenceNotes,
|
|
organizationId,
|
|
participantId: createdParticipant.id,
|
|
facilitatorId: req.currentUser.id,
|
|
createdById: req.currentUser.id,
|
|
updatedById: req.currentUser.id,
|
|
}, { transaction });
|
|
|
|
await Promise.all(answers.map((item, index) => db.interview_responses.create({
|
|
turn_number: index + 1,
|
|
response_format: mode === 'voice' ? 'spoken_style' : 'typed',
|
|
response_text: `Question: ${item.question}\n\nResponse: ${item.answer}`,
|
|
is_clarification: false,
|
|
interviewId: interview.id,
|
|
organizationsId: organizationId,
|
|
createdById: req.currentUser.id,
|
|
updatedById: req.currentUser.id,
|
|
}, { transaction })));
|
|
|
|
await Promise.all(answers.map((item, index) => db.interview_findings.create({
|
|
finding_type: FINDING_TYPES[index] || 'implication',
|
|
sentiment: 'neutral',
|
|
confidence: 0.7,
|
|
evidence_excerpt: sentenceFrom(item.answer),
|
|
notes: `${SECTION_BY_QUESTION[index]}: ${sentenceFrom(item.answer)}`,
|
|
interviewId: interview.id,
|
|
organizationsId: organizationId,
|
|
createdById: req.currentUser.id,
|
|
updatedById: req.currentUser.id,
|
|
}, { transaction })));
|
|
|
|
await transaction.commit();
|
|
|
|
const savedInterview = await db.interviews.findByPk(interview.id, {
|
|
include: [
|
|
{ model: db.participants, as: 'participant' },
|
|
{ model: db.interview_responses, as: 'interview_responses_interview' },
|
|
{ model: db.interview_findings, as: 'interview_findings_interview' },
|
|
],
|
|
});
|
|
|
|
return res.status(201).send(savedInterview);
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}));
|
|
|
|
router.use('/', require('../helpers').commonErrorHandler);
|
|
|
|
module.exports = router;
|