src/magister.js
'use strict'
// external
import _ from 'lodash'
import fetch from 'node-fetch'
import url from 'url'
import AuthCode from '@magisterjs/authcode'
// internal: used in this file
import AbsenceInfo from './absenceInfo'
import Activity from './activity'
import Appointment from './appointment'
import Assignment from './assignment'
import AuthError from './authError'
import Class from './class'
import Course from './course'
import FileFolder from './fileFolder'
import Http from './http'
import MessageFolder from './messageFolder'
import Person from './person'
import Privileges from './privileges'
import ProfileInfo from './profileInfo'
import School from './school'
import SchoolUtility from './schoolUtility'
import * as util from './util'
// internal: only being exported
import ActivityElement from './activityElement'
import AddressInfo from './addressInfo'
import AssignmentVersion from './assignmentVersion'
import File from './file'
import Grade from './grade'
import GradePeriod from './gradePeriod'
import GradeType from './gradeType'
import Message from './message'
import ProfileSettings from './profileSettings'
import VersionInfo from './versionInfo'
// TODO: add nice warnings when trying to do stuff while not logged in yet
/**
* Class to communicate with Magister.
*/
class Magister {
/**
* @private
* @param {Object} options
* @param {School} school
* @param {Http} http
*/
constructor(options, school, http) {
const info = url.parse(school.url)
if (!/^[^.#/\\]+\.magister\.net$/.test(info.host)) {
throw new Error('`school.url` is not a correct magister url')
}
school.url = `https://${info.host}`
/**
* @type {Object}
* @readonly
* @private
*/
this._options = options
/**
* @type {School}
* @readonly
*/
this.school = _.extend(new School({}), school)
/**
* @type {Http}
* @readonly
*/
this.http = http
/**
* @type {ProfileInfo}
* @readonly
*/
this.profileInfo = null
}
/**
* @type {string}
* @readonly
*/
get token() {
return this.http._token
}
/**
* @returns {Promise<Activity[]>}
*/
activities() {
return this._privileges.needs('activiteiten', 'read')
.then(() => this.http.get(`${this._personUrl}/activiteiten`))
.then(res => res.json())
.then(res => res.Items.map(a => new Activity(this, a)))
}
/**
* @param {Date} from Time is ignored.
* @param {Date} [to=from] Time is ignored
* @param {Object} [options={}]
* @param {boolean} [options.fillPersons=false]
* @param {boolean} [options.fetchAbsences=true]
* @param {boolean} [options.ignoreAbsenceErrors=true]
* @returns {Promise<Appointment[]>}
*/
appointments() {
// extract options
const {
fillPersons = false,
fetchAbsences = true,
ignoreAbsenceErrors = true,
} = _.find(arguments, _.isPlainObject) || {}
// extract dates
const dates = _(arguments).filter(_.isDate).sortBy().value()
const from = dates[0]
const to = dates[1] || dates[0]
const fromUrl = util.urlDateConvert(from)
const toUrl = util.urlDateConvert(to)
// fetch appointments
const appointmentsUrl = `${this._personUrl}/afspraken?van=${fromUrl}&tot=${toUrl}`
const appointmentsPromise = this._privileges.needs('afspraken', 'read')
.then(() => this.http.get(appointmentsUrl))
.then(res => res.json())
.then(res => res.Items.map(a => new Appointment(this, a)))
.then(appointments => {
if (!fillPersons) {
return appointments
}
const promises = appointments.map(a => {
return Promise.all(a.teachers.map(t => t.getFilled('teacher')))
.then(teachers => a.teachers = teachers)
.then(() => a)
})
return Promise.all(promises)
})
// fetch absences
let absencesPromise = Promise.resolve([])
if (fetchAbsences) {
const absencesUrl = `${this._personUrl}/absenties?van=${fromUrl}&tot=${toUrl}`
absencesPromise = this._privileges.needs('Absenties', 'read')
.then(() => this.http.get(absencesUrl))
.then(res => res.json())
.then(res => res.Items.map(a => new AbsenceInfo(this, a)))
if (ignoreAbsenceErrors) {
absencesPromise = absencesPromise.catch(() => [])
}
}
return Promise.all([ appointmentsPromise, absencesPromise ])
.then(([ appointments, absences ]) => {
for (const a of appointments) {
a.absenceInfo = absences.find(i => i.appointment.id === a.id)
}
return appointments
})
.then(appointments => _.sortBy(appointments, 'start'))
}
/**
* @param {Object} [options={}]
* @param {number} [options.count=50]
* @param {number} [options.skip=0]
* @param {boolean} [options.fillPersons=false]
* @returns {Promise<Assignment[]>}
*/
assignments({
count = 50,
skip = 0,
fillPersons = false,
} = {}) {
const url = `${this._personUrl}/opdrachten?top=${count}&skip=${skip}&status=alle`
return this._privileges.needs('eloopdracht', 'read')
.then(() => this.http.get(url))
.then(res => res.json())
.then(res => res.Items.map(i => i.Id))
.then(ids => {
const promises = ids.map(id => {
return this.http.get(`${this._personUrl}/opdrachten/${id}`)
.then(res => res.json())
})
return Promise.all(promises)
})
.then(items => {
const promises = items.map(item => {
const assignment = new Assignment(this, item)
if (!fillPersons) {
return assignment
}
return Promise.all(assignment.teachers.map(p => p.getFilled('teacher')))
.then(teachers => assignment.teachers = teachers)
.then(() => assignment)
})
return Promise.all(promises)
})
}
/**
* @returns {Promise<Magister[]>}
*/
children() {
if (this.profileInfo.isChild) {
return Promise.reject(new Error('User is not a parent'))
}
return this.http.get(`${this._personUrl}/kinderen`)
.then(res => res.json())
.then(res => res.Items)
.then(items => items.map(raw => {
const m = Object.create(this)
m.school = this.school
m.http = this.http
m._personUrl = `${this.school.url}/api/personen/${raw.Id}`
m._pupilUrl = `${this.school.url}/api/leerlingen/${raw.Id}`
m.profileInfo = new ProfileInfo(m, raw)
return m
}))
}
/**
* @returns {Promise<Course[]>}
*/
courses() {
return this._privileges.needs('aanmeldingen', 'read')
.then(() => this.http.get(`${this._personUrl}/aanmeldingen`))
.then(res => res.json())
.then(res => res.Items.map(c => new Course(this, c)))
.then(items => _.sortBy(items, 'start'))
}
/**
* @param {Object} options
* @param {string} options.description The description of the appointment.
* @param {Date} options.start The start of the appointment, time is
* ignored when `options.fullDay` is set to true.
* @param {Date} options.end The end of the appointment, this is ignored
* when `options.fullDay` is set to true.
* @param {boolean} [options.fullDay=false] When this is true,
* `options.end` is ignored and only `options.start` is used to set the
* begin and the end for the appointment.
* @param {string} [options.location] The location (classroom for example)
* for the appointment.
* @param {string} [options.content] Some arbitrary string you want to
* save.
* @param {number} [options.type=1] The type of the appointment: 1 for
* personal or 16 for planning
* @returns {Promise}
*/
createAppointment(options) {
const required = [ 'description', 'start', 'end' ]
for (const key of required) {
if (options[key] == null) {
const err = new Error(`Not all required fields for \`options\` are given, required are: [ ${required.join(', ')} ]`)
return Promise.reject(err)
}
}
if (options.fullDay) {
options.start = util.date(options.start)
options.end = new Date(options.start.getTime()) + 1000 * 60 * 60 * 24
}
let content = _.trim(options.content)
content = content.length > 0 ? _.escape(content) : null
const payload = {
Omschrijving: options.description,
Start: options.start.toJSON(),
Einde: options.end.toJSON(),
Lokatie: _.trim(options.location),
Inhoud: content,
Type: options.type || 1,
DuurtHeleDag: options.fullDay || false,
InfoType: content === null ? 0 : 6,
// Static non-configurable stuff.
WeergaveType: 1,
Status: 2,
HeeftBijlagen: false,
Bijlagen: null,
LesuurVan: null,
LesuurTotMet: null,
Aantekening: null,
Afgerond: false,
Vakken: null,
Docenten: null,
Links: null,
Id: 0,
Lokalen: null,
Groepen: null,
OpdrachtId: 0,
}
return this._privileges.needs('afspraken', 'create')
.then(() => this.http.post(`${this._personUrl}/afspraken`, payload))
}
/**
* @param {Integer} [parentId = 0]
* @returns {Promise<FileFolder[]>}
*/
fileFolders(parentId = 0) {
return this._privileges.needs('bronnen', 'read')
.then(() => {
let url = `${this._personUrl}/bronnen?soort=0`
if (parentId !== 0) {
url += `&parentId=${parentId}`
}
return this.http.get(url)
})
.then(res => res.json())
.then(res => {
return res.Items.filter(item => ![ 0, 1, 2, 4 ].includes(item.Type)).map(f => new FileFolder(this, f))
})
}
/**
* @returns {Promise<MessageFolder[]>}
*/
messageFolders() {
return this._privileges.needs('berichten', 'read')
.then(() => this.http.get(`${this._personUrl}/berichten/mappen`))
.then(res => res.json())
.then(res => res.Items.map(m => new MessageFolder(this, m)))
}
/**
* @param {string} query
* @param {string} [type]
* @returns {Promise<Person[]>}
*/
persons(query, type) {
query = query != null ? query.trim() : ''
if (query.length < 3) {
return Promise.resolve([])
} else if (type == null) {
return Promise.all([
this.persons(query, 'teacher'),
this.persons(query, 'pupil'),
]).then(([ teachers, pupils ]) => teachers.concat(pupils))
}
type = ({
'teacher': 'Personeel',
'pupil': 'Leerling',
'project': 'Project',
})[type] || 'Overig'
query = query.replace(/ +/g, '+')
const url = `${this._personUrl}/contactpersonen?contactPersoonType=${type}&q=${query}`
return this._privileges.needs('contactpersonen', 'read')
.then(() => this.http.get(url))
.then(res => res.json())
.then(res => res.Items.map(p => {
p = new Person(this, p)
p._filled = true
return p
}))
}
/**
* @returns {Promise<SchoolUtility[]>}
*/
schoolUtilities() {
const url = `${this._personUrl}/lesmateriaal`
return this._privileges.needs('digitaallesmateriaal', 'read')
.then(() => this.http.get(url))
.then(res => res.json())
.then(res => res.Items.map(u => new SchoolUtility(this, u)))
}
/**
* Logins to Magister.
* @param {boolean} [forceLogin=false] Force a login, even when a token
* is in the options object.
* @returns {Promise<string>} A promise that resolves when done logging in. With the current session ID as parameter.
*/
async login(forceLogin = false) {
// TODO: clean this code up a bit
const self = this
const options = this._options
const schoolUrl = this.school.url
const filteredName = schoolUrl.replace('https://', '')
let authorizeUrl = 'https://accounts.magister.net/connect/authorize'
authorizeUrl += `?client_id=M6-${filteredName}`
authorizeUrl += `&redirect_uri=https%3A%2F%2F${filteredName}%2Foidc%2Fredirect_callback.html`
authorizeUrl += '&response_type=id_token%20token'
authorizeUrl += '&scope=openid%20profile%20magister.ecs.legacy%20magister.mdv.broker.read%20magister.dnn.roles.read'
authorizeUrl += `&state=${await util.randomHex()}`
authorizeUrl += `&nonce=${await util.randomHex()}`
authorizeUrl += `&acr_values=tenant%3A${filteredName}`
const setToken = token => {
self.http._token = token
return token
}
const retrieveAccount = async () => {
const accountData =
await self.http.get(`${schoolUrl}/api/account`).then(res => res.json())
const id = accountData.Persoon.Id
self._personUrl = `${schoolUrl}/api/personen/${id}`
self._pupilUrl = `${schoolUrl}/api/leerlingen/${id}`
self._privileges = new Privileges(self, accountData.Groep[0].Privileges)
// REVIEW: do we want to make profileInfo a function?
self.profileInfo = new ProfileInfo(
self,
accountData.Persoon,
await self._privileges.can('kinderen', 'read'),
)
}
if (!forceLogin && options.token) {
setToken(options.token)
return await retrieveAccount()
}
// extract returnUrl
const location = await this.http.get(authorizeUrl, {
redirect: 'manual',
}).then(res => res.headers.get('Location'))
const returnUrl = decodeURIComponent(
location.split('returnUrl=')[1]
)
// extract session and XSRF related stuff
const xsrfResponse = await this.http.get(location, {
redirect: 'manual',
})
const authUrl = 'https://accounts.magister.net/challenge'
let sessionId
let xsrf
let authCookies
try {
sessionId =
xsrfResponse.headers.get('Location')
.split('?')[1]
.split('&')[0]
.split('=')[1]
xsrf =
xsrfResponse.headers.get('set-cookie')
.split('XSRF-TOKEN=')[1]
.split(';')[0]
authCookies = xsrfResponse.headers.get('set-cookie').toString()
} catch (err) {
if (err.message === 'Cannot read property \'split\' of null') {
throw new AuthError('Invalid school url')
} else {
throw err
}
}
let authRes
// test username
authRes = await this.http.post(`${authUrl}/username`, {
authCode: options.authCode,
sessionId: sessionId,
returnUrl: returnUrl,
username: options.username,
}, {
headers: {
Cookie: authCookies,
'X-XSRF-TOKEN': xsrf,
},
})
if (authRes.error || authRes.status !== 200) {
throw new AuthError(authRes.error || 'Invalid username')
}
// test password
authRes = await this.http.post(`${authUrl}/password`, {
authCode: options.authCode,
sessionId: sessionId,
returnUrl: returnUrl,
password: options.password,
}, {
headers: {
Cookie: authCookies,
'X-XSRF-TOKEN': xsrf,
},
})
if (authRes.error || authRes.status !== 200) {
throw new AuthError(authRes.error || 'Invalid password')
}
// extract bearer token
const res = await this.http.get(`https://accounts.magister.net${returnUrl}`, {
redirect: 'manual',
headers: {
Cookie: authRes.headers.get('set-cookie'),
'X-XSRF-TOKEN': xsrf,
},
})
const tokenRegex = /&access_token=([^&]*)/
const loc = res.headers.get('Location')
setToken(tokenRegex.exec(loc)[1])
return await retrieveAccount()
}
}
/**
* Create a new Magister object using `options`.
* @param {Object} options
* @param {School} options.school The school to login to.
* @param {string} [options.username] The username of the user to login to.
* @param {string} [options.password] The password of the user to login to.
* @param {string} [options.token] The Bearer token to use. (instead of the username and password)
* @param {boolean} [options.keepLoggedIn=true] Whether or not to keep the user logged in.
* @param {boolean} [options.login=true] Whether or not to call {@link login} before returning the object.
* @param {string} [options.authCode=AuthCode] The AuthCode that Magister uses in their
* requests. Per default we use the value from the @magisterjs/authcode
* package. Which you should keep up-to-date.
* @returns {Promise<Magister>}
*/
export default function magister(options) {
_.defaults(options, {
keepLoggedIn: true,
login: true,
authCode: AuthCode,
})
const rej = s => Promise.reject(new Error(s))
if (!(
options.school &&
(options.token || (options.username && options.password))
)) {
return rej('school and username&password or token are required.')
}
if (!_.isObject(options.school)) {
return rej('school is not an object')
} else if (!_.isString(options.school.url)) {
return rej('`school.url` is not a string')
}
return Promise.resolve().then(() => {
const m = new Magister(options, options.school, new Http())
return options.login ?
m.login().then(() => m) :
m
})
}
/**
* Get the schools matching `query`.
* @param {string} query
* @returns {Promise<School[]>}
*/
export function getSchools(query) {
query = query.replace(/\d/g, '')
query = query.trim()
query = query.replace(/ +/g, '+')
if (query.length < 3) {
return Promise.resolve([])
}
return fetch(`https://mijn.magister.net/api/schools?filter=${query}`)
.then(res => res.json())
.then(schools => schools.map(school => new School(school)))
}
/**
* The version of the library.
* @type {String}
* @readonly
*/
export const VERSION = __VERSION__
export {
AbsenceInfo,
Activity,
ActivityElement,
AddressInfo,
Appointment,
Assignment,
AssignmentVersion,
AuthError,
Class,
Course,
File,
FileFolder,
Grade,
GradePeriod,
GradeType,
Magister,
Message,
MessageFolder,
Person,
Privileges,
ProfileInfo,
ProfileSettings,
School,
SchoolUtility,
VersionInfo,
}