UNPKG

@universis/candidates

Version:

Universis api server plugin for study program candidates, internship selection etc

739 lines (714 loc) 37.1 kB
import { DataObject } from "@themost/data"; import { EdmMapping, EdmType , PermissionMask, DataPermissionEventListener} from "@themost/data"; import {xlsPostParserWithConfig} from '../middlewares'; const path = require('path'); import _ from 'lodash'; // eslint-disable-next-line no-unused-vars import { DataError, DataNotFoundError } from "@themost/common"; import { TraceUtils } from "@themost/common"; import {promisify} from 'util'; import { HttpForbiddenError } from "@themost/common"; import { RandomCandidateUserActivationCode } from '../RandomCandidateUserActivationCode'; import { SendSmsAfterCreateCandidateUser } from '../listeners/send-sms-after-create-candidate-user-action'; import util from 'util'; import fs from 'fs'; import { getMailer } from '@themost/mailer'; class CandidateStudent extends DataObject { @EdmMapping.param('schema', EdmType.EdmStream, false) @EdmMapping.param('file', EdmType.EdmStream, false) @EdmMapping.action('import', 'CandidateStudentUploadAction') static async importFile(context, file, schema, forceUpdate) { const req = { files: { file: [Object.assign(file, { mimetype: file.contentType, destination: path.dirname(file.path), filename: path.basename(file.path), originalfilename: file.contentFileName, originalname: file.contentFileName })], schema: [Object.assign(schema, { mimetype: schema.contentType, destination: path.dirname(schema.path), filename: path.basename(schema.path), originalfilename: schema.contentFileName, originalname: schema.contentFileName })] } } const res = {}; await new Promise((resolve, reject) => { xlsPostParserWithConfig({ name: "file", schema: "schema" })(req, res, (err) => { if (err) { return reject(err); } // get body return resolve(); }); }); if (req.body.length > 0) { const testDepartmentAltCode = req.body[0].department.hasOwnProperty('alternativeCode'); const groupByExp = testDepartmentAltCode ? 'department.alternativeCode' : 'department'; const searchPropertyDP = testDepartmentAltCode ? 'alternativeCode' : 'id'; const searchPropertySP = testDepartmentAltCode ? 'department/alternativeCode' : 'department'; const inscriptionModes = new Map(); const studyPrograms = new Map(); const lastActiveStudyPrograms = new Map(); // group candidates by department const candidates = _.groupBy(req.body, groupByExp); // iterate through each one of the unique departments for (const [department, candidateList] of Object.entries(candidates)) { if (department === 'undefined' /* groupBy shows undefined like a string */) { throw new DataError('Invalid candidates file data. Department column cannot be empty at this context.'); } // validate department const departmentObject = await context.model('Department') .where(searchPropertyDP).equal(department.toString()) .getItem(); if (departmentObject == null) { throw new DataNotFoundError(`The department with id or alternativeCode ${department} cannot be found or is inaccessible.`); } // validate data for each candidate for (const candidate of candidateList) { // get inscription mode id const inscriptionModeId = candidate.inscriptionMode; if (inscriptionModeId) { // check map to prevent uneccessary calls if (!inscriptionModes.has(inscriptionModeId)) { // validate inscription mode const inscriptionMode = await context.model('InscriptionMode') .where('id').equal(inscriptionModeId) .getItem(); if (inscriptionMode == null) { throw new DataNotFoundError(`The inscription mode with id ${inscriptionModeId} cannot be found or is inaccessible.`); } // cache inscription mode inscriptionModes.set(inscriptionModeId, inscriptionMode); } } else { throw new DataError('Invalid cadidate student data. Inscription mode column cannot be empty at this context.'); } // get studyProgramId const studyProgramId = candidate.studyProgram; if (studyProgramId) { // check map to prevent uneccessary calls if (!studyPrograms.has(studyProgramId)) { // validate study program const studyProgram = await context.model('StudyProgram') .where('id').equal(studyProgramId) .and('isActive').equal(1) .getItem(); if (studyProgram == null) { throw new DataNotFoundError(`The study program with id ${studyProgramId} cannot be found, is inaccessible or is inactive.`); } // cache study program studyPrograms.set(studyProgramId, studyProgram); } } else /*if study program is null, assign last active for that department*/ { let lastActiveSP; if (!lastActiveStudyPrograms.has(department)) { // get the department's last active study program lastActiveSP = await context.model('StudyProgram') .where(searchPropertySP).equal(department.toString()) .and('isActive').equal(1) .expand('specialties') .orderByDescending('id') .getItem(); if (lastActiveSP == null) { throw new DataNotFoundError('The candidate study program cannot be found or is inaccessible.'); } lastActiveStudyPrograms.set(department, lastActiveSP); } else { lastActiveSP = lastActiveStudyPrograms.get(department); } // get the core specialty of the study program const studyProgramSpecialty = lastActiveSP.specialties.find(sPSpecialty => sPSpecialty.specialty === -1); if (studyProgramSpecialty == null) { throw new DataNotFoundError('The candidate study program specialty cannot be found or is inaccessible.'); } // define extra required fields const extraRequiredFields = { studyProgram: lastActiveSP.id, studyProgramSpecialty: studyProgramSpecialty.id, specialtyId: -1 }; // assign fields to candidate student Object.assign(candidate, extraRequiredFields); } // ensure that department alternative code is a string if (candidate.department.alternativeCode) { candidate.department.alternativeCode = candidate.department.alternativeCode.toString(); } } } const uploadActionObject = { totalCandidates: req.body.length, updateForced: forceUpdate == null ? false : forceUpdate }; // get the inscriptionYear of the first candidate const inscriptionYear = req.body[0].inscriptionYear != null ? (req.body[0].inscriptionYear.id || req.body[0].inscriptionYear) : null; if (inscriptionYear) { // only if all the candidates have the same inscription year const yearIsShared = req.body.every(candidate => { const candidateYear = candidate.inscriptionYear && (candidate.inscriptionYear.id || candidate.inscriptionYear); return candidateYear === inscriptionYear; }); if (yearIsShared) { // append inscriptionYear to upload action uploadActionObject.inscriptionYear = inscriptionYear; } } const emptyData = req.body.find((candidate) => { return !(candidate.inscriptionNumber && candidate.inscriptionYear && candidate.inscriptionMode); }); if (emptyData) { throw new DataError('E_ATTRIBUTES', 'The inscription year, inscription mode and inscription number cannot be empty for any candidate.'); } const action = await context.model('CandidateStudentUploadAction').silent().save(uploadActionObject); await CandidateStudent.saveCandidates(context, req.body, action, forceUpdate); return action; } else { throw new DataError('No candidate students found in provided file.'); } } static saveCandidates(appContext, candidates, action, forceUpdate) { const app = appContext.getApplication(); const context = app.createContext(); context.user = appContext.user; const errors = []; const success = []; const skipped = []; (async function () { for (const candidate of candidates) { // ensure inscriptionNumber as string candidate.inscriptionNumber = candidate.inscriptionNumber.toString(); try { // try to find if the candidate is already registered // note: use the unique constraint attributes of the model const candidateAlreadySaved = await context .model('CandidateStudent') .where('inscriptionNumber').equal(candidate.inscriptionNumber.toString()) .and('inscriptionMode').equal(candidate.inscriptionMode) .and('inscriptionYear').equal(candidate.inscriptionYear) .select('id', 'user') .silent() .getItem(); // if force update is falsy and candidate exists, continue if (!forceUpdate && candidateAlreadySaved) { skipped.push(candidateAlreadySaved); continue; } const candidateUpdated = !!candidateAlreadySaved; if (candidateUpdated) { candidate.user = candidateAlreadySaved.user; } await new Promise((resolve, reject) => { return context.model('CandidateStudent').save(candidate).then(() => { success.push(candidate); // save also result return context.model('CandidateStudentUploadActionResult') .silent().save({ action: action.id, candidate: candidate, forcelyUpdated: candidateUpdated, result: true, inscriptionNumber: candidate.inscriptionNumber.toString() }).then(() => { return resolve(); }) .catch(err => { return reject(err); }); }).catch(err => { // extend error err.candidate = `${candidate.person.familyName} ${candidate.person.givenName} (${candidate.inscriptionNumber})`; // push error errors.push(JSON.stringify(err)); return context.model('CandidateStudentUploadActionResult') .silent().save({ action: action.id, forcelyUpdated: false, result: false, inscriptionNumber: candidate.inscriptionNumber.toString(), additionalMessage: JSON.stringify(err) }).then(() => { return resolve(); }).catch(err=> { return reject(err); }); }); }); } catch (err) { TraceUtils.error(err); } } })().then(() => { // finalize context context.finalize(() => { // add error messages to action message action.message = errors.length > 0 ? errors.join(' --- ') : null; // set action as completed action.actionStatus = {alternateName: 'CompletedActionStatus'}; // set totalFailed, totalInserted and totalSkipped action.totalFailed = errors.length; action.totalInserted = success.length; action.totalSkipped = skipped.length; action.endTime = new Date(); // and update return context.model('CandidateStudentUploadAction').silent().save(action).then(() => { // }).catch(err => { TraceUtils.error(`An error occured while trying to complete CandidateStudentUploadAction ${action.id}`); TraceUtils.error(err); }); }); }).catch(err => { context.finalize(() => { action.actionStatus = {alternateName: 'FailedActionStatus'}; action.endTime = new Date(); action.message = err.message; action.totalFailed = action.totalCandidates; action.totalInserted = 0; action.totalSkipped = skipped.length; // is 0 return context.model('CandidateStudentUploadAction').silent().save(action).then(() => { // }).catch(err => { TraceUtils.error(`An error occured while trying to set failed status to CandidateStudentUploadAction ${action.id}`); TraceUtils.error(err); }); }); }); } /** * Create candidate user */ @EdmMapping.action('createUser', 'CandidateStudent') async createUser() { const model = this.context.model('CandidateStudent'); // infer object state const candidate = await model.where('id').equal(this.getId()).expand('person','user').getItem(); if (candidate == null) { return; } // check if user already set if (candidate.user) { return candidate; } const username = candidate.inscriptionNumber.toString() || candidate.person.email; // check if user already exists const existingUser = await this.context.model('User').where('name').equal(username).select('id').silent().getItem(); if (existingUser) { const student = await this.context.model('Student').where('user').equal(existingUser.id).select('id').silent().count(); if (student) { throw new Error(`User ${username} already exists`) } candidate.user = existingUser; await this.context.model('CandidateStudent').silent().save(candidate); return candidate; } // create user const user = { "name": username, "email": candidate.person.email, "description":`${candidate.person.familyName} ${candidate.person.givenName}` }; const service = this.context.getApplication().getService(RandomCandidateUserActivationCode); if (service != null) { const activationCode = await service.generate(this.context, this); Object.assign(user, { activationCode }); } const result = await new Promise((resolve, reject) => { this.context.db.executeInTransaction((cb) => { return this.context.model('CandidateUser').silent().save(user).then((updated) => { candidate.user = updated; return model.save(candidate).then(() => { return cb(); }); }).catch((err) => { return cb(err); }); }, (err) => { if (err) { return reject(err); } return resolve(candidate); }); }) return result; } @EdmMapping.action('enroll', 'StudyProgramRegisterAction') async createStudyProgramRegisterAction() { const model = this.context.model('CandidateStudent'); // get candidate const candidate = await model.where('id').equal(this.getId()).getItem(); if (candidate == null) { throw new DataNotFoundError(`The candidate student cannot be found or is inaccessible.`); } // check if candidate has been linked to a student // (either directly or by an enrollment application approval) if (candidate.student) { throw new DataError('E_STUDENT', 'The candidate student has already been linked to a student.'); } // check if an action already exists for the candidate // for this, or later years const existingAction = await this.context.model('StudyProgramRegisterAction') .where('candidate').equal(candidate.id) .and('studyProgramEnrollmentEvent/inscriptionYear') .greaterOrEqual(candidate.inscriptionYear.id || candidate.inscriptionYear) .select('id') .silent() .count(); if (existingAction) { return existingAction; } // create studyProgramRegisterAction const studyProgramRegisterAction = { studyProgram: candidate.studyProgram, specialization: candidate.studyProgramSpecialty, inscriptionYear: candidate.inscriptionYear, inscriptionPeriod: candidate.inscriptionPeriod, inscriptionMode: candidate.inscriptionMode, candidate: candidate.id, owner: candidate.user // if owner is null, the studyProgramRegisterAction validation listener will throw an error }; // save studyProgramRegisterAction const action = await this.context.model('StudyProgramRegisterAction').save(studyProgramRegisterAction); // check if candidate has potential register actions in previous years const previousRegisterActions = await this.context.model('StudyProgramRegisterAction') .where('candidate').equal(candidate.id) .and('studyProgramEnrollmentEvent/inscriptionYear') .lowerThan(candidate.inscriptionYear.id || candidate.inscriptionYear) .and('actionStatus/alternateName').equal('PotentialActionStatus') .and('studyProgram').equal(candidate.studyProgram.id || candidate.studyProgram) .select('id') .silent() .getAllItems(); // if any exist if (previousRegisterActions && previousRegisterActions.length) { // auto-cancel them await this.context.model('StudyProgramRegisterAction').save(previousRegisterActions.map((item) => { return { id: item.id, actionStatus: { alternateName: 'CancelledActionStatus' } } })); } return action; } @EdmMapping.action('sendActivationMessage', 'CandidateStudent') async sendActivationMessage() { // get validator listener const validator = new DataPermissionEventListener(); // noinspection JSUnresolvedFunction const validateAsync = promisify(validator.validate) .bind(validator); // validate CandidateStudent/SendActivationMessage execute permission const event = { model: this.getModel(), privilege: 'CandidateStudent/SendActivationMessage', mask: PermissionMask.Execute, target: this, throwError: false } await validateAsync(event); if (event.result === false) { throw new HttpForbiddenError(); } /** * @type {ApplicationBase} */ const app = this.context.getApplication(); let service = app.getService(SendSmsAfterCreateCandidateUser); if (service != null) { return await service.send(this.context, this); } // otherwise check if application has sms service enabled const parentService = app.getService(function SmsService() {}); if (parentService == null) { throw new Error('The operation cannot be completed due to invalid application configuration. A required service is missing'); } return await SendSmsAfterCreateCandidateUser.prototype.send(this.context, this); } @EdmMapping.action('createStudent', 'Student') async createStudent() { const model = this.context.model('CandidateStudent'); // get candidate /** * @type {CandidateStudent} */ const candidate = await model.where('id').equal(this.getId()) .expand('student', { name: 'person', options: { $expand: 'gender' } }) .getTypedItem(); if (candidate == null) { throw new DataNotFoundError(`The candidate student cannot be found or is inaccessible.`); } // check if candidate student exists if (candidate.student) { throw new DataError('E_STATE',`Candidate student ${candidate.id} is already related to student ${candidate.student.id}.`); } // check if candidate has pending action for same academic year const exists = await this.context.model('StudyProgramRegisterAction').where('candidate').equal(candidate.id) .and('inscriptionYear').equal(candidate.inscriptionYear) .and('actionStatus/alternateName').in(['PotentialActionStatus', 'ActiveActionStatus']) .silent() .count(); if (exists) { throw new DataError('E_STATE',`The candidate student has pending StudyProgramRegisterAction for inscription year ${candidate.inscriptionYear && candidate.inscriptionYear.name}.`); } return await candidate.convertCandidateToStudent(this.context); } async convertCandidateToStudent(context) { const candidate = this; let student = candidate.student; const result = await new Promise((resolve, reject) => { this.context.db.executeInTransaction((cb) => { (async () => { if (student == null) { // get study program data const studyProgramSpecialty = await context.model('StudyProgramSpecialty') .where('id').equal(candidate.studyProgramSpecialty.id || candidate.studyProgramSpecialty).getItem(); // create person const person = Object.assign({}, candidate.person); // set gender person.gender = candidate.person.gender.identifier; // remove person id in order to insert a person based on its data delete person.id; // create student // important note: each student has a unique for each study program const newStudent = Object.assign({}, candidate); if (candidate.user) { // try to find if a student exists with the candidate user const studentWithCandidateUser = await context.model('Student') .where('user').equal(candidate.user.id || candidate.user) .select('id') .silent() .count(); if (studentWithCandidateUser) { const institute = await context.model('Department') .where('id') .equal(candidate.department) .silent() .select('organization') .value(); const allowStudentUserSharing = await context .model('InstituteConfiguration') .where('institute') .equal(institute) .select('allowStudentUserSharing') .silent() .value(); if (allowStudentUserSharing === false) { // and if it exists, ensure that the new student is not linked with it newStudent.user = null; } } const user = candidate.user; // when allowStudentUserSharing === true if (user != null && candidate.user === newStudent.user) { const convertedUser = context.model('User').convert(user); // add user to students group const userGroups = convertedUser.property('groups'); // add to students group await userGroups.silent().insert({ name: 'Students' }); // find user's groups const groupOfuser = await context.model('User') .where('id').equal(user) .expand('groups') .silent() .select('groups') .getItem(); const exists = groupOfuser.groups.filter(el => el.name === 'Candidates').length; if (exists) { // and remove it from candidates await userGroups.silent().remove({ name: 'Candidates' }); } if (candidate.department) { // handle user departments const userDepartments = convertedUser.property('departments'); // insert current candidate department await userDepartments.silent().insert({ id: candidate.department.id || candidate.department }); } // save also applicationUser // get student group // TODO: Change this when user model becomes updatable const studentGroup = await context.model('Group').where('name').equal('Students').silent().getItem(); const applicationUser = await context.model('ApplicationUser').where('userId').equal(user).silent().getItem(); if (applicationUser) { applicationUser.roleId = studentGroup.id; applicationUser.applicationId= 'GRUNI'; // save applicationUser await context.model('ApplicationUser').silent().save(applicationUser); } } delete newStudent.id; /* delete newStudent.studentIdentifier; delete newStudent.studentInstituteIdentifier; */ newStudent.person = person; newStudent.studyProgramSpecialty = studyProgramSpecialty; newStudent.semester = 1; newStudent.inscriptionDate = new Date(); newStudent.inscriptionSemester = 1; newStudent.studentStatus = { alternateName: 'active' }; newStudent.specialtyId = studyProgramSpecialty.specialty; const Students = context.model('Student'); student = await Students.save(newStudent); // create user for newStudent when allowStudentUserSharing = false if (student.user === null) { const studentModel = context.model('Student').convert(student); const action = await studentModel.createUser(context); student.user = action && action.student && action.student.user; } // update candidateStudent with new student id candidate.student = student.id; // save candidateStudent await context.model('CandidateStudent').save(candidate); } } })().then(() => { return cb(); }).catch((err) => { return cb(err); }); }, (err) => { if (err) { return reject(err); } return resolve(student); }); }); return result || student; } @EdmMapping.param('data','Object', false, true) @EdmMapping.param('file', EdmType.EdmStream, false) @EdmMapping.action('importFromSource', 'CandidateStudentUploadAction') static async importFromSource(context, file, data) { // parse form data data = JSON.parse(data && data.data); // validate candidate source on passed data if (!(data && data.candidateSource)) { throw new DataError('E_CANDIDATE_SOURCE', 'No candidate source provided.'); } // validate candidate source access/existance // NOTE: Consider using silent const candidateSource = await context .model('CandidateSource') .where('id').equal(data.candidateSource.id || data.candidateSource) .expand('attachments') .getItem(); if (candidateSource == null) { throw new DataNotFoundError('The specified candidate source is not found or is inaccessible.'); } // validate candidate source attachments (e.g. schema configuration) if (!(Array.isArray(candidateSource.attachments) && candidateSource.attachments.length)) { throw new DataNotFoundError('The specified candidate source is not linked to a schema (configuration) file or it is inaccessible.'); } // fetch the schema configuration attachment // based on the schemaURL of the source if (!candidateSource.schemaURL) { throw new DataError('E_SCHEMA_URL', 'The schemaURL is not set for the specified candidate source.'); } const schemaAttachment = candidateSource.attachments.find(attachment => attachment.url === candidateSource.schemaURL); if (schemaAttachment == null) { throw new DataError('E_SCHEMA_ATTACHMENT', 'The schema (configuration) attachment based on the schemaURL of the source cannot be found or is inaccessible.'); } /** * get private content service * @type {PrivateContentService} */ let privateContentService = context.getApplication().getService(function PrivateContentService() {}); if (privateContentService == null) { throw new DataError('E_PCService', 'Private content service is not registered.'); } // get physical path const resolvePhysicalPath = util.promisify(privateContentService.resolvePhysicalPath).bind(privateContentService); const physicalPath = await resolvePhysicalPath(context, schemaAttachment); // and create a read stream const configuration = fs.createReadStream(physicalPath); configuration.contentType = schemaAttachment.contentType; // and import the candidate students return await CandidateStudent.importFile(context, file, configuration, data.forceUpdate); } @EdmMapping.param('data','Object', false, true) @EdmMapping.action('sendMessage') async sendEmail(data) { if (!(data && data.candidateStudent && data.body && data.subject)) { throw new DataError('E_ARG', 'Provide a valid candidateStudent, body and subject.'); } const context = this.context; // validate student const candidate = await context .model('CandidateStudent') .where('id').equal(data.candidateStudent.id || data.candidateStudent) .expand('person') .getItem(); if (candidate == null) { throw new DataNotFoundError('The specified candidate student cannot be found or is inaccessible.'); } // validate email const email = candidate.person.email; // https://github.com/formio/formio.js/blob/master/src/validator/rules/Email.js#L13 const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; if (!re.test(email)) { throw new DataError('E_INVALID_EMAIL', `The candidate student's email is invalid or empty.`); } // get mail template const mailTemplate = (await context.model('MailConfiguration') .where('target').equal('DirectEmailToCandidate') .select('template') .silent() .value()) || 'direct-email-to-candidate'; const mailer = getMailer(context); const body = data.body; // and try to send return await new Promise((resolve, reject) => { mailer .template(mailTemplate) .subject(data.subject) .to(email) .send({ model: { candidate, body } }, (err) => { if (err) { TraceUtils.error(err); return reject(err); } return resolve(); }); }); } @EdmMapping.func("Me", "CandidateStudent") static getMe(context) { return context.model('CandidateStudent') .where('user/name').notEqual(null) .and('user/name').equal(context.user && context.user.name) .orderByDescending('id') .prepare(); } } module.exports = CandidateStudent;