/**
* @fileOverview Moment API Controller
* @class moment
* @author Calvin Ho
*/
const User = require('../models/user');
const Moment = require('../models/moment');
const Response = require('../models/response');
const SystemlogController = require('../controllers/systemlog');
const ScheduleController = require('../controllers/schedule');
const ResourceController = require('../controllers/resource');
const async = require('async');
const randtoken = require('rand-token');
const pushSettings = require('../../config/push-notifications');
const PushNotifications = require('@calvinckho/node-pushnotifications');
const push = new PushNotifications(pushSettings);
const Conversation = require('../models/conversation');
const UserData = require('../controllers/user-data');
const Calendar = require('../models/calendar');
const GeoSpatial = require('../models/geospatial');
const mongoose = require('mongoose');
/**
* @desc Load all notes of a given Relationship
* @memberof moment
* @param req
* @param res
* @param next
* @param req.query.relationship: notes in this relationship (type: ObjectID) will be loaded
* @returns {Array} an array of Notes object
* updated by Calvin Ho 2/14/2020
*/
exports.loadNotes = async (req, res, next) => {
try {
let hasSuperAdminPermission;
if (req.query.relationship) {
if (await ResourceController.checkMomentPermission(req.query.relationship, 'user_list_2', null, null, req.user, null)) {
hasSuperAdminPermission = true;
}
}
query = req.query.relationship && req.query.relationship.length ? [
{
$match: {
_id: mongoose.Types.ObjectId(req.query.relationship)
}
},
] : [];
results = await Moment.aggregate(
query.concat([
{
$match: { // filter relationship that the user is a part of
$expr: {
$or: [
{ $in: [ req.user._id, { $ifNull: ["$user_list_1", []] }] },
{ $in: [ req.user._id, { $ifNull: ["$user_list_2", []] }] },
{ $in: [ req.user._id, { $ifNull: ["$user_list_3", []] }] },
hasSuperAdminPermission
]
}
}
},
{
$match: { // filter out deleted Activities
$expr: {
$cond: [
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
}
}
},
{
$lookup: { // load responses in the context of a relationship
from: "responses",
localField: "_id",
foreignField: "relationship",
as: "response"
}
},
{
$unwind: "$response"
},
{
$match: { // only choose responses to public and privateNotes, i.e. moment: ObjectId('5e4b39cd76ffc96ae6aba323')
$expr: {
$in: [ '$response.moment', [mongoose.Types.ObjectId('5e4b39cd76ffc96ae6aba323'), mongoose.Types.ObjectId('5e4c28d694fd1963ac0a5b1e') ]]
}
}
},
{
$lookup: { // load content calendars
from: "calendars",
localField: "response.calendar",
foreignField: "_id",
as: "content_calendar"
}
},
{
$unwind: {
path: "$content_calendar",
// for Note whose content calendar has been deleted, do not return Note (it has been deleted)
preserveNullAndEmptyArrays: false
},
},
{
$addFields: {
answer_array: "$response.matrix_string",
responseUpdatedAt: "$response.updatedAt"
}
},
{
$sort: { // sort the responses from the latest to the oldest. This is needed for the later group stage when only the latest doc is kept
responseUpdatedAt: -1
}
},
{
$unwind: "$answer_array" // unwind the responses text answers settings matrix
},
{
$addFields: {
question_id: {
$arrayElemAt: [ "$answer_array", 0 ]
}
}
},
{
$match: { // only question ID that is a valid 16-digit int will pass
$expr: {
$gt: [ { $convert: { input: "$question_id", to: "double", onError: 0, onNull: 0 } }, 0 ]
}
}
},
{
$addFields: {
question_id: {
$toDouble: "$question_id"
}
}
},
{
$lookup: { // check if response should be returned (i.e. if the question is collaborative)
from: "moments",
let: { contentId: "$content_calendar.moment", question_id: "$question_id", authorId: "$response.user" },
pipeline: [
{
$match: { // filter the corresponding relationship
$expr: {
$eq: [ "$_id", "$$contentId" ]
}
}
},
{
$lookup: { // load content's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$match: {
$expr: {
$and: [
{ // if question id's corresponds to an text answer component (40010)
$eq: [ {$arrayElemAt: [ { $arrayElemAt: [ "$resource.matrix_number", 0 ]}, {$indexOfArray: [ {$arrayElemAt: [ "$resource.matrix_number", 2 ]}, "$$question_id" ]} ]}, 40010 ]
},
{
$switch: { // check the user's permission to access the response
branches: [
{
case: { // if question setting is private
$eq: [{$arrayElemAt: [{$arrayElemAt: [ "$matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$resource.matrix_number", 2 ]}, "$$question_id" ]} ]}, 1]}, 0]
},
then: { // the user is the author
$eq: [ "$$authorId", req.user._id ]
},
},
{
case: { // if question setting is collaborative
$eq: [{$arrayElemAt: [{$arrayElemAt: [ "$matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$resource.matrix_number", 2 ]}, "$$question_id" ]} ]}, 1]}, 1]
},
then: true // for collaborative answers, return it
}
],
default: { // default is private, i.e. check user is the author
$eq: [ "$$authorId", req.user._id ]
}
}
}
]
}
}
},
{
$addFields: {
title: {
$arrayElemAt: [ { $arrayElemAt: [ "$matrix_string", 0 ]}, 0 ]
},
question: {
$arrayElemAt: [ { $arrayElemAt: [ "$matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$resource.matrix_number", 2 ]}, "$$question_id" ]} ]}, 0 ]
},
}
},
],
as: "question_settings"
}
},
{
$unwind: {
path: "$question_settings",
// DO not preserve empty array! because of privacy settings, private answer is supposed to be weeded out here
preserveNullAndEmptyArrays: false
}
},
{
$lookup: { // load author
from: "users",
localField: "response.user",
foreignField: "_id",
as: "response_author"
}
},
{
$unwind: {
path: "$response_author",
//preserveNullAndEmptyArrays: true
},
},
{
$addFields: {
doc: {
group_id: { // calendar Id is used. If null, use response id
$cond: {
if: {
$ifNull: [ "$content_calendar", false]
},
then: "$content_calendar._id",
else: "$response._id"
}
},
content_id: "$question_settings._id",
calendar_id: { // real calendar Id exists without response Id
$cond: {
if: {
$ifNull: [ "$content_calendar", false ]
},
then: "$content_calendar._id",
else: null
}
},
response_id: "$response._id",
question_id: "$question_id",
calendar_title: {
$cond: {
if: {
$ifNull: [ "$content_calendar", false]
},
then: "$content_calendar.title",
else: null
}
},
content_title: "$question_settings.title",
question: "$question_settings.question",
answer: "$answer_array",
updatedAt: "$responseUpdatedAt"
},
title: {
$arrayElemAt: [ { $arrayElemAt: [ "$matrix_string", 0 ]}, 0 ]
},
author: {
_id: "$response_author._id",
first_name: "$response_author.first_name",
last_name: "$response_author.last_name",
avatar: "$response_author.avatar"
}
}
},
{
$group: {
_id: "$doc.group_id", // group it by group id (calendar Id or response Id)
relationship: {
$first: "$_id"
},
doc: {
$first: "$doc"
},
authors: {
$addToSet: "$author"
},
title: {
$first: "$title"
}
}
},
{
$addFields: {
"doc.authors": "$authors"
}
},
{
$group: {
_id: "$relationship", // group it by relationship Id
title: {
$first: "$title"
},
docs: {
$addToSet: "$doc"
}
}
}
])
);//.allowDiskUse(true);
res.send(results);
} catch (err) {
next({topic: 'System Error', title: 'loadNotes', err: err});
}
};
/**
* @desc load all the onboarding answers of a given user
* @memberof moment
* @param user: the user (type: ObjectID)
* @param showOnlyPublicProfile: whether to show only public profile or full private profile (type: Boolean)
* @returns {Array} an array of Notes object
* updated by Calvin Ho 4/21/2020
*/
exports.loadUserOnboardingAnswers = async (user, showOnlyPublicProfile) => {
try {
results = await Moment.aggregate([
{
$match: {
$expr: {
$or: [
{ $in: [ mongoose.Types.ObjectId(user), { $ifNull: ["$user_list_1", []] }] },
{ $in: [ mongoose.Types.ObjectId(user), { $ifNull: ["$user_list_2", []] }] },
{ $in: [ mongoose.Types.ObjectId(user), { $ifNull: ["$user_list_3", []] }] }
]
}
}
},
{
$match: { // if show only public profile, only show Activity with array_boolean.0 === true
$expr: {
$cond: {
if: {
$eq: [ showOnlyPublicProfile, true ]
},
then: {
$arrayElemAt: [ { $ifNull: ["$array_boolean", [ false ]] }, 0 ]
},
else: true
}
}
}
},
{
$match: { // if show only public profile, only show Activity with array_boolean.0 === true
$expr: {
$cond: [ // filter out deleted Activities
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
}
}
},
{
$lookup: { // load program's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$lookup: { // load onboarding process' resource
from: "moments",
let: { programId: "$_id"},
pipeline: [
{
$match: {
$expr: {
$eq: [ "$program", "$$programId" ]
}
}
},
{
$lookup: { // load program's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
/*{
$unwind: "$resource.matrix_number"
},*/
],
as: "onboarding_process"
}
},
{
$unwind: "$onboarding_process"
},
{
$lookup: { // look up responses based on onboarding processes
from: "responses",
let: { onboarding_process: "$onboarding_process" },
pipeline: [
{
$match: { // filter the current Program's matched user's text answers
$expr: {
$and: [
{
$eq: ["$user", mongoose.Types.ObjectId(user) ]
},
{
$eq: ["$moment", "$$onboarding_process._id" ]
},
]
}
}
},
{
$unwind: {
path: "$matrix_number",
preserveNullAndEmptyArrays: true
}
},
{
$unwind: {
path: "$matrix_string",
preserveNullAndEmptyArrays: true
}
// only text answers are kept. m.c. and tile choice are excluded since they don't have their matrix_string is []
},
{
$addFields: {
multiple_choice: {
$cond: {
if: {
$ifNull: [ "$matrix_number", false ]
},
then: {
$cond: {
if: {
$gt: [ { $size: "$matrix_number" }, 5 ]
},
then: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
},
else: []
}
},
else: []
}
}
}
},
{
$unwind: {
path: "$multiple_choice",
preserveNullAndEmptyArrays: true
}
},
{
$project: {
_id: 1,
question_id: {
$switch: {
branches: [
{
case: { // text answer
$ifNull: [ "$matrix_string", false ]
},
then: { // question id
$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] } // using matrix_string[0] since it is valid for both m.c. and tile choice.
},
},
{
case: { // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
then: { // question id
$arrayElemAt: [ "$matrix_number", 0 ]
}
}
],
default: null
}
},
user_answer: {
$switch: {
branches: [
{
case: { // text answer type
$ifNull: [ "$matrix_string", false ]
},
then: { // textanswer
$arrayElemAt: [ "$matrix_string", 1 ]
//$slice: [ "$matrix_string", 1, { $subtract: [ { $size: "$matrix_string" }, 1 ] } ]
},
},
{
case: { // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
then: {
$switch: {
branches: [
{
case: {
$and: [
{ // tile choices. the user selection is stored as a option timestamp, hence a 15 digit number
$gte: [ "$multiple_choice", 100000 ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
},
{ // option index needs to be greater than -1
$gt: [{$indexOfArray: [{$arrayElemAt: [ "$$onboarding_process.matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice"]}, -1]
}
]
},
then: { // get user answer by reading the label from onboarding_process.matrix_string[componentIndex][optionIndex]
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string",
// componentIndex
{ $indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]},
// option index
{ $subtract: [{$indexOfArray: [{$arrayElemAt: [ "$$onboarding_process.matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice"]}, 5]}
]
},
},
{
case: {
$and: [
{ // multiple choice
$lt: [ "$multiple_choice", 100000 ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
},
{ // size of the array needs to be greater than the multiple_choice, which is a position index
$gt: [{$size:{$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}}, "$multiple_choice"]
}
]
},
then: { // get user answer by reading the label from onboarding_process.matrix_string[componentIndex][multiple_choice]
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice" ]
},
}
],
default: []
}
}
}
],
default: []
}
},
question: {
$switch: {
branches: [
{
case: {
$and: [
{ // text answer
$ifNull: [ "$matrix_string", false ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] }}]}, -1]
}
]
},
then: {
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] }}]} ]}, 0 ]
},
},
{
case: {
$and: [
{ // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
}
]
},
then: {
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.resource.en-US.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, 1 ]
},
}
],
default: null
}
}
}
},
{
$addFields: {
participation_type: {
$slice: [ "$$onboarding_process.array_boolean", 2, 4]
}
}
},
{
$group: {
_id: "$question_id",
response_id: {
$first: "$_id"
},
onboarding_id: {
$first: "$$onboarding_process._id"
},
participation_type: {
$first: "$participation_type"
},
question: {
$first: "$question"
},
user_answer: {
$addToSet: "$user_answer"
}
}
},
],
as: "user_data"
}
},
{
$unwind: "$user_data"
},
{
$group: {
_id: "$_id",
resource: {
$first: "$resource"
},
matrix_string: {
$first: "$matrix_string"
},
matrix_number: {
$first: "$matrix_number"
},
assets: {
$first: "$assets"
},
user_data: {
$addToSet: "$user_data"
}
}
},
]);
//console.log("res", results)
programs = [];
results.forEach((result) => {
temp_program = { _id: result._id,
name: result.matrix_string[0][0],
assets: result.assets,
participant: [],
organizer: [],
leader: []
};
componentIndex = result.resource.matrix_number[0].indexOf(10500);
if (componentIndex > -1) {
joinedandResponded = false;
for (i = 0; i < result.matrix_number[componentIndex].length - 11; i++) {
result.user_data.forEach((question_data) => {
if (result.matrix_number[componentIndex][i + 11] === question_data._id) {
if (i % 3 === 0 && question_data.participation_type[0]) {
temp_program.participant.push(question_data);
} else if (i % 3 === 1 && question_data.participation_type[1]) {
temp_program.organizer.push(question_data);
} else if (i % 3 === 2 && question_data.participation_type[2]) {
question_data.role = result.matrix_string[componentIndex][0];
temp_program.leader.push(question_data);
joinedandResponded = true;
}
}
})
}
}
if (joinedandResponded || showOnlyPublicProfile) {
programs.push(temp_program);
}
});
return programs;
} catch (err) {
SystemlogController.logFunctionError({topic: 'System Error', title: 'loadUserOnboardingAnswers', err: err});
}
};
/**
* @desc load all Activities shared in the marketplace
* @memberof moment
* @param req
* @param res
* @param next
* @params req.query.category: requested category (type ObjectId)
* @param req.query.version: API version
* @returns {Array} an array of marketplace activities
* req.body.categories is an array of categories the user is requesting. Typical use case in Picker expects an array of 1 category
* created by Calvin Ho 1/9/20
*/
exports.loadSampleActivities = async (req, res, next) => {
try {
if (req.query.version === '1') { // version 1 for 1.7.4+
let requested_categories;
const userId = req.user ? req.user._id : 'unauthenticated';
// all types allowed to be returned: Community, Program, Journey, Relationship, Mentoring, Group, Content, Onboarding Process
//const all_child_categories = [mongoose.Types.ObjectId('5c915324e172e4e64590e346'),mongoose.Types.ObjectId('5c915475e172e4e64590e348'),mongoose.Types.ObjectId('5e9f46e1c8bf1a622fec69d5'),mongoose.Types.ObjectId('5dfdbb547b00ea76b75e5a70'),mongoose.Types.ObjectId('5e9fe35cc8bf1a622fec69d7'),mongoose.Types.ObjectId('5e9fe372c8bf1a622fec69d8'),mongoose.Types.ObjectId('5c915476e172e4e64590e349'),mongoose.Types.ObjectId('5e1bbda67b00ea76b75e5a73')];
if (req.query.category && req.query.category.length) {
requested_categories = [mongoose.Types.ObjectId(req.query.category)];
} else { // if none is provided, return these categories: Program, Journey, Relationship, Mentoring, Group. Use case: Admin Choose Plans viewer
requested_categories = [mongoose.Types.ObjectId('5c915475e172e4e64590e348'),mongoose.Types.ObjectId('5e9f46e1c8bf1a622fec69d5'),mongoose.Types.ObjectId('5dfdbb547b00ea76b75e5a70'),mongoose.Types.ObjectId('5e9fe35cc8bf1a622fec69d7'),mongoose.Types.ObjectId('5e9fe372c8bf1a622fec69d8')];
}
const sample_activities = await Moment.aggregate([
{
$match: { // find all communities or programs a user is a part of
$expr:
{ $cond: {
if: { // if querying onboarding Activity
$in: [ mongoose.Types.ObjectId('5e17acd47b00ea76b75e5a71'), requested_categories ]
},
then: // only select Restvo defaults
{ $eq: [ "$_id", mongoose.Types.ObjectId('5d5785b462489003817fee18') ] },
else: // select all Programs and Communities a user is a part of
{
$and: [
{
$or: [ // either a community or a program
{ $in: [ mongoose.Types.ObjectId('5c915324e172e4e64590e346'), { $ifNull: ["$categories", []] }] },
{ $in: [ mongoose.Types.ObjectId('5c915475e172e4e64590e348'), { $ifNull: ["$categories", []] }] },
]
}
]
}
}},
}
},
{
$match: { // show only non-deleted Activities
$expr: {
$cond: [ // filter out deleted Activities
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
}
}
},
{
$lookup: { // load child/onboarding Activities that are hosted by the parent Programs/Communities
from: "moments",
let: { parent_program_id: "$_id", parent_user_list_1: "$user_list_1", parent_user_list_2: "$user_list_2", parent_user_list_3: "$user_list_3" },
pipeline: [
{
$match: { // select activities by parent program ID and matching at least 1 requested category
$expr: {
$and: [
{
$cond: [ // filter out deleted Activities
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
},
{ // if categories match the queried params
$gt: [ { $size: { $setIntersection: [ { $ifNull: [ "$categories", []] }, requested_categories ] }}, 0 ]
},
{
$switch: {
branches: [
{
case: { // if querying onboarding Activity
$in: [ mongoose.Types.ObjectId('5e17acd47b00ea76b75e5a71'), requested_categories ]
},
then: { // if onboarding Activity has been shared
$arrayElemAt: [ "$array_boolean", 1 ]
}
},
{
case: { // if public access and unauthenticated
$not: [ { $ifNull: [!!req.user, false] } ]
},
then: { // only return child activities if a child of Restvo, shared with marketplace is on, or not a child of Restvo, with discoverable turned on
$and: [
{
$in: [ "$$parent_program_id", { $ifNull: ["$parent_programs", []] }]
},
{
$or: [
{
$and: [
{ $eq: [ "$$parent_program_id", mongoose.Types.ObjectId('5d5785b462489003817fee18') ] },
{
$or: [ { $arrayElemAt: [ "$array_boolean", 0 ] },
{ $arrayElemAt: [ "$array_boolean", 1 ] } ]
}
]
},
{
$and: [
{ $ne: [ "$$parent_program_id", mongoose.Types.ObjectId('5d5785b462489003817fee18') ] },
{ $arrayElemAt: [ "$array_boolean", 0 ] }
]
}]
}
]
}
},
{
case: { // if user has joined parent program
$or: [
{ $in: [ userId, { $ifNull: ["$$parent_user_list_1", []] }] },
{ $in: [ userId, { $ifNull: ["$$parent_user_list_2", []] }] },
{ $in: [ userId, { $ifNull: ["$$parent_user_list_3", []] }] },
]
},
then: { // only return child activities if shared with marketplace is on, or if discoverable is on
$and: [
{
$in: [ "$$parent_program_id", { $ifNull: ["$parent_programs", []] }]
},
{
$or: [ { $arrayElemAt: [ "$array_boolean", 0 ] },
{ $arrayElemAt: [ "$array_boolean", 1 ] } ]
}
]
}
},
{
case: { // if user has not joined parent program
$eq: [{
$and: [
{ $in: [ userId, { $ifNull: ["$$parent_user_list_1", []] }] },
{ $in: [ userId, { $ifNull: ["$$parent_user_list_2", []] }] },
{ $in: [ userId, { $ifNull: ["$$parent_user_list_3", []] }] },
]
}, false ]
},
then: { // only return child activities that has discoverable turned on
$and: [
{
$in: [ "$$parent_program_id", { $ifNull: ["$parent_programs", []] }]
},
{
$arrayElemAt: [ "$array_boolean", 0 ]
}
]
}
},
/*{
case: { // if querying child activity, return those matching parent Activity ID
$gt: [ { $size: { $setIntersection: [ all_child_categories, requested_categories ] }}, 0 ]
},
then: { // only return child activities if user is enrolled their the parent program
$in: [ "$$parent_program_id", { $ifNull: ["$parent_programs", []] }]
}
}*/
],
default: false
}
},
]
}
}
},
{
$lookup: { // load program's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
],
as: "sample_activities"
}
},
{
$unwind: {
path: "$sample_activities",
preserveNullAndEmptyArrays: false // since categories is an optional field and can be empty
}
},
{
$group: {
_id: "$_id",
resource: {
$first: "$resource"
},
categories: {
$first: "$categories"
},
matrix_string: {
$first: "$matrix_string"
},
matrix_number: {
$first: "$matrix_number"
},
assets: {
$first: "$assets"
},
sample_activities: {
$addToSet: "$sample_activities"
}
}
},
]);
res.json(sample_activities);
} else { // backward compatibility for < 1.7.4
const moments = await Moment.find({
"array_boolean.1": true,
deletedAt: { $exists: false }
}).populate('resource categories');
res.json(moments);
}
} catch (err) {
next({topic: 'System Error', title: 'loadSampleActivities', err: err});
}
};
/**
* @desc Loads all the child Activities hosted by the given program
* @memberof moment
* @param req
* @param req.params.programId: Activity (type: ObjectId) of interest
* @param res
* @param next
* @returns {array} Array of child Activities
*
* created by Calvin Ho 1/9/20
*/
exports.loadChildActivities = async (req, res, next) => {
try {
if (req.params.programId && req.query.category) { // load all child Activities hosted by a parent Program
let query = {
parent_programs: req.params.programId,
categories: req.query.category,
};
if (!['owner','admin','staff'].includes(req.user.role)) { // if not admin, returns only live Activities
query.deletedAt = { $exists: false }
}
const moments = await Moment.find(query).populate('resource categories');
res.json(moments);
} else { // program ID or category ID missing
next({topic: 'System Error', title: 'loadChildActivities: mission Parent Program ID or category', err: err});
}
} catch (err) {
next({topic: 'System Error', title: 'loadChildActivities', err: err});
}
};
/**
* @desc Load the list of onboarding flow that the program (req.params.dependentMomentId) associates with
* @memberof moment
* @param req
* @param res
* @param next
* @returns {Promise<void>}
*
* created by Calvin Ho 10/14/19
*/
//
exports.loadOnboardActivities = async (req, res, next) => {
try {
const programId = req.params.programId || "5d5785b462489003817fee18"; // the program Id, if not provided, use the default Restvo Mentoring Program
query = [
{
$match: { // find Onboarding process that matches the program Id
"program": mongoose.Types.ObjectId(programId)
}
},
{
$match: { // select only Live Activities
$expr: {
$cond: [ // filter out deleted Activities
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
},
}
},
{
$match: { // match the onboarding participant type (req.query.type)
$expr: {
$or: [
{ // backward compatibility when none is provided
$not: [req.params.programId]
},
{ // when loading type 2, 3, or 4 together, no type value is provided and it returns all types
$not: [req.query.type]
},
{ // querying type 2 (participants), type 3 (organizers), type 4 (leaders)
$eq: [{ $arrayElemAt: [ "$array_boolean", parseInt(req.query.type, 10) ]}, true ]
}
]
}
}
},
{
$lookup: { // load the moment's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$lookup: { // load the moment's categories
from: "resources",
localField: "categories",
foreignField: "_id",
as: "categories"
}
},
{
$unwind: {
path: "$categories",
preserveNullAndEmptyArrays: true // since categories is an optional field and can be empty
}
},
{
$lookup: { // load the moment's date/time data
from: "calendar",
localField: "calendar",
foreignField: "_id",
as: "calendar"
}
},
{
$unwind: {
path: "$calendar",
preserveNullAndEmptyArrays: true // since calendar is an optional field and can be empty
}
}];
const moments = await Moment.aggregate(
query.concat([
{
$sort: {
updatedAt: -1
}
}
])
);
if (!req.query.version) { // for version 0 that is < 1.6.3 (30)
res.json(moments);
} else { // for version 1 that is 1.6.3 (30)+
// return both System Activity responses and Matching Configuration records
results = await Response.find({
dependent_moment: programId,
});
responses = { // responses has two fields
preferences: [],
matching_config: []
};
for (const response of results) {
// if Matching Configuration records
if (response.array_number && response.array_number.length && (response.array_number[0] === 50000)) {
responses.matching_config.push(response);
} else { // for Onboarding Process responses
responses.preferences.push(response);
}
}
res.json({preferences: moments, responses: responses});
}
} catch (err) {
next({topic: 'System Error', title: 'loadOnboardActivities', err: err});
}
};
//
/** load the list of onboarding questionnaires the user is required to respond to
* @func loadUserPreferences
* @desc load the list of onboarding questionnaires the user is required to respond to
* @memberof moment
* @param req
* @param res
* @param next
* @param req.query.type: type of Onboarding Process (type: Number). Type 2: Participant, Type 3: Organizer, Type 4: Leader
* @param req.query.pageNum: page number (type: Number)
* @returns {Array} Array of Onboarding Processes
* created by Calvin Ho 5/7/19
*/
exports.loadUserPreferences = async (req, res, next) => {
try {
itemsPerPage = 10;
let pageNum = parseInt(req.query.pageNum, 10) || 1;
let onboarding_processes = [];
const type = req.query.type ? (parseInt(req.query.type, 10) > 1 ? parseInt(req.query.type, 10) : null) : null;
if (req.query.programId) {
userProgramIds = [mongoose.Types.ObjectId(req.query.programId)]
} else {
userPrograms = await Moment.find({ $or: [
{ user_list_1: req.user._id },
{ user_list_2: req.user._id },
{ user_list_3: req.user._id },
], deletedAt: { $exists: false} }, { _id: 1, matrix_string: 1 });
userProgramIds = userPrograms.map((c) => c._id );
}
if (pageNum === 1) {
// load all required onboarding processes
// case 1: program exists and type exists - load onboarding processes for an unenrolled program
// case 2: program exists but no type - load all onboarding processes for that program, program already enrolled
// case 3: no program nor type provided - load all onboarding processes for programs enrolled by the user
// if programId is provided, make it the first stage
query = req.query.programId && req.query.programId.length ? [
{
$match: {
_id: mongoose.Types.ObjectId(req.query.programId)
}
}
] : [];
if (!req.query.programId || !req.query.type) { // case 2 or 3, if at least one of the two is not provided
query = query.concat([
{
$match: { // find all programs a user is a part of
$expr: {
$or: [
{ $in: [ req.user._id, { $ifNull: ["$user_list_1", []] }] },
{ $in: [ req.user._id, { $ifNull: ["$user_list_2", []] }] },
{ $in: [ req.user._id, { $ifNull: ["$user_list_3", []] }] }
]
}
}
},
]);
}
onboarding_processes = await Moment.aggregate(
query.concat([
{
$lookup: { // find the corresponding onboarding processes
from: "moments",
let: { programId: "$_id", user_list_1: "$user_list_1", user_list_2: "$user_list_2", user_list_3: "$user_list_3" },
pipeline: [
{
$match: { // retain if it finds a parent record
$expr: {
$and: [
{
$eq: ["$program", "$$programId" ]
},
{
$cond: [ // filter out deleted Processes
{$ifNull: ['$deletedAt', false]}, // if
false, // then
true // else
]
},
{
$switch: {
branches: [
{
case: { // if both program and type are provided, case 1 - user has not joined program
$and: [{$ifNull: [ req.query.programId, false ]}, { $ifNull: [ type, false ] }]
},
then: {
$eq: [{ $arrayElemAt: [ "$array_boolean", type ]}, true ]
}
},
{
case: { // case 2 and 3. user has joined these programs, get all the onboarding processes
$ifNull: [ type, true ] // if type is not provided
},
then: { //
$or: [
{ $and: [{ $arrayElemAt: [ "$array_boolean", 2 ]}, { $in: [ req.user._id, "$$user_list_1" ]} ]},
{ $and: [{ $arrayElemAt: [ "$array_boolean", 3 ]}, { $in: [ req.user._id, "$$user_list_2" ]} ]},
{ $and: [{ $arrayElemAt: [ "$array_boolean", 4 ]}, { $in: [ req.user._id, "$$user_list_3" ]} ]},
]
}
}
]
}
}
]
}
}
},
{
$lookup: { // find all parent activity relationship records
from: "responses",
let: { moment_id: "$_id" },
pipeline: [
{
$match: { // retain if it finds a parent record
$expr: {
$eq: ["$dependent_moment", "$$moment_id" ]
}
}
}
],
as: "response" // response is an array if it has a parent, empty array if it has no parent
}
},
{
$unwind: { // unwind and preserve records as null if they have no parent record
path: "$response",
preserveNullAndEmptyArrays: true // response will be false if no parent is found
}
},
{
$match: { // select moment that has no parent activity, i.e. they are required onboarding processes
response: { $exists: false }
}
},
{
$lookup: { // load moment's resource field
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$lookup: { // load the current user's responses to the questionnaires
from: "responses",
let: { moment_id: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{
$eq: ["$user", req.user._id]
},
{
$eq: ["$moment", "$$moment_id"]
}
]
}
}
},
],
as: "response"
}
},
{
$unwind: {
path: "$response",
preserveNullAndEmptyArrays: true // since response can be empty
}
}
],
as: "onboarding_process" // onboarding_process is an array if it exists, empty array if there is no onboarding process
}
},
{
$unwind: { // unwind and remove programs if they don't have onboarding processes that match the participant status (participant, organizer, leader)
path: "$onboarding_process",
}
},
{
$replaceRoot: {
newRoot: "$onboarding_process"
}
},
{
$lookup: { // load the program
from: "moments",
localField: "program",
foreignField: "_id",
as: "program"
}
},
{
$unwind: "$program"
}
])
);
}
// load onboarding processes that were unlocked by user's choices
unlocked_processes = await Response.aggregate([
{
$match: { // retain the user's responses
user: req.user._id
}
},
{
$unwind: "$matrix_number"
},
{
$match: { // retain responses to interactables
$expr: {
$gt: [
{ $size: "$matrix_number" }, 5
]
}
}
},
{
$addFields: { // add question id field
question_id: {
$arrayElemAt: [ "$matrix_number", 0 ]
}
},
},
{
$project: { // remove the metadata elements (position 0 - 4)
moment: 1,
question_id: 1,
choices: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
}
}
},
{
$lookup: { // find parent/child activity records for the current question (own_question_id)
from: "responses",
let: { own_question_id: "$question_id", own_choices: "$choices" },
pipeline: [
{
$match: {
dependent_moment: { $exists: true }
}
},
{
$unwind: "$matrix_number"
},
{
$match: {
$expr: {
$gt: [
{ $size: "$matrix_number" }, 5
]
}
}
},
{
$addFields: {
others_question_id: {
$arrayElemAt: [ "$matrix_number", 0 ]
}
},
},
{
$project: { // clean up others choices by removing the metadata
_id: 0,
dependent_moment: 1,
others_question_id: 1,
others_choices: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
}
}
},
{
$match: {
$expr: {
$eq: [ "$others_question_id", "$$own_question_id" ]
}
}
},
{
$project: { // if child questionnaire matches user's choices, commonToBoth will have an array of those choices
dependent_moment: 1,
others_choices: 1,
others_question_id: 1,
commonToBoth: {
$setIntersection: [
"$others_choices", "$$own_choices"
]
}
}
}
],
as: "matched_dependent_moments"
}
},
{
$match: { // retain if matched dependent moments have at least one element in the array
$expr: {
$gt: [
{ $size: "$matched_dependent_moments" }, 0
]
}
}
},
{
$unwind: "$matched_dependent_moments"
},
{
$match: { // retain if there is at least one user's choice that matches a dependent moment
$expr: {
$gt: [
{ $size: "$matched_dependent_moments.commonToBoth" }, 0
]
}
}
},
{
$lookup: { // load the dependent moment (it unlocks it for the user)
from: "moments",
localField: "matched_dependent_moments.dependent_moment",
foreignField: "_id",
as: "matched_dependent_moments"
}
},
{
$unwind: "$matched_dependent_moments"
},
{
$replaceRoot: { // promote it
newRoot: "$matched_dependent_moments"
}
},
{
$match: { // matches the program Id
//"program": mongoose.Types.ObjectId(programId)
$expr: {
$and: [
{
$in: [ "$program", userProgramIds ]
},
{
$switch: {
branches: [
{
case: {
$gt: [ type, 1 ] // if type is 2, 3, or 4
},
then: {
$eq: [{ $arrayElemAt: [ "$array_boolean", type ]}, true ]
}
},
{
case: {
$ifNull: [ type, true ] // if type is missing
},
then: {
$or: [
{ $eq: [{ $arrayElemAt: [ "$array_boolean", 2 ]}, true ]},
{ $eq: [{ $arrayElemAt: [ "$array_boolean", 3 ]}, true ]},
{ $eq: [{ $arrayElemAt: [ "$array_boolean", 4 ]}, true ]},
]
}
}
]
}
}
]
}
}
},
{
$lookup: { // load the dependent moment's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$lookup: { // load the program
from: "moments",
localField: "program",
foreignField: "_id",
as: "program",
}
},
{
$unwind: "$program"
},
{
$lookup: { // load the user's choices in response to the dependent questionnaire (to display whether the user has at least responded to one question)
from: "responses",
let: { moment_id: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{
$eq: ["$user", req.user._id]
},
{
$eq: ["$moment", "$$moment_id"]
}
]
}
}
},
],
as: "response"
}
},
{
$unwind: {
path: "$response",
preserveNullAndEmptyArrays: true // since response can be empty
}
},
{
$skip: itemsPerPage * (pageNum - 1)
},
{
$limit: itemsPerPage
},
]);
onboarding_processes = onboarding_processes.concat(unlocked_processes);
res.json(onboarding_processes);
} catch (err) {
next({topic: 'System Error', title: 'loadUserPreferences', err: err});
}
};
/**
* @desc execute the matching algorithm to find users best matched based on how they answered the questions
* @memberof moment
* @param req
* @param res
* @param next
* @returns {Promise<void>}
*/
//
exports.computeMatchingUsers = async (req, res, next) => {
try {
user = req.user ? req.user._id : mongoose.Types.ObjectId('596dc94782e2eb4512a320b6'); // default user is Gideon Ho, if it is an unauthenticated user
itemsPerPage = 15;
pageNum = req.query.pageNum || 1;
programId = req.query.momentId ? mongoose.Types.ObjectId(req.query.momentId) : mongoose.Types.ObjectId("5d5785b462489003817fee18"); // '' if no momentId is provided, set it to the default Restvo mentorship program
// load the program's onboarding processes
const program = await Moment.findOne({ _id: programId }).populate('resource');
if (!program) return res.json([]); // in cases where the Activity is deleted but the web app still tries to query for it (e.g. click the back button), it returns an empty array
const onboarding_processes = await Moment.find({ program: programId }).populate('resource');
let onboarding_processes_ids = onboarding_processes.map((c) => mongoose.Types.ObjectId(c._id) );
onboarding_processes_ids.push(mongoose.Types.ObjectId('5d9699ae4b43ac3154cba176'));
const matched_users = await Response.aggregate([
{
$match: { // select user's own responses
user: user
}
},
{ // unwind the list of questions
$unwind: "$matrix_number"
},
{
$match: { // select only if responses has matrix_number that is longer than length 5 (responses to questionnaire)
$expr: {
$gt: [
{ $size: "$matrix_number" }, 5
]
}
}
},
{
$addFields: { // add question id field, stored in matrix_number, position 0
question_id: {
$arrayElemAt: [ "$matrix_number", 0 ]
}
},
},
{
$project: { // format data by removing first 5 elements in matrix_number (metadata). See schematic for more details
moment: 1,
question_id: 1,
choices: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
}
}
},
{
$lookup: { // get a list of moment's participants
from: "moments",
pipeline: [
{
$match: {
_id: programId
}
},
{
$lookup: { // load moment's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$addFields: { // fetch the list of component Ids
componentIds: {
$arrayElemAt: [ "$resource.matrix_number", 0 ]
}
},
},
{
$addFields: { // find the index of the show directory component
component_index: {
$indexOfArray: [ "$componentIds", 50000 ] // find the index of component Id 50000
}
},
},
{
$addFields: { // get the show directory config settings
directory_config_settings: {
$arrayElemAt: [ "$matrix_number", "$component_index" ]
}
},
},
{
$project: { // project only the user_list_1, user_list_2, user_list_3 fields which contain the list of participants, organizers, and mentors
directory_config_settings: 1,
user_list_1: 1,
user_list_2: 1,
user_list_3: 1
}
}
],
as: "program"
}
},
{ // unwind program
$unwind: "$program"
},
{
$lookup: { // do an outer join with other users' responses to the questionnaires
from: "responses",
let: { own_question_id: "$question_id", own_choices: "$choices", program: "$program" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ // exclude user's own responses
$ne: [ "$user", user ]
},
{
$or: [
{ // retain the responses of the Activity's participants only (if set in config)
$and: [
{
$eq: [{ $arrayElemAt: [ "$$program.directory_config_settings", 0 ]}, 1 ]
},
{
$in: [ "$user", "$$program.user_list_1" ]
}]
},
{ // retain the responses of the Activity's organizers only (if set in config), or if it is the default Restvo Mentoring program, show all Restvo users
$and: [
{
$eq: [{ $arrayElemAt: [ "$$program.directory_config_settings", 1 ]}, 1 ]
},
{
$in: [ "$user", "$$program.user_list_2" ]
}]
},
{ // retain the responses of the Activity's leaders only (if set in config), or if it is the default Restvo Mentoring program, show all Restvo users
$and: [
{
$eq: [{ $arrayElemAt: [ "$$program.directory_config_settings", 2 ]}, 1 ]
},
{
$in: [ "$user", "$$program.user_list_3" ]
}]
},
/*{
$eq: [ programId, mongoose.Types.ObjectId("5d5785b462489003817fee18") ]
}*/
]
}
]
}
}
},
{ // unwind the questions in each response
$unwind: "$matrix_number"
},
{
$match: { // filter out records that has matrix_number shorter than 5. They are not valid records.
$expr: {
$gt: [
{ $size: "$matrix_number" }, 5
]
}
}
},
{
$addFields: { // add the others_question_id field
others_question_id: {
$arrayElemAt: [ "$matrix_number", 0 ]
}
},
},
{
$project: { // format data by removing first 5 elements in matrix_number (metadata). See schematic for more details
_id: 0,
user: 1,
others_question_id: 1,
others_choices: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
},
}
},
{
$match: { // keep records with matching question ids or if it is the special Public Listing question (should be unique in Restvo Mentors)
$expr: {
$or: [
{
$eq: [ "$others_question_id", "$$own_question_id" ]
},
{
$eq: [ "$others_question_id", 1571716738858625 ] // this should only apply when Program queried is Restvo Mentor
}
]
}
}
},
{
$project: { // format and calculate if the user's choices intersect with the participant's choice (return an array of matched choices in commonToBoth)
user: 1,
others_choices: 1,
others_question_id: 1,
commonToBoth: { // an array of matched choices that are common in both arrays, excluding the special Public Listing response which is manually included in the previous stage
$cond: {
if: {
$ne: [ "$others_question_id", 1571716738858625 ] // this is necessary to excluded the Public Sharing response answers from being included in the matched_score calculation, which will skew the results
},
then: {
$setIntersection: [ "$others_choices", "$$own_choices" ]
},
else: []
}
},
isOthersOnly: { // an array of choices that exists in other's choices only, excluding the special Public Listing response
$cond: {
if: {
$ne: [ "$others_question_id", 1571716738858625 ] // this is necessary to excluded the Public Sharing response answers from being included in the matched_score calculation, which will skew the results
},
then: {
$setDifference: [ "$others_choices", "$$own_choices" ]
},
else: []
}
},
isOwnOnly: { // an array of choices that exists in own choices only, excluding the special Public Listing response
$cond: {
if: {
$ne: [ "$others_question_id", 1571716738858625 ] // this is necessary to excluded the Public Sharing response answers from being included in the matched_score calculation, which will skew the results
},
then: {
$setDifference: [ "$$own_choices", "$others_choices" ]
},
else: []
}
},
}
}
],
as: "matched_users"
}
},
{
$unwind: "$matched_users"
},
{
$lookup: { // do an outer join to get a list of matching configuration records
from: "responses",
let: { moment_id: "$moment", question_id: "$matched_users.others_question_id" },
pipeline: [
{
$match: { // select only matching config records
"array_number.0": 50000
}
},
{
$match: {
$expr: {
$and: [
{
$eq: ["$moment", "$$moment_id"]
},
{
$eq: ["$dependent_moment", programId]
}
]
}
}
},
{ // unwind the questions in each response
$unwind: "$matrix_number"
},
{
$addFields: { // add the others_question_id field
question_id: {
$arrayElemAt: [ "$matrix_number", 0 ]
}
},
},
{
$match: {
$expr: {
$eq: ["$question_id", "$$question_id"]
}
}
},
],
as: "matching_config"
}
},
{
$unwind: {
path: "$matching_config",
preserveNullAndEmptyArrays: true // since matching config can be null (no matching config)
},
},
{
$project: {
user: "$matched_users.user",
moment: 1,
response: {
question_id: "$question_id",
selections: "$matched_users.commonToBoth"
},
matching_config: 1,
matched_users: 1,
matched_score: {
$switch: {
branches: [
{
case: // when the question is excluded from the cumulative scoring
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 1 ]}, -1 ]
},
then: 0
},
{
case: // when matching similar answers
{
$and: [
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 1 ]}, 1 ]
},
{
$or: [
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 2 ]}, 1 ]
},
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 2 ]}, 2 ]
}
]
}
]
},
then: {
$multiply: [
{
$arrayElemAt: [ "$matching_config.matrix_number", 3 ]
},
{
$size: "$matched_users.commonToBoth"
}
]
}
},
{
case: // when matching different answers
{
$and: [
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 1 ]}, 1 ]
},
{
$or: [
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 2 ]}, 3 ]
},
{
$eq: [{ $arrayElemAt: [ "$matching_config.matrix_number", 2 ]}, 4 ]
}
]
}
]
},
then: {
$multiply: [
{
$arrayElemAt: [ "$matching_config.matrix_number", 3 ]
},
{
$add: [
{
$size: "$matched_users.isOthersOnly"
},
{
$size: "$matched_users.isOwnOnly"
}
]
}
]
}
},
],
default: { $size: "$matched_users.commonToBoth" }
}
},
user_filter: {
$switch: {
branches: [
{
case: // special case: when a user turns off public sharing for Restvo Mentor
{
$and: [
{
$eq: [ programId, mongoose.Types.ObjectId("5d5785b462489003817fee18") ]
},
{
$eq: [ "$matched_users.others_question_id", 1571716738858625 ]
},
{
$eq: [ { $arrayElemAt: [ "$matched_users.others_choices", 0 ] }, 1 ] // user chooses to turn off listing in Restvo mentor
}
]
},
then: 1,
},
{
case: // filter out records that requires an answer but the other user's record is blank
{
$and: [
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 1] }, 2]
},
{
$ne: [{ $arrayElemAt: ["$matching_config.matrix_number", 0] }, "$question_id"]
}
]
},
then: 1,
},
{
case: // filter out records that are NOT partially similar match config settings
{
$and: [
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 1] }, 2]
},
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 2] }, 1]
},
{
$eq: [{ $size: "$matched_users.commonToBoth" }, 0] // has no overlap
},
{
$or: [ // either exactly the same or exactly opposite
{
$gt: [{ $size: "$matched_users.isOthersOnly" }, 0] // has is others only
},
{
$gt: [{ $size: "$matched_users.isOwnOnly" }, 0] // has is Own only
},
]
},
]
},
then: 1,
},
{
case: // filter out records that are NOT exact match config settings
{
$and: [
{
$and: [
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 1] }, 2]
},
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 2] }, 2]
}
]
},
{
$or: [ // either exactly the same or exactly opposite
{
$gt: [{ $size: "$matched_users.isOthersOnly" }, 0] // has is others only
},
{
$gt: [{ $size: "$matched_users.isOwnOnly" }, 0] // has is own only
},
]
}
]
},
then: 1,
},
{
case: // filter out records that are NOT partially different match config settings
{
$and: [
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 1] }, 2]
},
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 2] }, 3]
},
{
$gt: [{ $size: "$matched_users.commonToBoth" }, 0] // has at least one overlap
},
{
$eq: [{ $size: "$matched_users.isOthersOnly" }, 0]
},
{
$eq: [{ $size: "$matched_users.isOwnOnly" }, 0]
},
]
},
then: 1,
},
{
case: // filter out records that are NOT exact opposite match config settings
{
$and: [
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 1] }, 2]
},
{
$eq: [{ $arrayElemAt: ["$matching_config.matrix_number", 2] }, 4]
},
{
$gt: [{ $size: "$matched_users.commonToBoth" }, 0] // has at least one overlap
},
]
},
then: 1,
}
],
default: 0
}
}
}
},
{
$lookup: { // load the response's moment
from: "moments",
localField: "moment",
foreignField: "_id",
as: "moment"
}
},
{
$unwind: "$moment"
},
{
$lookup: { // load moment's resource
from: "resources",
localField: "moment.resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
{
$group: { // tally up the responses' matched score into the users' matched score
_id: "$user", // the matched user's id
score: {
$sum: "$matched_score"
},
preferences: {
$addToSet: {
resource: "$resource",
moment: "$moment",
response: "$response",
}
},
matching_config: {
$addToSet: "$matching_config"
},
matched_users: {
$addToSet: "$matched_users"
},
remove_user: {
$sum: "$user_filter"
}
}
},
{
$match: { // retain user that has no remove_user operator turned on
"remove_user": 0
}
},
{
$sort: { // sort matched scores in reversed order
score: -1
}
},
{ // pagination
$skip: itemsPerPage * (pageNum - 1)
},
{ // limit number of returned items per query
$limit: itemsPerPage
},
{ // load the matched user's profile
$lookup: {
from: "users",
localField: "_id",
foreignField: "_id",
as: "user"
}
},
{ // remove the container array
$unwind: "$user"
},
{
$lookup: {
from: "moments",
pipeline: [
{
$match: {
_id: programId
}
},
{
$lookup: { // load program's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
],
as: "program"
}
},
{
$unwind: "$program"
},
{
$lookup: { // load onboarding process' resource
from: "moments",
let: { programId: "$program._id"},
pipeline: [
{
$match: {
$expr: {
$eq: [ "$program", "$$programId" ]
}
}
},
{
$lookup: { // load program's resource
from: "resources",
localField: "resource",
foreignField: "_id",
as: "resource"
}
},
{
$unwind: "$resource"
},
/*{
$unwind: "$resource.matrix_number"
},*/
],
as: "onboarding_process"
}
},
{
$unwind: "$onboarding_process"
},
{ // load matched user's answers to the current program of interest
$lookup: { //
from: "responses",
let: { userId: "$user._id", onboarding_process: "$onboarding_process" },
pipeline: [
{
$match: { // filter the current Program's matched user's text answers
$expr: {
$and: [
{
$eq: ["$user", "$$userId"]
},
{
$in: ["$moment", {$ifNull :[ onboarding_processes_ids,[] ]}]
},
// temp-recovery remove to allow old user bio question to show up. can be reinstated once 1.6.3 (48) is obsolete
/*{
$in: [40010, {$ifNull :[ "$array_number", [] ]}]
}*/
]
}
}
},
{
$unwind: {
path: "$matrix_number",
preserveNullAndEmptyArrays: true
}
},
{
$unwind: {
path: "$matrix_string",
preserveNullAndEmptyArrays: true
}
// only text answers are kept. m.c. and tile choice are excluded since they don't have their matrix_string is []
},
{
$addFields: {
multiple_choice: {
$cond: {
if: {
$ifNull: [ "$matrix_number", false ]
},
then: {
$cond: {
if: {
$gt: [ { $size: "$matrix_number" }, 5 ]
},
then: {
$slice: [ "$matrix_number", 5, { $subtract: [ { $size: "$matrix_number" }, 5 ] } ]
},
else: []
}
},
else: []
}
}
}
},
{
$unwind: {
path: "$multiple_choice",
preserveNullAndEmptyArrays: true
}
},
{
$project: {
_id: 1,
question_id: {
$switch: {
branches: [
{
case: { // text answer
$ifNull: [ "$matrix_string", false ]
},
then: { // question id
$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] } // using matrix_string[0] since it is valid for both m.c. and tile choice.
},
},
{
case: { // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
then: { // question id
$arrayElemAt: [ "$matrix_number", 0 ]
}
}
],
default: null
}
},
user_answer: {
$switch: {
branches: [
{
case: { // text answer type
$ifNull: [ "$matrix_string", false ]
},
then: { // textanswer
$arrayElemAt: [ "$matrix_string", 1 ]
//$slice: [ "$matrix_string", 1, { $subtract: [ { $size: "$matrix_string" }, 1 ] } ]
},
},
{
case: { // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
then: {
$switch: {
branches: [
{
case: {
$and: [
{ // tile choices. the user selection is stored as a option timestamp, hence a 15 digit number
$gte: [ "$multiple_choice", 100000 ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
},
{ // option index needs to be greater than -1
$gt: [{$indexOfArray: [{$arrayElemAt: [ "$$onboarding_process.matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice"]}, -1]
}
]
},
then: { // get user answer by reading the label from onboarding_process.matrix_string[componentIndex][optionIndex]
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string",
// componentIndex
{ $indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]},
// option index
{ $subtract: [{$indexOfArray: [{$arrayElemAt: [ "$$onboarding_process.matrix_number", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice"]}, 5]}
]
},
},
{
case: {
$and: [
{ // multiple choice
$lt: [ "$multiple_choice", 100000 ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
},
{ // size of the array needs to be greater than the multiple_choice, which is a position index
$gt: [{$size:{$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}}, "$multiple_choice"]
}
]
},
then: { // get user answer by reading the label from onboarding_process.matrix_string[componentIndex][multiple_choice]
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, "$multiple_choice" ]
},
}
],
default: []
}
}
}
],
default: []
}
},
question: {
$switch: {
branches: [
{
case: {
$and: [
{ // text answer
$ifNull: [ "$matrix_string", false ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] }}]}, -1]
}
]
},
then: {
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] }}]} ]}, 0 ]
},
},
{
case: {
$and: [
{ // multiple choice and tile choices
$ifNull: [ "$matrix_number", false ]
},
{ // componentIndex needs to be greater than -1
$gt: [{$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]}, -1]
}
]
},
then: {
$arrayElemAt: [ {$arrayElemAt: [ "$$onboarding_process.resource.en-US.matrix_string", {$indexOfArray: [ {$arrayElemAt: [ "$$onboarding_process.resource.matrix_number", 2 ]}, {$arrayElemAt: [ "$matrix_number", 0 ]}]} ]}, 1 ]
},
}
],
default: null
}
}
}
},
{
$group: {
_id: "$question_id",
response_id: {
$first: "$_id"
},
question: {
$first: "$question"
},
user_answer: {
$addToSet: "$user_answer"
}
}
},
{
$project: {
_id: 1,
question_id: "$_id",
question: "$question",
user_answer: "$user_answer"
}
}
/*{
$unwind: "$matrix_string"
// only text answers are kept. m.c. and tile choice are excluded since they don't have their matrix_string is []
},
{
$project: { // project only the matrix_string[0][1] element which is the bio field
_id: 0,
question_id: {
$toLong: { $arrayElemAt: [ "$matrix_string", 0 ] } // using matrix_string[0] since it is valid for both m.c. and tile choice.
},
user_answer: { // slice out pos 0 which is the
$slice: [ "$matrix_string", 1, { $subtract: [ { $size: "$matrix_string" }, 1 ] } ]
}
}
},
{
$unwind: {
path: "$user_answer",
preserveNullAndEmptyArrays: true // shouldn't be empty, but just in case
}
}*/
],
as: "user_data"
}
},
{
$group: {
_id: "$_id",
user: {
$first: "$user"
},
score: {
$first: "$score"
},
preferences: {
$first: "$preferences"
},
user_data: {
$first: "$user_data"
},
matched_users: {
$first: "$matched_users"
},
}
},
/*{ // remove the container array
$unwind: {
path: "$user_data",
preserveNullAndEmptyArrays: true // preserve user with no user_data_1 response yet
},
},*/
{
$project: { // format returned data
first_name: "$user.first_name",
last_name: "$user.last_name",
//user_data_1: "$user_data.user_data_1",
avatar: "$user.avatar",
score: 1,
preferences: 1,
user_data: 1,
// matching_config: 1, // for debugging purpose
matched_users: 1,
//remove_user: 1 // for debugging purpose
}
}
]);
for (let user of matched_users) {
user.preferences.forEach((preference) => {
preference.answers = [];
componentIndex = preference.resource.matrix_number[2].indexOf(preference.response.question_id);
if (componentIndex > -1) { // it is possible the question has been deleted
preference.question = preference.resource['en-US'].matrix_string[componentIndex][1];
preference.response.selections.forEach((selection) => {
if (selection > 1000000) { // for tile choice, whose choice is stored as timestamp in the response
const index = preference.moment.matrix_number[componentIndex].indexOf(selection) - 5; // get the label index by splicing the first 5 index positions which are settings
if (index > -1) {
preference.answers.push(preference.moment.matrix_string[componentIndex][index]); // push the answer label to the array
}
} else { // for multiple choice, whose choice is stored as answer index (0, 1, 2, etc)
if (preference.moment.matrix_string[componentIndex].length > selection) {
preference.answers.push(preference.moment.matrix_string[componentIndex][selection]);
}
}
});
}
delete preference.resource;
delete preference.moment;
delete preference.response;
});
user.user_data.forEach((data) => {
if (data.question_id === 1570150762856667) {
user.user_data_1 = data.user_answer;
}
onboarding_processes.forEach((process) => {
componentIndex = process.resource.matrix_number[2].indexOf(data.question_id);
if (componentIndex > -1) { // it is possible the question has been deleted
data.question = process.matrix_string[componentIndex][0];
}
});
program.resource.matrix_number[0].forEach((componentId, i) => {
if (componentId === 50000) {
program.matrix_number[i].forEach((question_id, j) => {
if (question_id === data.question_id) {
if (j >= 11 && j <= 13) { // question id stored in location 11
user.title = data.user_answer;
} else if (j >= 14 && j <= 16) { // question id stored in location 14 - 16
user.bio = data.user_answer;
}
}
})
}
})
})
}
res.json(matched_users);
} catch (err) {
next({topic: 'System Error', title: 'computeMatchingUsers', err: err});
}
};
/**
* @desc Querying nearby users (obsolete as not using map at this point)
* @memberof moment
* @param req
* @param res
* @param next
* @returns {Promise<void>}
*/
exports.loadNearbyPeople = async (req, res, next) => {
try {
const itemsPerPage = 10;
const pageNum = req.query.page || 1;
const types = req.query.type && req.query.type.length ? req.query.type.split(' ') : [];
if (req.query.lat > 85) req.query.lat = 85;
if (req.query.lat < -85) req.query.lat = -85;
if (req.query.lng > 180) req.query.lng = 180;
if (req.query.lng < -180) req.query.lng = -180;
let query = {
kind: {$in: types},
metadata: {$regex: req.query.keyword || "", $options: 'i'},
};
if (req.query.lat && req.query.lng) {
query["location.geo"] = {
$nearSphere: {
$geometry: {
type: "Point", coordinates: [req.query.lng, req.query.lat]
}, $maxDistance: req.query.radius * 1609.34 // in meters
}
};
}
results = await GeoSpatial.find(query)
.skip(itemsPerPage * (pageNum - 1))
.limit(itemsPerPage);
res.json(results);
} catch (err) {
next({topic: 'System Error', title: 'loadPublicActivities', err: err});
}
};
/**
* @desc for querying activity by category
* @memberof moment
* @param req
* @param res
* @param next
* @returns {Promise<void>}
* last worked on by Calvin Ho on 3/20/2019
*/
exports.loadPublicActivityByCategory = async (req, res, next) => {
try {
let itemsPerPage = 10;
let pageNum = req.query.pageNum || 1;
query = { 'array_boolean.0': true, deletedAt: { $exists: false } };
if (req.params.category !== 'all') {
query.categories = req.params.category;
}
let moment = await Moment.find(query) //search public activity
.populate({
path: 'resource',
//select: 'matrix_number ' + req.query.language || 'en-US'
})
.populate({
path: 'categories'
})
.populate({
path: 'calendar'
})
.populate({
path: 'array_community',
select: 'name'
})
.populate({ //for Activity that has a plan
path: 'array_moment',
populate: [{
path: 'calendar'
}, {
path: 'resource'
}]
})
.populate({
path: 'author',
select: 'first_name last_name avatar'
})
.skip(itemsPerPage * ( pageNum - 1 ))
.sort('-updatedAt')
.limit(itemsPerPage);
res.json(moment);
} catch (err) {
console.log(err);
next({topic: 'System Error', title: 'loadPublicMomentByCategory', err: err});
}
};
/**
* @desc Load an Activity
* @memberof moment
* @param req
* @param res
* @param next
* @param req.params.momentId (required): the Activity of interest (type: ObjectId)
* @returns {Object} Object of the Moment
*/
exports.loadMoment = async (req, res, next) => {
try {
let moment;
if (req.params.momentId && req.params.momentId.length && req.params.momentId.length === 24) {
let query = { _id: req.params.momentId };
if (!['owner','admin','staff'].includes(req.user.role)) { // only show deleted Moment to Restvo staff
query.deletedAt = { $exists: false }
}
moment = await Moment.findOne(query)
.populate({
path: 'resource'
})
.populate({
path: 'categories'
})
.populate({
path: 'calendar'
})
.populate({
path: 'user_list_1',
select: 'first_name last_name avatar'
})
.populate({
path: 'user_list_2',
select: 'first_name last_name avatar'
})
.populate({
path: 'user_list_3',
select: 'first_name last_name avatar'
})
.populate({
path: 'array_community',
select: 'name'
})
.populate({ //for Activity that has a plan
path: 'array_moment',
populate: [{
path: 'calendar'
}, {
path: 'resource'
}]
})
.populate({ //for Activity that has parent and grandparent Programs
path: 'parent_programs',
select: 'user_list_2 parent_programs matrix_string resource categories',
populate: [{
path: 'parent_programs',
select: 'user_list_2 parent_programs matrix_string resource categories',
populate: {
path: 'resource',
select: 'en-US'
}
},
{
path: 'resource',
select: 'en-US'
}]
});
if (!moment) {
return res.status(401).json({})
}
// if user has organizer's privilege or super admin privileges, keep access tokens
if (moment && moment.user_list_2 && moment.user_list_2.map((c) => c._id).includes(req.user._id)) {
// keep access tokens
} else if (moment && moment.parent_programs && moment.parent_programs.length && moment.parent_programs[0].user_list_2 && moment.parent_programs[0].user_list_2.includes(req.user._id)) {
// keep access tokens
} else if (moment && moment.parent_programs && moment.parent_programs.length && moment.parent_programs[0].parent_programs && moment.parent_programs[0].parent_programs.length && moment.parent_programs[0].parent_programs[0] && moment.parent_programs[0].parent_programs[0].user_list_2 && moment.parent_programs[0].parent_programs[0].user_list_2.includes(req.user._id)) {
// keep access tokens
} else if (moment && moment.access_tokens) { // if no organizer's or super admin access, delete access tokens
moment.access_tokens = [];
}
if (moment && moment.resource.matrix_number.length === 3) { // for backward compatibility before implementing Tabs
moment.resource.matrix_number[3] = Array(moment.resource.matrix_number[0].length);
}
res.json(moment);
/*
log systemlog when user is loading an Activity
*/
if (moment && moment._id && moment.matrix_string && moment.array_boolean) {
let parent_programs = []; // array of parent program ids
if (moment.parent_programs && moment.parent_programs.length) {
moment.parent_programs.forEach((program) => {
parent_programs.push(program._id); // already populated so need to grab ids
if (program.parent_programs && program.parent_programs.length) {
parent_programs.push(...program.parent_programs.map((c) => c._id)); // already populated so need to grab ids
}
})
}
await SystemlogController.logActivity({
topic: 'Load Activity',
user: req.user._id
}, {
activity: moment._id,
parent_programs: parent_programs,
categories: moment.categories
});
}
} else {
return res.status(401).json({})
}
} catch (err){
next({topic: 'Mongodb Error', title: 'loadMoment', err: err});
}
};
/**
* @desc load an Activity without authentication
* @memberof moment
* @param req
* @param res
* @param next
* @param req.params.momentId (required): the Moment (type ObjectId) of interest
* @returns {Moment} an object of the Moment
*/
exports.loadPublicMoment = async (req, res, next) => {
try{
let moment = await Moment.findOne({ _id: req.params.momentId, deletedAt: { $exists: false } }, { author: 0, conversation: 0, user_list_1: 0, access_tokens: 0 })
.populate({
path: 'resource'
})
.populate({
path: 'categories'
})
.populate({
path: 'calendar'
})
.populate({
path: 'user_list_2',
select: 'first_name last_name avatar'
})
.populate({
path: 'user_list_3',
select: 'first_name last_name avatar'
})
.populate({
path: 'array_community',
select: 'name'
})
.populate({ //for Activity that has a plan
path: 'array_moment',
populate: [{
path: 'calendar'
}, {
path: 'resource'
}]
});
res.json(moment);
} catch (err){
next({topic: 'Mongodb Error', title: 'loadPublicMoment', err: err});
}
};
/**
* @desc Create Activity
* @memberof moment
* @param req
* @param res
* @param next
* @param req.body: an object of the Activity to be created
* @returns {Promise<void>}
*
* last updated by Calvin Ho on Nov 21
*/
exports.createMoment = async (req, res, next) => {
try {
const createdMoment = await exports.create(req.body, req.user, null);
res.json(createdMoment);
} catch (err){
next({topic: 'System Error', title: 'createMoment', err: err});
}
};
/**
* @desc Create Activity
* @memberof moment
* @param moment: the Activity object
* @param user: the User object
* @param optOutReason
* null - join as participant and organizer
* 'admin' - an organizer cloning a sample or an Activity for another leader, therefore no need to join as participant and organizer
* 'staff' - Restvo staff. same as 'admin' + append a timestamp to the cloned title
* @returns {Activity} an object of the created Activity
*
* last updated by Calvin Ho on 2/25/20
*/
exports.create = async (moment, user, optOutReason) => {
try {
// depopulate resource
if (moment.resource && moment.resource._id) {
moment.resource = moment.resource._id;
}
//console.log("body", moment);
// if creating an onboarding activity, check permission first
if (moment.program && !await ResourceController.checkMomentPermission(moment.program, 'user_list_2', null, 'create moment', user, null)) {
return res.status(401).json("create onboarding activity permission denied");
}
let createdCalendarEvent = {};
// in the case if calendar info is already populated, create a calendar document and send the _id to moment creation
if (moment.calendar && moment.calendar.title && moment.calendar.startDate) {
delete moment.calendar._id;
delete moment.calendar.users;
createdCalendarEvent = await Calendar.create(moment.calendar);
// if no opt out reason, or if an admin is adding a child Program to a Community, add user to calendar
if (!optOutReason || (optOutReason === 'admin' && moment.categories.includes('5c915475e172e4e64590e348'))) {
await Calendar.updateOne({_id: createdCalendarEvent._id}, {$addToSet: {users: mongoose.Types.ObjectId(user._id)}});
}
moment.calendar = createdCalendarEvent._id; // depopulate the calendar object
} else if (typeof moment.calendar === 'string') {
const result = await Calendar.findById(moment.calendar);
if (result) {
let cachedCalendarEvent = JSON.parse(JSON.stringify(result));
delete cachedCalendarEvent._id;
delete cachedCalendarEvent.users;
createdCalendarEvent = await Calendar.create(cachedCalendarEvent);
// if no opt out reason, or if an admin is adding a child Program to a Community, add user to calendar
if (!optOutReason || (optOutReason === 'admin' && moment.categories.includes('5c915475e172e4e64590e348'))) {
await Calendar.updateOne({_id: createdCalendarEvent._id}, {$addToSet: {users: mongoose.Types.ObjectId(user._id)}});
}
moment.calendar = createdCalendarEvent._id; //depopulate the calendar object
}
} else { // if calendar is not set up properly, remove the calendar field
delete moment.calendar;
}
if (optOutReason) { // if opting out, do not join conversations
//new conversations need to be created
const conversation = new Conversation({ type: "moment"});
newConversation = await conversation.save();
moment.conversation = mongoose.Types.ObjectId(newConversation._id);
const conversation_2 = new Conversation({ type: "moment"});
newConversation_2 = await conversation_2.save();
moment.conversation_2 = mongoose.Types.ObjectId(newConversation_2._id);
const conversation_3 = new Conversation({ type: "moment"});
newConversation_3 = await conversation_3.save();
moment.conversation_3 = mongoose.Types.ObjectId(newConversation_3._id);
} else { // if no opt out reason, user joins conversations
//new conversation need to be created with just the user in it and will be associated with the moment
const conversation = new Conversation({ participants: [user._id], userBadges: [{_id: user._id, unreadConversationBadgeCount: 0}], pushNotifications: [{_id: user._id, preference: 'all'}], type: "moment"});
newConversation = await conversation.save();
moment.conversation = mongoose.Types.ObjectId(newConversation._id);
const conversation_2 = new Conversation({ participants: [user._id], userBadges: [{_id: user._id, unreadConversationBadgeCount: 0}], pushNotifications: [{_id: user._id, preference: 'all'}], type: "moment"});
newConversation_2 = await conversation_2.save();
moment.conversation_2 = mongoose.Types.ObjectId(newConversation_2._id);
const conversation_3 = new Conversation({ participants: [user._id], userBadges: [{_id: user._id, unreadConversationBadgeCount: 0}], pushNotifications: [{_id: user._id, preference: 'all'}], type: "moment"});
newConversation_3 = await conversation_3.save();
moment.conversation_3 = mongoose.Types.ObjectId(newConversation_3._id);
}
moment.access_tokens = [randtoken.generate(10), randtoken.generate(10), randtoken.generate(10)]; // for access control
if (optOutReason) { // if opting out, reset all user lists
moment.user_list_1 = [];
moment.user_list_2 = [];
moment.user_list_3 = [];
} else { // if no opt out reason, add user to the organizer's lists
moment.user_list_1 = [];
moment.user_list_2 = [mongoose.Types.ObjectId(user._id)];
moment.user_list_3 = [];
}
let createdMoment = await Moment.create(moment);
//reset user list 1 (attendees) and list 2 (organizers) to be just the user, list 3 (leaders) to empty
if (createdCalendarEvent && createdCalendarEvent._id){
await Calendar.updateOne({ _id: createdCalendarEvent._id }, { $set: { moment: mongoose.Types.ObjectId(createdMoment._id) }});
}
//add moment ID into the newly created conversations
await Conversation.updateOne({ _id: newConversation._id }, { $set: { moment: mongoose.Types.ObjectId(createdMoment._id) }});
await Conversation.updateOne({ _id: newConversation_2._id }, { $set: { moment: mongoose.Types.ObjectId(createdMoment._id) }});
await Conversation.updateOne({ _id: newConversation_3._id }, { $set: { moment: mongoose.Types.ObjectId(createdMoment._id) }});
return createdMoment;
} catch (err) {
return SystemlogController.logFunctionError({topic: 'System Error', title: 'create failed', err: err});
}
};
/**
* @desc Clone Activity
* @memberof moment
* @param req
* @param res
* @param next
*
* 1) clone 1 level of associated Plans and their onboarding processes,
* 2) clone the Activity
*
* optOutReason:
* null - join as participant and organizer
* 'admin' - an admin cloning a sample or an Activity for another leader, therefore no need to join as participant and organizer
* 'staff' - Restvo staff. same as 'admin' + append a timestamp to the cloned title
*
* last updated by Calvin Ho on Dec 4
* @returns {Promise<void>}
*/
exports.cloneMoments = async (req, res, next) => {
try {
const optOutReason = req.body.optOutReason;
const moments = req.body.moments || [];
const clonedMoments = [];
const promises1 = moments.map( async (originalParent) => {
if (optOutReason === 'staff') { // if cloning by Restvo staff, add the time stamp after the name
originalParent.matrix_string[0][0] += ' ' + new Date().getFullYear().toString() + (new Date().getMonth() + 1).toString() + new Date().getDate().toString() + new Date().getSeconds().toString();
}
let cachedParent = JSON.parse(JSON.stringify(originalParent));
delete cachedParent._id;
delete cachedParent.updatedAt;
delete cachedParent.createdAt;
if (cachedParent.array_boolean && cachedParent.array_boolean.length > 1) {
cachedParent.array_boolean[0] = null; // turn off show in discovery
cachedParent.array_boolean[1] = null; // turn off share to Marketplace
}
let clonedParent = await exports.create(cachedParent, req.user, optOutReason);
// if the clonedParent is a Journey or a Relationship, also adopt its schedules
if (clonedParent && clonedParent.categories && clonedParent.categories.length && (clonedParent.categories.includes('5e9f46e1c8bf1a622fec69d5') || clonedParent.categories.includes('5dfdbb547b00ea76b75e5a70'))) {
// about permission: optOutOption === 'admin' should still be able to adopt the Plan even though the Program and the Relationship levels have no user_list_2 assigned yet. Since the permission checking should check for the super admin of a Community which is 2 levels up from the relationship level.
await ScheduleController.adopt({
operation: 'adopt plan',
startDate: originalParent.startDate ? new Date(originalParent.startDate) : new Date(), // convert to date object
planIds: [originalParent._id], // the journey or relationship that provide the schedules to be adopted
parent_programId: clonedParent._id // the journey or relationship that receives the schedules of the plan
}, req.user);
}
// clone the Moment's Children.
await exports.cloneChildren(originalParent, clonedParent, req.user, optOutReason);
// Note that it is not necessary to return the children (for updating the array_moments if it was modified), since the web app only uses the Parent Moment's name, assets for display purposes and not populated array_moments
clonedMoments.push(clonedParent);
});
await Promise.all(promises1);
res.json(clonedMoments);
} catch (err) {
next({topic: 'System Error', title: 'cloneMoments', err: err});
}
};
/**
* @desc Clone an Activity with its onboarding processes
* @memberof moment
* @param originalParent: the original Activity to be cloned
* @param clonedParent: the cloned Activity
* @param user: the User object
* @param optOutReason
* null - join as participant and organizer
* 'admin' - an organizer cloning a sample or an Activity for another leader, therefore no need to join as participant and organizer
* 'staff' - Restvo staff. same as 'admin' + append a timestamp to the cloned title
* @returns {Void}
* last updated by Calvin Ho on 1/9/20
*/
exports.cloneChildren = async (originalParent, clonedParent, user, optOutReason) => {
try {
const onboardingProcesses = await Moment.find({
program: originalParent._id,
deletedAt: { $exists: false }
});
// clone onboarding process, which is a type of child Activity
let promises = onboardingProcesses.map( async (onboardingProcess) => {
let cachedProcess = JSON.parse(JSON.stringify(onboardingProcess));
delete cachedProcess._id;
delete cachedProcess.updatedAt;
delete cachedProcess.createdAt;
if (cachedProcess.array_boolean && cachedProcess.array_boolean.length > 1) {
cachedProcess.array_boolean[1] = null; // turn off share to Marketplace
}
cachedProcess.program = clonedParent._id;
console.log("cloning process", cachedProcess.matrix_string[0][0]);
clonedProcess = await exports.create(cachedProcess, user, optOutReason);
//console.log("cloned process", clonedProcess, clonedParent._id);
});
await Promise.all(promises);
const childActivities = await Moment.find({
"array_boolean.1": true, // only clone child Activity that are marked as listed in Marketplace. i.e. Sample Program is cloned with only those relationships listed as 'shared in marketplace' and not the normal relationships
parent_programs: originalParent._id,
categories: { $nin: [ mongoose.Types.ObjectId('5c915476e172e4e64590e349'), mongoose.Types.ObjectId('5e1bbda67b00ea76b75e5a73') ]}, // not a Plan (excluding Plan which is an obsolete data type) or a Content
deletedAt: { $exists: false }
});
promises = childActivities.map( async (childActivity) => {
let cachedChildActivity = JSON.parse(JSON.stringify(childActivity));
delete cachedChildActivity._id;
delete cachedChildActivity.updatedAt;
delete cachedChildActivity.createdAt;
// if the child Activity is a Program, turn off share to Marketplace. (i.e. relationship can keep its 'share in marketplace' status)
if (cachedChildActivity.categories.includes('5c915475e172e4e64590e348') && cachedChildActivity.array_boolean && cachedChildActivity.array_boolean.length > 1) {
cachedChildActivity.array_boolean[1] = null; // turn off share to Marketplace
}
cachedChildActivity.parent_programs = [clonedParent._id];
console.log("cloning Child Activity", cachedChildActivity.matrix_string[0][0]);
clonedChildActivity = await exports.create(cachedChildActivity, user, optOutReason);
// only add the cloned program to the list of Referenced Program if it is hosted by a Community
if (clonedParent.categories.includes('5c915324e172e4e64590e346') && clonedChildActivity.categories.includes('5c915475e172e4e64590e348')) {
//console.log("cloned Activity", clonedChildActivity, clonedParent._id);
await Moment.updateOne({ _id: clonedParent._id }, { $addToSet: { array_moments: clonedChildActivity._id }} );
}
// if the cloned activity is a sample Journey or sample Relationship, adopt its schedules
if (clonedChildActivity && clonedChildActivity.categories && clonedChildActivity.categories.length && (clonedChildActivity.categories.includes('5e9f46e1c8bf1a622fec69d5') || clonedChildActivity.categories.includes('5dfdbb547b00ea76b75e5a70'))) {
// about permission: optOutOption === 'admin' should still be able to adopt the Plan even though the Program and the Relationship levels have no user_list_2 assigned yet. Since the permission checking should check for the super admin of a Community which is 2 levels up from the relationship level.
await ScheduleController.adopt({
operation: 'adopt plan',
planIds: [childActivity._id], // the journey or relationship that provide the schedules to be adopted
parent_programId: clonedChildActivity._id // the journey or relationship that receives the schedules of the plan
}, user);
}
await exports.cloneChildren(childActivity, clonedChildActivity, user, optOutReason);
});
await Promise.all(promises);
} catch (err) {
return SystemlogController.logFunctionError({topic: 'System Error', title: 'clone failed', err: err});
}
};
/**
* @desc Update an Activity
* @memberof moment
* @param req
* @param req.body: Activity to be updated
* @param res
* @param next
* @returns {String} "success"
* // last updated by Calvin Ho on Oct 6
*/
exports.updateMoment = async (req, res, next) => {
try {
// if updating onboarding activity, check program organizer permission
if (req.body.program && !await ResourceController.checkMomentPermission(req.body.program, 'user_list_2', null, 'update moment', req.user, null)) {
return res.status(401).json({msg: 'update onboarding activity permission denied'});
// if updating Restvo activity, check organizer's permission
} else if (!req.body.program && !await ResourceController.checkMomentPermission(req.body._id, 'user_list_2', null, 'update moment', req.user, null)) {
return res.status(401).json({msg: 'update activity permission denied'});
}
//console.log("Moment ID is: " + req.body._id);
if (req.body.calendar && req.body.calendar.title && req.body.calendar.startDate) {
req.body.calendar.moment = req.body._id; // store the Moment _id
if (req.body.matrix_string && req.body.matrix_string.length && req.body.matrix_string[0] && req.body.matrix_string[0].length && req.body.matrix_string[0][0]) {
req.body.calendar.title = req.body.matrix_string[0][0];
}
if (!req.body.calendar._id) {
if (req.body.calendar.users && !req.body.calendar.users.length) {
delete req.body.calendar.users;
}
createdCalendarEvent = await Calendar.create(req.body.calendar);
await Calendar.updateOne({_id: createdCalendarEvent._id}, { $addToSet: { users: req.user._id }});
calendarId = createdCalendarEvent._id;
} else {
calendarId = req.body.calendar._id;
await Calendar.updateOne({ _id: calendarId }, {$set: req.body.calendar });
}
if (req.body.calendar.options && req.body.calendar.options.reminders && req.body.calendar.options.reminders.length === 0){
//if there is no reminder, unset the first and second reminder field
await Calendar.updateOne({ _id: calendarId }, {$unset: { "options.firstReminderMinutes": "", "options.secondReminderMinutes": "" }});
}
else if (req.body.calendar.options && req.body.calendar.options.reminders && req.body.calendar.options.reminders.length === 1) {
await Calendar.updateOne({ _id: calendarId }, {$unset: { "options.secondReminderMinutes": "" }});
}
req.body.calendar = calendarId;
}
req.body.updatedAt = new Date(); // Moment model timestamp is disabled. There we need to manually update the timestamp. This is performed only when updating moment eg. Event, not when users RSVP in updateMomentUserLists().
if (!req.body.access_tokens || req.body.access_tokens.length < 3) { // if access tokens is missing, add it to the backend
req.body.access_tokens = [randtoken.generate(10), randtoken.generate(10), randtoken.generate(10)];
}
// refresh chat updatedAt so it refreshes the list of chat view for user
const moment = await Moment.findOneAndUpdate({_id: req.body._id}, {$set: req.body});
if (moment.conversation) {
await Conversation.updateOne({_id: moment.conversation}, {$set:{updatedAt: new Date()}});
}
if (moment.conversation_2) {
await Conversation.updateOne({_id: moment.conversation_2}, {$set:{updatedAt: new Date()}});
}
if (moment.conversation_3) {
await Conversation.updateOne({_id: moment.conversation_3}, {$set:{updatedAt: new Date()}});
}
res.json("success");
} catch (err){
next({topic: 'System Error', title: 'updateMoment', err: err});
}
};
/**
* @desc Delete an Activity
* @memberof moment
* @param req
* @param req.params.momentId: the Activity (type: ObjectId) of interest
* @param res
* @param next
* @returns {Promise<*>}
*/
// last updated by Calvin Ho on Oct 6
exports.deleteMoment = async (req, res, next) => {
try {
if (req.params.momentId !== '5d5785b462489003817fee18') { // safeguard against deleting Restvo Community
const moment = await Moment.findById(req.params.momentId);
// if deleting an onboarding activity, check program organizer permission
if (moment.program && !await ResourceController.checkMomentPermission(moment.program, 'user_list_2', null, 'delete moment', req.user, null)) {
return res.status(401).json("delete onboarding activity permission denied");
// if deleting a moment, check permission
} else if (!moment.program && !await ResourceController.checkMomentPermission(moment._id, 'user_list_2', null, 'delete moment', req.user, null)) {
return res.status(401).json("delete activity permission denied");
}
console.log("about to delete Moment w/ ID: " + req.params.momentId);
if (['owner','admin','staff'].includes(req.user.role) && !req.query.archive) { //only a Restvo staff has permission to delete the event
await Calendar.deleteOne({_id: moment.calendar});
await Conversation.deleteOne({_id: moment.conversation});
await Conversation.deleteOne({_id: moment.conversation_2});
await Conversation.deleteOne({_id: moment.conversation_3});
await GeoSpatial.deleteMany({ moment: req.params.momentId });
// if it has onboarding activities, delete them and the Geospatial records
const onboarding_processes = await Moment.find({ program: req.params.momentId }, { _id: 1 });
if (onboarding_processes && onboarding_processes.length) {
await GeoSpatial.deleteMany({ moment: { $in: onboarding_processes.map((c) => c._id) } });
}
await Moment.deleteMany({ program: req.params.momentId });
// if Content, remove depending Content Calendar docs, remove Content from schedules, and remove responses to Content Calendars
await Calendar.deleteMany({ moment: req.params.momentId }); // remove depending Content Calendar docs
await Calendar.updateMany({ child_moments: req.params.momentId }, { $pull: { child_moments: req.params.momentId }}); // remove Content from schedules
await Response.deleteMany({ relationship: req.params.momentId });
// if it has schedules (i.e. it is a Relationship), delete the schedules and their content calendar items
const schedules = await Calendar.find({ parent_moments: req.params.momentId }, { _id: 1 });
if (schedules && schedules.length) {
await Calendar.deleteMany({ parent_moments: req.params.momentId });
await Calendar.deleteMany({ schedule: { $in: schedules.map((c) => c._id) } });
}
// delete all responses to the Relationship (To-Dos, Goals, text answer etc)
await Response.deleteMany({ moment: req.params.momentId });
await Moment.deleteOne({ _id: req.params.momentId });
} else { // an organizer, or staff with archive param. then archive Moment and clear participant lists
await Moment.updateOne({ _id: req.params.momentId }, {
$set: {
deletedAt: new Date()
}
});
// remove users from content calendar of relationships
await Calendar.updateMany({ moment: req.params.momentId }, { $unset: { users: '' }}); // remove depending Content Calendar docs
}
}
res.json("success");
} catch (err){
next({topic: 'System Error', title: 'deleteMoment', err: err});
}
};
/**
* @desc update an Activity's users lists
* @memberof moment
* @param req
* @param res
* @param next
*
* @param req.body.operation: operation
* @param req.body.user_lists: array of list names: ["user_list_1", "user_list_2"] etc.
* @param req.body.users: array of user IDs or null to be added to the user lists
* if null, req.body.conversationId: participants in a chat room to be added to the user lists
* if null, req.body.conversations: list of conversations whose participants to be added to the user lists
* @param req.body.momentId: activity ID
* @param req.body.calendarId: calendar ID
*
* @returns {Promise<void>}
*
* Modified by Calvin Ho: 9/5/2019
*/
exports.updateMomentUserLists = async (req, res, next) => {
try {
console.log("input body is: " + JSON.stringify(req.body));
let userObjectIds = [];
let conversation = {};
//process req.body.users
if(req.body.users && req.body.users.length) {
userObjectIds = req.body.users.map((c) => mongoose.Types.ObjectId(c));
} else if (req.body.conversationId){
conversation = await Conversation.findById(req.body.conversationId).select('participants');
userObjectIds = conversation.participants;
} else if (req.body.conversations) {
const promises = req.body.conversations.map( async (conversationId) => {
conversation = await Conversation.findById(conversationId).select('participants');
userObjectIds.push(...conversation.participants);
return conversation.participants;
});
await Promise.all(promises);
console.log("result", userObjectIds);
}
if (userObjectIds && userObjectIds.length){
if (req.body.operation === 'add to lists and calendar') { //add users to lists
let result = 'success';
const promises = req.body.user_lists.map( async (user_list) => {
if (await ResourceController.checkMomentPermission(req.body.momentId, user_list, userObjectIds, 'add to list', req.user, req.query.token)) {
let updateObject = {};
updateObject[user_list] = { $each: userObjectIds };
let moment = await Moment.findOneAndUpdate({_id: req.body.momentId}, {$addToSet: updateObject})
.populate('conversation conversation_2 conversation_3')
.populate({
path: 'user_list_2',
select: 'deviceTokens snoozed pushNotifySystemMessages'
});
if (user_list === 'user_list_3') { // if adding a leader, also add her to the Restvo community
await Moment.updateOne({_id: '5d5785b462489003817fee18'}, {$addToSet: updateObject})
}
// all user types will be joining the moment's conversation (mandatory)
if (moment && moment.conversation) {
// build the object arrays to add multiple user to the conversation at once
participantObjectList = [];
userBadgesObjectList = [];
pushNotificationsObjectList = [];
userObjectIds.forEach((userId) => {
if (!moment.conversation.participants.includes(userId)) {
participantObjectList.push(userId);
userBadgesObjectList.push({ _id : userId, unreadConversationBadgeCount: 0 });
pushNotificationsObjectList.push({ _id : userId, preference: 'leaders-only' });
}
});
if (participantObjectList.length) {
await Conversation.updateOne({ _id : moment.conversation },
{ $addToSet: {
participants : { $each: participantObjectList },
userBadges : { $each: userBadgesObjectList },
pushNotifications : { $each: pushNotificationsObjectList }
}});
await Conversation.updateOne({ _id : moment.conversation }, // update the updatedAt timestamp
{ $set: { updatedAt: new Date() }});
}
}
// if joining the organizer's list, add to the organizer's list
if (user_list === 'user_list_2' && moment.conversation_2) {
// build the object arrays to add multiple user to the conversation at once
participantObjectList = [];
userBadgesObjectList = [];
pushNotificationsObjectList = [];
userObjectIds.forEach((userId) => {
if (!moment.conversation_2.participants.includes(userId)) {
participantObjectList.push(userId);
userBadgesObjectList.push({ _id : userId, unreadConversationBadgeCount: 0 });
pushNotificationsObjectList.push({ _id : userId, preference: 'leaders-only' });
}
});
if (participantObjectList.length) {
await Conversation.updateOne({_id: moment.conversation_2},
{
$addToSet: {
participants: {$each: participantObjectList},
userBadges: {$each: userBadgesObjectList},
pushNotifications: {$each: pushNotificationsObjectList}
}
});
await Conversation.updateOne({_id: moment.conversation_2}, // update the updatedAt timestamp
{$set: {updatedAt: new Date()}});
}
}
// if joining the organizer's list, or joining the leader's list, add user to the leader's conversation
if ((user_list === 'user_list_2' && moment.conversation_2) || (user_list === 'user_list_3' && moment.conversation_3)) {
// build the object arrays to add multiple user to the conversation at once
participantObjectList = [];
userBadgesObjectList = [];
pushNotificationsObjectList = [];
userObjectIds.forEach((userId) => {
if (!moment.conversation_3.participants.includes(userId)) {
participantObjectList.push(userId);
userBadgesObjectList.push({ _id : userId, unreadConversationBadgeCount: 0 });
pushNotificationsObjectList.push({ _id : userId, preference: 'leaders-only' });
}
});
if (participantObjectList.length) {
await Conversation.updateOne({_id: moment.conversation_3},
{
$addToSet: {
participants: {$each: participantObjectList},
userBadges: {$each: userBadgesObjectList},
pushNotifications: {$each: pushNotificationsObjectList}
}
});
await Conversation.updateOne({_id: moment.conversation_3}, // update the updatedAt timestamp
{$set: {updatedAt: new Date()}});
}
}
// in a relationship, process all schedules' content calendar items (either add or remove users from the calendars)
await exports.touchSchedulesContentCalendarItems(moment._id, req.body.operation, userObjectIds);
// notify organizers if a user actively joins this moment (except Restvo Mentor)
if (moment._id.toString() !== '5d5785b462489003817fee18' && userObjectIds.length && userObjectIds.length === 1 && userObjectIds[0].toString() === req.user._id.toString()) {
// prepare the data object
data = {
title: moment.matrix_string[0][0],
body: req.user.first_name + ' ' + req.user.last_name + ' has joined as ' + (user_list === 'user_list_1' ? 'Participant.' : (user_list === 'user_list_2' ? 'Organizer.' : 'Leader.')),
custom: {
momentId: moment._id,
//featureType: moment.resource.field,
notId: moment._id,
BigTextStyle: true,
page: 'Moment',
createAt: new Date(),
silence: false
},
badge: 0,
topic: "com.restvo.app"
};
moment.user_list_2.forEach((organizer) => {
userSnoozeOff = true;
if (organizer.snoozed) {
if (organizer.snoozed.state === 'on' || (organizer.snoozed.state === 'timed' && new Date().getTime() < new Date(organizer.snoozed.expiredAt).getTime())) {
userSnoozeOff = false;
}
}
if (userSnoozeOff && organizer.pushNotifySystemMessages === 'instant') {
push.send(organizer.deviceTokens, data, function (err, result) {
if (err) {
console.log(err);
} else {
console.log(result[0].message);
UserData.removeDeviceTokens(organizer, result[0].message);
}
});
}
});
// notify Calvin when someone joins an Activity, only if Calvin is not already notified as an admin
if (moment.user_list_2.find((c) => c._id !== '596d4fbfdd53ac40156db4d5')) {
let owner = await User.findOne({_id: '596d4fbfdd53ac40156db4d5'}).select('deviceTokens appName unreadBadgeCount pushNotifySystemMessages snoozed');
userSnoozeOff = true;
if (owner.snoozed) {
if (owner.snoozed.state === 'on' || (owner.snoozed.state === 'timed' && new Date().getTime() < new Date(owner.snoozed.expiredAt).getTime())) {
userSnoozeOff = false;
}
}
if (userSnoozeOff && owner.pushNotifySystemMessages === 'instant') {
push.send(owner.deviceTokens, data, function (err, result) {
if (err) {
console.log(err);
} else {
console.log(result[0].message);
UserData.removeDeviceTokens(owner, result[0].message);
}
});
}
}
}
} else {
result = 'permission denied';
}
});
await Promise.all(promises);
if (req.body.calendarId){
await Calendar.updateOne({_id: req.body.calendarId}, {$addToSet: { users: { $each: userObjectIds } }});
}
res.json(result);
} else if (req.body.operation === 'remove from lists') { //remove users from lists
let result = 'success';
const promises = req.body.user_lists.map( async (user_list) => {
if (await ResourceController.checkMomentPermission(req.body.momentId, user_list, userObjectIds, 'remove from list', req.user, req.query.token)) {
let updateObject = {};
updateObject[user_list] = userObjectIds; // { 'user_list_3': [ ObjectId("..."), ObjectId("...")... ] }
moment = await Moment.findOneAndUpdate({_id: req.body.momentId}, {$pullAll: updateObject}); // intentionally not use {new: true} to keep the old record for processing the rest of the request
const promises_2 = userObjectIds.map( async (userId) => {
let organizerOnly = false;
let leaderOnly = false;
let participantOnly = !moment.user_list_2.includes(userId) && !moment.user_list_3.includes(userId);
let organizerNotLeader = false;
// removing from the organizer chat
if (user_list === 'user_list_2' && moment.user_list_2.includes(userId) && moment.conversation_2) {
await Conversation.updateOne({ _id : moment.conversation_2 },
{ $pull: { participants : userId,
userBadges : { _id : userId },
pushNotifications : { _id : userId }}});
await Conversation.updateOne({_id: moment.conversation_2 }, // update the updatedAt timestamp
{ $set: {updatedAt: new Date()}} );
organizerOnly = !moment.user_list_1.includes(userId) && !moment.user_list_3.includes(userId);
organizerNotLeader = !moment.user_list_3.includes(userId);
}
// removing from the leader chat
if (moment.conversation_3 && ((user_list === 'user_list_3' && moment.user_list_3.includes(userId)) || organizerNotLeader)) {
await Conversation.updateOne({ _id : moment.conversation_3 },
{ $pull: { participants : userId,
userBadges : { _id : userId },
pushNotifications : { _id : userId }}});
await Conversation.updateOne({_id: moment.conversation_3 }, // update the updatedAt timestamp
{ $set: {updatedAt: new Date()}} );
leaderOnly = !moment.user_list_1.includes(userId) && !moment.user_list_2.includes(userId);
}
// removing from the participant chat
if ((user_list === 'user_list_1' && participantOnly) || organizerOnly || leaderOnly) {
await Conversation.updateOne({ _id : moment.conversation },
{ $pull: { participants : userId,
userBadges : { _id : userId },
pushNotifications : { _id : userId }}});
await Conversation.updateOne({_id: moment.conversation }, // update the updatedAt timestamp
{ $set: {updatedAt: new Date()}} );
}
});
await Promise.all(promises_2);
// in a relationship, process all schedules' content calendar items (either add or remove users from the calendars)
await exports.touchSchedulesContentCalendarItems(moment._id, req.body.operation, userObjectIds);
} else {
result = 'permission denied';
}
});
await Promise.all(promises);
res.json(result);
} else if (req.body.operation === 'add to calendar') { //add users to lists
if (req.body.calendarId){
const promises = userObjectIds.map( async (userObjectId) => {
await Calendar.updateOne({_id: req.body.calendarId}, {$addToSet: { users: userObjectId }});
});
await Promise.all(promises);
}
res.json("success");
} else if (req.body.operation === 'remove from calendar') { //add users to lists
if (req.body.calendarId){
await Calendar.updateOne({ _id: req.body.calendarId }, { $pullAll: { users : userObjectIds } });
}
res.json("success");
} else {
res.json("wrong operation");
}
} else {
res.json("no user provided");
console.log("no user provided");
}
} catch (err){
next({topic: 'System Error', title: 'updateMomentUserLists', err: err});
}
};
/**
* @desc add or remove users in the Activity's schedules' content calendar items
* @memberof moment
* @param momentId: The Relationship (type: ObjectId) which hosts the Schedules
* @param operation: Operation (type: String)
* @param userObjectIds: an array of Users (type: [ObjectId])
* @returns {Void}
*
* updated by Calvin Ho 1/13/20
*/
exports.touchSchedulesContentCalendarItems = async (momentId, operation, userObjectIds) => {
const schedules = await Calendar.find({ parent_moments: momentId });
const promises = schedules.map( async (schedule) => {
if (operation === 'add to lists and calendar') {
await Calendar.updateMany({ schedule: schedule._id }, { $addToSet: { users: { $each: userObjectIds } }});
} else if (operation === 'remove from lists') {
await Calendar.updateMany({ schedule: schedule._id }, { $pullAll: { users: userObjectIds } });
}
});
await Promise.all(promises);
};
/**
* @desc Submit various types of responses
* @memberof moment
* @example User's Response to Old Restvo Features (response class id = null)
* @param req
* @param req.body.moment:
* @param res
* @param next
*
* @returns {Promise<void>}
*
* @example User's Response to an Activity or Content Calendar's Interactables (response class id = null)
*
* @param req
* @param req.body.moment: the Activity (type: ObjectId)
* @param req.body.relationship (optional): the relationship context (type: ObjectId)
* @param req.body.calendar (optional): the calendar context (type: ObjectId)
* @param res
* @param next
*
* @returns {Promise<void>}
*
* @example the user's multiple choice responses to an Activity's list of questions
*
* @param req
* @param req.body.moment: the Activity (type: ObjectId)
* @param res
* @param next
*
* @returns {Promise<void>}
*
* @example Parent/Child Relationship Record for Onboarding Processes (response class id = null)
*
* @param req
* @param req.body.class: null
* @param req.body.dependentMomentId: the parent Onboarding process (type: ObjectId)
* @param res
* @param next
* @returns {String} 'success'
*
* @example an Onboarding Process choices of a parent onboarding process's question. Only responding to one question is allowed per record
*
* @param req
* @param req.body.class: response class (type: Number)
* @param req.body.dependentMomentId: the Onboarding process (type: ObjectId)
* @param res
* @param next
* @returns {String} 'success'
*
* @example Activity's Matching Config (50000) Settings (response class id = 50000)
* an User Matching setting that defines which Onboarding Processes, which question in it, and the logical query operator it uses when matching users' responses
*
* @param req
* @param req.body.class: response class (type: Number)
* @param req.body.dependentMomentId: the Onboarding process/question of interest (type: ObjectId)
* @param res
* @param next
*
* @returns {Promise<void>}
*/
exports.submitResponse = async (req, res, next) => {
try {
if (req.body.class && req.body.dependentMomentId) { // 1.3.6 (30)+. Added the class field in the body payload
await Response.deleteMany({
"array_number.0": req.body.class,
dependent_moment: req.body.dependentMomentId
});
if (req.body.responses) {
const promises = req.body.responses.map( async (response) => {
await Response.create(response);
});
await Promise.all(promises);
}
res.json("success");
} else if (!req.body.class && req.body.dependentMomentId) { // obsolete 1.3.6 (30)+
response = await Response.findOneAndUpdate({
moment: req.body.moment,
dependent_moment: req.body.dependentMomentId
}, {
$set: req.body
}, {
upsert: true,
new: true
});
// clean up old responses
responses = await Response.deleteMany({
_id: { $ne: response._id },
dependent_moment: req.body.dependentMomentId
});
res.json(response);
} else { // this is for user submitting the responses to MC and other interactables
let query = {
moment: req.body.moment,
user: req.user._id
};
// it filters based on relationship context, if provided. see models/response.js for definition
if (req.body.relationship) {
query.relationship = req.body.relationship;
} else {
query.relationship = { $exists: false };
}
// it filters based on content calendar context, if provided. see models/response.js for definition
if (req.body.calendar) {
query.calendar = req.body.calendar;
} else {
query.calendar = { $exists: false };
}
req.body.user = req.user._id; // ensure author is set to the current user
delete req.body._id; // the _id is not needed since the doc is already queried
if (req.query.version === '1') {
const response = await Response.findOneAndUpdate(query, { $set: req.body }, {
upsert: true,
new: true
});
if (response && response._id) {
res.json({ status: 'success', _id: response._id });
} else {
res.json({ status: 'failed' });
}
} else {
const response = await Response.findOneAndUpdate(query, { $set: req.body }, {
upsert: true,
new: true
})
.populate({
path: 'user',
select: 'first_name last_name avatar'
});
res.json(response);
}
}
} catch (err){
next({topic: 'System Error', title: 'submitResponse', err: err});
}
};
exports.deleteResponse = async (req, res, next) => {
try {
await Response.deleteMany({ _id: req.params.responseId });
res.json('success');
} catch (err){
next({topic: 'System Error', title: 'deleteResponse', err: err});
}
};
/**
* @desc Load Responses by Activity Id
* @memberof moment
* @param req
* @param req.params.momentId: Activity of interest (type: ObjectId)
* @param res
* @param next
* @returns {Array} an array of responses
* updated by Calvin Ho on 2/12/20
*/
exports.findResponsesByMomentId = async (req, res, next) => {
try {
// first check for organizer's access and if containing public interactable (30000 - 39999)
const moment = await Moment.findById(req.params.momentId)
.populate('resource')
.select('resource matrix_number categories'); // load the organizer's list
let hasOrganizerLeaderAccess = false;
let hasParticipantAccess = false;
// check if it is a Content. If so, use the Relationship Object ID to check for Community or Program level admin access and Relationship level leader's access
if (req.query.relationshipId) {
hasOrganizerLeaderAccess = await ResourceController.checkMomentPermission(req.query.relationshipId, 'user_list_3', null, null, req.user, null);//moment.user_list_2.indexOf(req.user._id) > -1;
hasParticipantAccess = await ResourceController.checkMomentPermission(req.query.relationshipId, 'user_list_1', null, null, req.user, null);//moment.user_list_2.indexOf(req.user._id) > -1;
} else { // for onboarding and other Activity responses, check for organizer's access
hasOrganizerLeaderAccess = await ResourceController.checkMomentPermission(req.params.momentId, 'user_list_2', null, null, req.user, null);//moment.user_list_2.indexOf(req.user._id) > -1;
hasParticipantAccess = await ResourceController.checkMomentPermission(req.params.momentId, 'user_list_1', null, null, req.user, null);//moment.user_list_2.indexOf(req.user._id) > -1;
}
let hasPublicOrCollaborativeInteractable = false;
if (moment && moment.resource && moment.resource.matrix_number && moment.resource.matrix_number.length) {
hasPublicOrCollaborativeInteractable = moment.resource.matrix_number[0].find((c) => (c >= 10210 && c <= 10220) || (c >= 30000 && c <= 39999)); // moment that has a schedule or a poll
moment.resource.matrix_number[0].forEach((item, componentIndex) => {
if (item === 40010) {
hasPublicOrCollaborativeInteractable = (moment.matrix_number[componentIndex].length > 1 && moment.matrix_number[componentIndex][1]) || hasPublicOrCollaborativeInteractable; // true or keep the same value
}
});
}
// build the Mongodb query
let query = { moment: req.params.momentId };
// if it is not a private Note, and it has parent or current organizer's or leader's permission, or has public or collaborative interactable
if (hasOrganizerLeaderAccess || (hasParticipantAccess && hasPublicOrCollaborativeInteractable)) {
query.user = { $exists: true };
} else { // only user's own response is returned
query.user = req.user._id;
}
// filter if it is in the context of a relationship
if (req.query.relationshipId) {
query.relationship = req.query.relationshipId;
}
// filter if it is in the context of a content calendar
if (req.query.calendarId) {
query.calendar = req.query.calendarId;
}
const responses = await Response.find(query)
.populate({
path: 'user',
select: 'first_name last_name avatar'
})
.sort('updatedAt');
if (!req.query.version) {
return res.status(200).json(responses);
} else if (req.query.version === '1') {
return res.status(200).json({ responses: responses, hasOrganizerLeaderAccess: hasOrganizerLeaderAccess });
}
} catch (err) {
next({topic: 'Mongodb Error', title: 'findResponsesByMomentId', err: err});
}
};
/**
* @desc Load Responses by an array of Activity Ids
* @memberof moment
* @param req
* @param req.params.momentId: Activity of interest (type: ObjectId)
* @param res
* @param next
* @returns {Array} an array of responses
* updated by Calvin Ho on 2/12/20
*/
exports.findResponsesByMomentIds = async (req, res, next) => {
try {
let allResponses = [];
async.each(req.body.array, function (momentId, callback) {
Response.find({
moment: momentId,
user: { $exists: true }
}).exec(function (err, responses) {
if (err) {
return next({topic: 'Mongodb Error', title: 'findResponsesByMomentIds', err: err});
}
for (response of responses){
allResponses.push(response);
}
callback();
});
}, (err) => {
if (err) res.send(err);
return res.json(allResponses);
});
} catch (err){
next({topic: 'System Error', title: 'findResponsesByMomentIds', err: err});
}
};
/**
* @desc send Push Notification messages about Calendar Reminders
* @memberof moment
* @param io: socket io object
* @param calendar: the calendar object
* @param title: title of the Push message
* @param body: the body of the Push message
* @returns {Void}
*/
exports.sendRefreshNotification = async (io, calendar, title, body) => {
try {
let moment = calendar.moment;
let data = {
title: title,
body: body,
custom: {
momentId: calendar.relationship ? calendar.relationship._id : moment._id, // if it is a content calendar item, open the relationship page. Otherwise, open the Moment page
featureType: moment.resource.field,
notId: moment._id,
BigTextStyle: true,
page: 'Moment',
createAt: new Date(),
silence: false
},
badge: 0,
topic: ""
};
// this is for refreshing the Poll when it is due. TODO: might not be needed anymore
socketData = {
action: 'refresh moment',
momentId: moment._id,
featureType: moment.resource.field,
title: data.title,
body: data.body,
};
io.of('/').to(moment.conversation).emit('refresh status', moment.conversation, socketData);
calendar.users.forEach(function (user) {
//Determine if we need to send a push notification
let userSnoozeOn;
if (user.snoozed) {
if (user.snoozed.state === 'on' || (user.snoozed.state === 'timed' && new Date().getTime() < new Date(user.snoozed.expiredAt).getTime())) {
userSnoozeOn = true;
}
}
if (userSnoozeOn || (user.pushNotifySystemMessages !== 'instant')) {
console.log("Push Notification disabled!");
data.custom.silence = true;
delete data.title;
delete data.body;
}
data.topic = user.appName;
data.badge = user.unreadBadgeCount;
push.send(user.deviceTokens, data, function (err, result) {
if (err) {
console.log(err);
} else {
console.log(result[0].message);
UserData.removeDeviceTokens(user, result[0].message);
}
});
});
await Calendar.updateOne({_id: calendar._id, //set reminderSent to true
"options.reminders": {
$elemMatch: {
remindAt: {
$gte: new Date(new Date().getTime() - 30 * 60 * 1000), //time after 30 min ago to safeguard against accidental server downtime
$lte: new Date() //time earlier than now
},
reminderSent: false
}
}},
{
$set: {"options.reminders.$.reminderSent" : true}
});
} catch (err) {
SystemlogController.logFunctionError({topic: 'Mongodb Error', title: 'sendRefreshNotification', err: err});
}
};
/** Obsolete. For future reference only
* @func loadPublicActivities
* @memberof moment
* @param req
* @param res
* @param next
* @returns {Promise<void>}
*/
exports.loadPublicActivities = async (req, res, next) => { // api for querying live activities
try {
const itemsPerPage = 10;
const pageNum = req.query.page || 1;
if (req.query.lat > 85) req.query.lat = 85;
if (req.query.lat < -85) req.query.lat = -85;
if (req.query.lng > 180) req.query.lng = 180;
if (req.query.lng < -180) req.query.lng = -180;
let query = {
kind: {$in: req.query.type.split(' ')},
metadata: {$regex: req.query.keyword || "", $options: 'i'},
};
if (req.query.lat && req.query.lng) {
query["location.geo"] = {
$nearSphere: {
$geometry: {
type: "Point", coordinates: [req.query.lng, req.query.lat]
}, $maxDistance: req.query.radius * 1609.34 // in meters
}
};
}
if (req.query.hasOwnProperty('time') && req.query.time === '0') { // query live activities
query.startDate = {
$lte: new Date()
};
query.endDate = {
$gte: new Date()
};
} else if (req.query.hasOwnProperty('time') && Math.abs(req.query.time) > 0) { // query upcoming or past activities, elapsed time in seconds
query.startDate = {
$gte: new Date(new Date().getTime() + req.query.time*1000)
};
query.endDate = {
$gte: new Date(new Date().getTime() + req.query.time*1000)
};
} else { // if not queried, show all activities except expired activities
query.endDate = {
$gte: new Date()
};
}
results = await GeoSpatial.find(query)
.populate({
path: 'moment',
populate: [{
path: 'calendar'
}, {
path: 'resource'
}, {
path: 'categories'
}, {
path: 'array_community',
select: 'name'
}, {
path: 'author',
select: 'first_name last_name avatar'
}]
})
.skip(itemsPerPage * (pageNum - 1))
.limit(itemsPerPage);
res.json(results);
} catch (err) {
next({topic: 'System Error', title: 'loadPublicActivities', err: err});
}
};