@universis/candidates
Version:
Universis api server plugin for study program candidates, internship selection etc
739 lines (714 loc) • 37.1 kB
JavaScript
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 {
.param('schema', EdmType.EdmStream, false)
.param('file', EdmType.EdmStream, false)
.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
*/
.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;
}
.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;
}
.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);
}
.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;
}
.param('data','Object', false, true)
.param('file', EdmType.EdmStream, false)
.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);
}
.param('data','Object', false, true)
.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();
});
});
}
.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;