Skip to content

Commit

Permalink
Recurring tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
kennsippell committed Oct 3, 2023
1 parent a34cf94 commit 0521647
Show file tree
Hide file tree
Showing 11 changed files with 599 additions and 86 deletions.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
},
"homepage": "https://github.com/medic/cht-conf#readme",
"dependencies": {
"joi": "^17.9.2",
"@medic/translation-checker": "^1.0.1",
"@parcel/watcher": "^2.0.5",
"@xmldom/xmldom": "^0.8.2",
Expand All @@ -46,9 +45,11 @@
"eslint-loader": "^3.0.4",
"googleapis": "^84.0.0",
"iso-639-1": "^2.1.9",
"joi": "^17.9.2",
"json-diff": "^0.5.4",
"json-stringify-safe": "^5.0.1",
"json2csv": "^4.5.4",
"luxon": "^3.4.3",
"mime-types": "^2.1.32",
"minimist": "^1.2.5",
"mkdirp": "^1.0.4",
Expand Down
25 changes: 18 additions & 7 deletions src/lib/compilation/validate-declarative-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ const TargetSchema = joi.array().items(
.unique('id')
.required();

const EventSchema = idPresence => joi.object({
const NonRecurringEventSchema = idPresence => joi.object({
id: joi.string().presence(idPresence),
days: joi.alternatives().conditional('dueDate', { is: joi.exist(), then: joi.forbidden(), otherwise: joi.number().required() })
.error(taskError('"event.days" is a required integer field only when "event.dueDate" is absent')),
.error(taskError('"event.days" is a required integer field but only when "event.dueDate" is absent')),
dueDate: joi.alternatives().conditional('days', { is: joi.exist(), then: joi.forbidden(), otherwise: joi.function().required() })
.error(taskError('"event.dueDate" is required to be "function(event, contact, report)" only when "event.days" is absent')),
start: joi.number().min(0).required(),
Expand Down Expand Up @@ -117,11 +117,22 @@ const TaskSchema = joi.array().items(
'should define property "resolvedIf" as: function(contact, report) { ... }.'
)
),
events: joi.alternatives().conditional('events', {
is: joi.array().length(1),
then: joi.array().items(EventSchema('optional')).min(1).required(),
otherwise: joi.array().items(EventSchema('required')).unique('id').required(),
}),
events: joi.alternatives().try(
joi.object({
recurringStartDate: joi.alternatives().conditional('period', { is: joi.number().greater(1), then: joi.date().required(), otherwise: joi.date().optional() })
.error(taskError('"event.recurringStartDate" is a required date field when "event.period" is longer than daily')),
recurringEndDate: joi.date().optional(),
start: joi.number().min(0).optional(),
end: joi.number().min(0).optional(),
period: joi.number().min(1).optional(),
periodUnit: joi.string().valid('day', 'days', 'month', 'months').optional(),
}),
joi.alternatives().conditional('events', {
is: joi.array().length(1),
then: joi.array().items(NonRecurringEventSchema('optional')).min(1).required(),
otherwise: joi.array().items(NonRecurringEventSchema('required')).unique('id').required(),
}),
).required(),
priority: joi.alternatives().try(
joi.object({
level: joi.string().valid('high', 'medium').optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/nools/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"root": true,
"parserOptions": {
"ecmaVersion": 5
"ecmaVersion": 6
},
"extends": "eslint:recommended",
"rules": {
Expand Down
7 changes: 6 additions & 1 deletion src/nools/definition-preparation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const Luxon = require('luxon');

/*
Declarative tasks and targets (the elements exported by partner task.js and target.js files), are complex objects containing functions.
Definition-preparation.js binds a value for `this` in all the functions within a definition.
Expand All @@ -16,7 +18,10 @@ function bindAllFunctionsToContext(obj, context) {
var key = keys[i];
switch(typeof obj[key]) {
case 'object':
bindAllFunctionsToContext(obj[key], context);
const isLuxon = Luxon.Duration.isDuration(obj[key]) || Luxon.DateTime.isDateTime(obj[key]);
if (!isLuxon) {
bindAllFunctionsToContext(obj[key], context);
}
break;
case 'function':
obj[key] = obj[key].bind(context);
Expand Down
162 changes: 108 additions & 54 deletions src/nools/task-emitter.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
var prepareDefinition = require('./definition-preparation');
var taskDefaults = require('./task-defaults');
const prepareDefinition = require('./definition-preparation');
const taskDefaults = require('./task-defaults');
const emitRecurringEvents = require('./task-recurring');

function taskEmitter(taskDefinitions, c, Utils, Task, emit) {
if (!taskDefinitions) return;

var taskDefinition, r;
for (var idx1 = 0; idx1 < taskDefinitions.length; ++idx1) {
taskDefinition = Object.assign({}, taskDefinitions[idx1], taskDefaults);
for (let idx1 = 0; idx1 < taskDefinitions.length; ++idx1) {
const taskDefinition = Object.assign({}, taskDefinitions[idx1], taskDefaults);
if (typeof taskDefinition.resolvedIf !== 'function') {
taskDefinition.resolvedIf = function (contact, report, event, dueDate) {
return taskDefinition.defaultResolvedIf(contact, report, event, dueDate, Utils);
};
}
prepareDefinition(taskDefinition);

const emitterContext = {
taskDefinition,
c,
Utils,
Task,
emit,
};

switch (taskDefinition.appliesTo) {
case 'reports':
case 'scheduled_tasks':
for (var idx2=0; idx2<c.reports.length; ++idx2) {
r = c.reports[idx2];
emitTasks(taskDefinition, Utils, Task, emit, c, r);
for (let idx2=0; idx2<c.reports.length; ++idx2) {
emitterContext.r = c.reports[idx2];
emitTaskDefinition(emitterContext);
}
break;
case 'contacts':
if (c.contact) {
emitTasks(taskDefinition, Utils, Task, emit, c);
emitTaskDefinition(emitterContext);
}
break;
default:
Expand All @@ -33,11 +41,11 @@ function taskEmitter(taskDefinitions, c, Utils, Task, emit) {
}
}

function emitTasks(taskDefinition, Utils, Task, emit, c, r) {
var i;
function emitTaskDefinition(emitterContext) {
const { taskDefinition, c, r } = emitterContext;

if (taskDefinition.appliesToType) {
var type;
let type;
if (taskDefinition.appliesTo === 'contacts') {
if (!c.contact) {
// no assigned contact - does not apply
Expand Down Expand Up @@ -67,18 +75,18 @@ function emitTasks(taskDefinition, Utils, Task, emit, c, r) {
return;
}

for (i = 0; i < r.scheduled_tasks.length; i++) {
for (let i = 0; i < r.scheduled_tasks.length; i++) {
if (taskDefinition.appliesIf(c, r, i)) {
emitForEvents(i);
emitForEvents(emitterContext, i);
}
}
}
} else {
emitForEvents();
emitForEvents(emitterContext);
}

function obtainContactLabelFromSchedule(taskDefinition, c, r) {
var contactLabel;
let contactLabel;
if (typeof taskDefinition.contactLabel === 'function') {
contactLabel = taskDefinition.contactLabel(c, r);
} else {
Expand All @@ -88,10 +96,30 @@ function emitTasks(taskDefinition, Utils, Task, emit, c, r) {
return contactLabel ? { name: contactLabel } : c.contact;
}

function emitForEvents(scheduledTaskIdx) {
var i, dueDate = null, event, priority, task;
for (i = 0; i < taskDefinition.events.length; i++) {
event = taskDefinition.events[i];
function emitForEvents(emitterContext, scheduledTaskIdx) {
let partialEmissions;

if (Array.isArray(taskDefinition.events)) {
partialEmissions = emitEventsArray(emitterContext, scheduledTaskIdx);
} else {
if (scheduledTaskIdx) {
throw 'not supported';
}

partialEmissions = emitRecurringEvents(emitterContext);
}

partialEmissions.forEach(partialEmission => {
emitTaskEvent(emitterContext, partialEmission);
});
}

function emitEventsArray(emitterContext, scheduledTaskIdx) {
const { Utils } = emitterContext;
const result = [];
let dueDate = null;
for (let i = 0; i < taskDefinition.events.length; i++) {
const event = taskDefinition.events[i];

if (event.dueDate) {
dueDate = event.dueDate(event, c, r, scheduledTaskIdx);
Expand All @@ -105,58 +133,84 @@ function emitTasks(taskDefinition, Utils, Task, emit, c, r) {
if (event.dueDate) {
dueDate = event.dueDate(event, c);
} else {
var defaultDueDate = c.contact && c.contact.reported_date ? new Date(c.contact.reported_date) : new Date();
const defaultDueDate = c.contact && c.contact.reported_date ? new Date(c.contact.reported_date) : new Date();
dueDate = new Date(Utils.addDate(defaultDueDate, event.days));
}
}

if (!Utils.isTimely(dueDate, event)) {
continue;
}

task = {
// One task instance for each event per form that triggers a task, not per contact
// Otherwise they collide when contact has multiple reports of the same form
_id: (r ? r._id : c.contact && c.contact._id) + '~' + (event.id || i) + '~' + taskDefinition.name,
deleted: !!((c.contact && c.contact.deleted) || r ? r.deleted : false),
doc: c,
contact: obtainContactLabelFromSchedule(taskDefinition, c, r),
icon: taskDefinition.icon,
const uuidPrefix = r ? r._id : c.contact && c.contact._id;
result.push({
_id: `${uuidPrefix}~${event.id || i}~${taskDefinition.name}`,
date: dueDate,
readyStart: event.start || 0,
readyEnd: event.end || 0,
title: taskDefinition.title,
resolved: taskDefinition.resolvedIf(c, r, event, dueDate, scheduledTaskIdx),
actions: initActions(taskDefinition.actions, event),
};
event,
});
}

if (scheduledTaskIdx !== undefined) {
task._id += '-' + scheduledTaskIdx;
}
return result;
}

priority = taskDefinition.priority;
if (typeof priority === 'function') {
priority = priority(c, r);
}
function emitTaskEvent(emitterContext, partialEmission, scheduledTaskIdx) {
const { taskDefinition, Utils, c, r, emit, Task } = emitterContext;

if (priority) {
task.priority = priority.level;
task.priorityLabel = priority.label;
}
if (!partialEmission._id) {
throw 'partialEmission._id';
}

if (!partialEmission.date) {
throw 'partialEmission.date';
}

emit('task', new Task(task));
if (!partialEmission.event) {
throw 'partialEmission.event';
}

const { event, date: dueDate } = partialEmission;
if (!Utils.isTimely(dueDate, event)) {
return;
}

const defaultEmission = {
// One task instance for each event per form that triggers a task, not per contact
// Otherwise they collide when contact has multiple reports of the same form
deleted: !!((c.contact && c.contact.deleted) || r ? r.deleted : false),
doc: c,
contact: obtainContactLabelFromSchedule(taskDefinition, c, r),
icon: taskDefinition.icon,
readyStart: event.start || 0,
readyEnd: event.end || 0,
title: taskDefinition.title,
resolved: taskDefinition.resolvedIf(c, r, event, dueDate, scheduledTaskIdx),
actions: initActions(taskDefinition.actions, event),
};

if (scheduledTaskIdx !== undefined) {
defaultEmission._id += '-' + scheduledTaskIdx;
}

let priority = taskDefinition.priority;
if (typeof priority === 'function') {
priority = priority(c, r);
}

if (priority) {
defaultEmission.priority = priority.level;
defaultEmission.priorityLabel = priority.label;
}

const emission = Object.assign({}, defaultEmission, partialEmission);
delete emission.event;
emit('task', new Task(emission));
}

function initActions(actions, event) {
return taskDefinition.actions.map(function(action) {
return actions.map(function(action) {
return initAction(action, event);
});
}

function initAction(action, event) {
var appliesToReport = !!r;
var content = {
const appliesToReport = !!r;
const content = {
source: 'task',
source_id: appliesToReport ? r._id : c.contact && c.contact._id,
contact: c.contact,
Expand Down
Loading

0 comments on commit 0521647

Please sign in to comment.