Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/2631 SCC Report Summary page #1282

Merged
merged 14 commits into from
Dec 20, 2024
3 changes: 3 additions & 0 deletions database/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ service cloud.firestore {
match /reports/{reportId} {
allow read: if userIsAuthenticated() && userIsJAC() && hasPermission('e1');
}
match /reports/sccSummary { // specific rule for sccSummary report
allow write: if userIsAuthenticated() && userIsJAC() && hasPermission('e3');
}
match /tasks/{taskId} {
allow create, update: if userIsAuthenticated() && userIsJAC();
allow read: if userIsAuthenticated() && userIsJAC();
Expand Down
237 changes: 237 additions & 0 deletions functions/actions/exercises/sccSummaryReport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { getDocument, formatDate } from '../../shared/helpers.js';
import htmlWriter from '../../shared/htmlWriter.js';
import config from '../../shared/config.js';
import initDrive from '../../shared/google-drive.js';
import initExerciseHelper from '../../shared/exerciseHelper.js';

const drive = initDrive();

export default (firebase, db) => {
const { SELECTION_CATEGORIES, applicationCounts, shortlistingMethods, formatSelectionDays } = initExerciseHelper(config);
const { APPLICATION_STATUS } = config;

return {
generateSccSummaryReport,
exportSccSummaryReport,
};

async function generateSccSummaryReport(exerciseId) {
const exercise = await getDocument(db.collection('exercises').doc(exerciseId));
const sccSummaryReport = await db.collection('exercises').doc(exerciseId).collection('reports').doc('sccSummary').get();
const reportData = sccSummaryReport.exists ? sccSummaryReport.data() : {};

// set the report-specific fields from the report document
const numberOfCandidatesProposedForRecommendation = reportData.numberOfCandidatesProposedForRecommendation || '';
const shortlistingDates = reportData.shortlistingDates || '';
const statutoryConsultees = reportData.statutoryConsultees || '';
const vr = reportData.vr || '';
const dateS94ListCreated = reportData.dateS94ListCreated || '';
const candidatesRemainingOnS94List = reportData.candidatesRemainingOnS94List || '';
const vacanciesByJurisdictionChamber = reportData.vacanciesByJurisdictionChamber || '';
const characterChecksUndertaken = reportData.characterChecksUndertaken || '';
const numberOfACandidates = reportData.numberOfACandidates || '';
const characterIssues = reportData.characterIssues || '';
const mattersRequiringADecision = reportData.mattersRequiringADecision || '';
const previouslyDeclaredWithinGuidance = reportData.previouslyDeclaredWithinGuidance || '';
const highScoringDCandidates = reportData.highScoringDCandidates || '';

// set the report-specific fields from the exercise document
let numberOfVacancies = [];
if (exercise.immediateStart > 0) {
numberOfVacancies.push(`${exercise.immediateStart} Immediate start (S87)`);
}
if (exercise.futureStart > 0) {
numberOfVacancies.push(`${exercise.futureStart} Future start (S94)`);
}

const locationDetails = exercise.location;
const launch = formatDate(exercise.applicationOpenDate);
const closed = formatDate(exercise.applicationCloseDate);

const applicationCountValues = applicationCounts(exercise);
const numberOfApplications = applicationCountValues.applied || 0;
const numberOfWithdrawals = applicationCountValues.withdrawn || 0;

let numberOfRemovedOnEligibilityOrASC = 0;
let numberOfShortlisted = 0;

if (exercise._applicationRecords.status) {
numberOfRemovedOnEligibilityOrASC =
(exercise._applicationRecords.status[APPLICATION_STATUS.REJECTED_INELIGIBLE_STATUTORY] || 0) +
(exercise._applicationRecords.status[APPLICATION_STATUS.REJECTED_INELIGIBLE_ADDITIONAL] || 0);
numberOfShortlisted = exercise._applicationRecords.status[APPLICATION_STATUS.SHORTLISTING_PASSED] || 0;
}

const shortlistingMethod = shortlistingMethods(exercise);
const selectionCategories = exercise.selectionCategories || [];
const selectionDayTools = Object.values(SELECTION_CATEGORIES).filter((c) => selectionCategories.includes(c.value)).map((c) => c.description);
const datesOfSelectionDays = exercise.selectionDays || [];

// construct the report document
const report = {
numberOfVacancies: numberOfVacancies.join(', '),
locationDetails,
launch,
closed,
numberOfApplications,
numberOfWithdrawals,
numberOfRemovedOnEligibilityOrASC,
numberOfShortlisted,
selectionDayTools: selectionDayTools.join(', '),
datesOfSelectionDays,
numberOfACandidates,
characterChecksUndertaken,
characterIssues,
mattersRequiringADecision,
previouslyDeclaredWithinGuidance,
highScoringDCandidates,
numberOfCandidatesProposedForRecommendation,
vacanciesByJurisdictionChamber,
vr,
shortlistingDates,
statutoryConsultees: statutoryConsultees.length > 0 ? statutoryConsultees : 'Insert name(s) or Consultation waived',
dateS94ListCreated,
candidatesRemainingOnS94List,
shortlistingMethod: shortlistingMethod.join(', '),
};

// store the report document in the database
await db.collection('exercises').doc(exerciseId).collection('reports').doc('sccSummary').set(report, { merge: true });

// return the report in the HTTP response
return report;
}

/**
* exportSccSummaryReport
* Generates an export of the SCC Summary report for the selected exercise
* @param {*} `exerciseId` (required) ID of exercise to include in the export
*/
async function exportSccSummaryReport(exerciseId) {

const exercise = await getDocument(db.collection('exercises').doc(exerciseId));
const sccSummaryReport = await generateSccSummaryReport(exerciseId);

// get drive service
await drive.login();

// get settings and apply them
const settings = await getDocument(db.collection('settings').doc('services'));
drive.setDriveId(settings.google.driveId);

// generate a filename for the document we are going to create ex. JAC00787_SCC Summary
const now = new Date();
// const timestamp = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString();
const filename = `${exercise.referenceNumber}_SCC Summary` ;

// make sure a destination folder exists to create the file in
const folderName = 'SCC Summary Export';
const folders = await drive.listFolders();
let folderId = 0;
folders.forEach((v, i) => {
if (v.name === folderName) {
folderId = v.id;
}
});
if (folderId === 0) { // folder doesn't exist so create it
folderId = await drive.createFolder(folderName);
}

// Create SCC Summary document
const fileId = await drive.createFile(filename, {
folderId: folderId,
sourceType: drive.MIME_TYPE.HTML,
sourceContent: getHtmlSccSummaryReport(sccSummaryReport),
destinationType: drive.MIME_TYPE.DOCUMENT,
});

if (fileId) {
return await drive.exportFile(fileId, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
}

return false;
}

function getHtmlSccSummaryReport(sccSummaryReport) {
const {
numberOfVacancies,
locationDetails,
launch,
closed,
numberOfApplications,
numberOfWithdrawals,
numberOfRemovedOnEligibilityOrASC,
numberOfShortlisted,
selectionDayTools,
datesOfSelectionDays,
numberOfACandidates,
characterChecksUndertaken,
characterIssues,
mattersRequiringADecision,
previouslyDeclaredWithinGuidance,
highScoringDCandidates,
numberOfCandidatesProposedForRecommendation,
vacanciesByJurisdictionChamber,
vr,
shortlistingDates,
statutoryConsultees,
dateS94ListCreated,
candidatesRemainingOnS94List,
shortlistingMethod,
} = sccSummaryReport;

const rows = [
{ header: 'Number of vacancies', content: numberOfVacancies },
{ header: 'Number of candidates proposed for Recommendation', content: numberOfCandidatesProposedForRecommendation },
{ header: 'Vacancies by jurisdiction/chamber', content: vacanciesByJurisdictionChamber },
{ header: 'Location details', content: locationDetails },
{ header: 'VR (includes details of SPTW)', content: `Annex ${vr}` },
{ header: '', content: '' },
{ header: 'Launch', content: formatDate(launch, 'DD/MM/YYYY') },
{ header: 'Closed', content: formatDate(closed, 'DD/MM/YYYY') },
{ header: 'Number of applications', content: numberOfApplications },
{ header: 'Number of withdrawals', content: numberOfWithdrawals },
{ header: 'Number removed on eligibility/ASC', content: numberOfRemovedOnEligibilityOrASC },
{ header: '', content: '' },
{ header: 'Number shortlisted', content: numberOfShortlisted },
{ header: 'Method of shortlisting', content: shortlistingMethod },
{ header: 'Dates of shortlisting', content: shortlistingDates },
{ header: '', content: '' },
{ header: 'Statutory consultee(s)', content: statutoryConsultees },
{ header: '', content: '' },
{ header: 'Selection day tools', content: selectionDayTools },
{ header: 'Number of A-C candidates', content: numberOfACandidates },
{ header: 'Dates of selection days', content: formatSelectionDays({ selectionDays: datesOfSelectionDays}).join('<br />') },
{ header: '', content: '' },
{ header: 'Date s.94 list Created', content: dateS94ListCreated },
{ header: 'Candidates Remaining on s.94 list', content: candidatesRemainingOnS94List },
{ header: '', content: '' },
{ header: 'Character Checks undertaken', content: characterChecksUndertaken },
{ header: 'Character issues', content: characterIssues },
{ header: 'Matters requiring a decision', content: `Annex ${mattersRequiringADecision}` },
{ header: 'Previously declared, within guidance', content: `Annex ${previouslyDeclaredWithinGuidance}` },
{ header: 'High scoring D candidates', content: `Annex ${highScoringDCandidates}` },
];

let writer = new htmlWriter();
writer.addHeading('SCC Summary', 'center');
writer.addRaw(`
<table>
<tbody>`
);
for (const row of rows) {
writer.addRaw(
`<tr>
<td><b>${row.header}</b></td>
<td>${row.content}</td>
</tr>`
);
}
writer.addRaw(
`</tbody>
</table>`);

return writer.toString();
}
};

50 changes: 50 additions & 0 deletions functions/callableFunctions/exportSccSummaryReport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import functions from 'firebase-functions';
import { firebase, db, auth } from '../shared/admin.js';
import initExportSccSummaryReport from '../actions/exercises/sccSummaryReport.js';
import { getDocument } from '../shared/helpers.js';
import initLogEvent from '../actions/logs/logEvent.js';
import initServiceSettings from '../shared/serviceSettings.js';
import { PERMISSIONS, hasPermissions } from '../shared/permissions.js';

const { exportSccSummaryReport } = initExportSccSummaryReport(firebase, db);
const { logEvent } = initLogEvent(firebase, db, auth);
const { checkFunctionEnabled } = initServiceSettings(db);

export default functions.region('europe-west2').https.onCall(async (data, context) => {
await checkFunctionEnabled();

if (!context.auth) {
throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.');
}

hasPermissions(context.auth.token.rp, [
PERMISSIONS.exercises.permissions.canReadExercises.value,
PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value,
PERMISSIONS.applications.permissions.canReadApplications.value,
]);

if (!(typeof data.exerciseId === 'string') || data.exerciseId.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'Please specify an "exerciseId"');
}

if (!(typeof data.format === 'string') || data.format.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'Please specify a data format (excel or googledoc)');
}

const exercise = await getDocument(db.collection('exercises').doc(data.exerciseId));

if (!exercise) {
throw new functions.https.HttpsError('not-found', 'Excercise not found');
}

let details = {
exerciseId: exercise.id,
exerciseRef: exercise.referenceNumber,
};
let user = {
id: context.auth.token.user_id,
name: context.auth.token.name,
};
await logEvent('info', 'SCC Summary report exported (to ' + data.format + ')', details, user);
return await exportSccSummaryReport(data.exerciseId, data.format, (data.status || null));
});
56 changes: 56 additions & 0 deletions functions/callableFunctions/generateSccSummaryReport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import functions from 'firebase-functions';
import { firebase, db, auth } from '../shared/admin.js';
import initGenerateSccSummaryReport from '../actions/exercises/sccSummaryReport.js';
import { getDocument } from '../shared/helpers.js';
import initLogEvent from '../actions/logs/logEvent.js';
import initServiceSettings from '../shared/serviceSettings.js';
import { PERMISSIONS, hasPermissions } from '../shared/permissions.js';

const { generateSccSummaryReport } = initGenerateSccSummaryReport(firebase, db);
const { logEvent } = initLogEvent(firebase, db, auth);
const { checkFunctionEnabled } = initServiceSettings(db);

const runtimeOptions = {
timeoutSeconds: 120,
memory: '512MB',
};

export default functions.runWith(runtimeOptions).region('europe-west2').https.onCall(async (data, context) => {
await checkFunctionEnabled();

// authenticate the request
if (!context.auth) {
throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.');
}

hasPermissions(context.auth.token.rp, [
PERMISSIONS.exercises.permissions.canReadExercises.value,
PERMISSIONS.applications.permissions.canReadApplications.value,
PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value,
]);

// validate input parameters
if (!(typeof data.exerciseId === 'string') || data.exerciseId.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'Please specify an "exerciseId"');
}

// generate the report
const result = await generateSccSummaryReport(data.exerciseId);

// log an event
const exercise = await getDocument(db.collection('exercises').doc(data.exerciseId));
let details = {
exerciseId: exercise.id,
exerciseRef: exercise.referenceNumber,
};
let user = {
id: context.auth.token.user_id,
name: context.auth.token.name,
};
await logEvent('info', 'SCC Summary report generated', details, user);

// return the report to the caller
return {
result: result,
};
});
4 changes: 4 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ import getLatestReleases from './callableFunctions/getLatestReleases.js';
import verifyFileChecksum from './callableFunctions/verifyFileChecksum.js';
import sendSmsVerificationCode from './callableFunctions/sendSmsVerificationCode.js';
import verifySmsVerificationCode from './callableFunctions/verifySmsVerificationCode.js';
import generateSccSummaryReport from './callableFunctions/generateSccSummaryReport.js';
import exportSccSummaryReport from './callableFunctions/exportSccSummaryReport.js';
import getMultipleApplicationData from './callableFunctions/getMultipleApplicationData.js';

// Callable - QTs v2
Expand Down Expand Up @@ -208,6 +210,8 @@ export {
sendSmsVerificationCode,
verifySmsVerificationCode,
getMultipleApplicationData,
generateSccSummaryReport,
exportSccSummaryReport,

// Callable - QTs v2
listQualifyingTests,
Expand Down
Loading
Loading