WIP(multiassign): resume shared completion implementation

This commit is contained in:
SabreCat 2021-11-11 16:32:30 -06:00
parent 1f81e1971b
commit fa99458ca4
7 changed files with 80 additions and 120 deletions

View file

@ -454,19 +454,19 @@
class="col-10 mb-1" class="col-10 mb-1"
>{{ $t('assignedTo') }}</label> >{{ $t('assignedTo') }}</label>
<a <a
v-if="assignedMember" v-if="assignedMembers.length > 0"
class="col-2 text-right mt-1" class="col-2 text-right mt-1"
@click="toggleAssignment(assignedMember._id)"
> >
{{ $t('clear') }} {{ $t('clear') }}
</a> </a>
<div class="col-12"> <div class="col-12">
<select-single <select-multi
:key="assignedMember" ref="assignMembers"
:all-items="membersNameAndId" :all-items="membersNameAndId"
:empty-message="$t('unassigned')" :empty-message="$t('unassigned')"
:pill-invert="true"
:search-placeholder="$t('chooseTeamMember')" :search-placeholder="$t('chooseTeamMember')"
:selected-item="assignedMember" :selected-items="assignedMembers"
@toggle="toggleAssignment($event)" @toggle="toggleAssignment($event)"
/> />
</div> </div>
@ -1056,11 +1056,9 @@ import goldIcon from '@/assets/svg/gold.svg';
import chevronIcon from '@/assets/svg/chevron.svg'; import chevronIcon from '@/assets/svg/chevron.svg';
import calendarIcon from '@/assets/svg/calendar.svg'; import calendarIcon from '@/assets/svg/calendar.svg';
import gripIcon from '@/assets/svg/grip.svg'; import gripIcon from '@/assets/svg/grip.svg';
import SelectSingle from './modal-controls/selectSingle';
export default { export default {
components: { components: {
SelectSingle,
SelectMulti, SelectMulti,
Datepicker, Datepicker,
checklist, checklist,
@ -1093,7 +1091,7 @@ export default {
members: [], members: [],
membersNameAndId: [], membersNameAndId: [],
memberNamesById: {}, memberNamesById: {},
assignedMember: null, assignedMembers: [],
managers: [], managers: [],
showAdvancedOptions: false, showAdvancedOptions: false,
attributesStrings: { attributesStrings: {
@ -1284,10 +1282,9 @@ export default {
}); });
this.memberNamesById[member._id] = member.profile.name; this.memberNamesById[member._id] = member.profile.name;
}); });
this.assignedMember = null; this.assignedMembers = [];
if (this.task.group?.assignedUsers?.length > 0) { if (this.task.group?.assignedUsers?.length > 0) {
// eslint-disable-next-line prefer-destructuring this.assignedMembers = this.task.group.assignedUsers;
this.assignedMember = this.task.group.assignedUsers[0];
} }
} }
@ -1472,13 +1469,12 @@ export default {
tasks: [this.task], tasks: [this.task],
}); });
Object.assign(this.task, response); Object.assign(this.task, response);
if (this.assignedMember) { const promises = this.assignedMembers.map(memberId => this.$store.dispatch('tasks:assignTask', {
await this.$store.dispatch('tasks:assignTask', { taskId: this.task._id,
taskId: this.task._id, userId: memberId,
userId: this.assignedMember, }));
}); Promise.all(promises);
this.task.group.assignedUsers = [this.assignedMember]; this.task.group.assignedUsers = this.assignedMembers;
}
this.$emit('taskCreated', this.task); this.$emit('taskCreated', this.task);
} else { } else {
this.createTask(this.task); this.createTask(this.task);
@ -1505,21 +1501,21 @@ export default {
this.$emit('cancel'); this.$emit('cancel');
}, },
async toggleAssignment (memberId) { async toggleAssignment (memberId) {
if (this.purpose === 'edit') { if (this.purpose === 'create') {
if (this.assignedMember && this.assignedMember !== memberId) { return;
await this.$store.dispatch('tasks:unassignTask', { }
taskId: this.task._id, const assignedIndex = this.assignedMembers.indexOf(memberId);
userId: this.assignedMember, if (assignedIndex === -1) {
}); await this.$store.dispatch('tasks:unassignTask', {
} taskId: this.task._id,
if (memberId) { userId: memberId,
await this.$store.dispatch('tasks:assignTask', { });
taskId: this.task._id, } else {
userId: memberId, await this.$store.dispatch('tasks:assignTask', {
}); taskId: this.task._id,
} userId: memberId,
});
} }
this.assignedMember = memberId;
}, },
focusInput () { focusInput () {
this.$refs.inputToFocus.focus(); this.$refs.inputToFocus.focus();

View file

@ -15,24 +15,8 @@ export default {
}), }),
}, },
methods: { methods: {
async beforeTaskScore (task) { async beforeTaskScore () {
const { user } = this; return (!this.castingSpell);
if (this.castingSpell) return false;
if (task.group.approval.required && !task.group.approval.approved) {
task.group.approval.requested = true;
const { data: groupPlans } = await this.$store.dispatch('guilds:getGroupPlans');
const groupPlan = groupPlans.find(g => g.id === task.group.id);
if (groupPlan) {
const managers = Object.keys(groupPlan.managers);
managers.push(groupPlan.leader);
if (managers.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
}
}
}
return true;
}, },
playTaskScoreSound (task, direction) { playTaskScoreSound (task, direction) {
switch (task.type) { // eslint-disable-line default-case switch (task.type) { // eslint-disable-line default-case

View file

@ -234,11 +234,6 @@ export default function scoreTask (options = {}, req = {}, analytics) {
exp: user.stats.exp, exp: user.stats.exp,
}; };
if (
task.group && task.group.approval && task.group.approval.required
&& !task.group.approval.approved && !(task.type === 'todo' && cron)
) return 0;
// This is for setting one-time temporary flags, // This is for setting one-time temporary flags,
// such as streakBonus or itemDropped. Useful for notifying // such as streakBonus or itemDropped. Useful for notifying
// the API consumer, then cleared afterwards // the API consumer, then cleared afterwards

View file

@ -775,17 +775,12 @@ api.scoreTask = {
const userStats = user.stats.toJSON(); const userStats = user.stats.toJSON();
// group tasks that require a manager's approval const resJsonData = _.assign({
if (taskResponse.requiresApproval === true) { delta: taskResponse.delta,
res.respond(202, { requiresApproval: true }, taskResponse.message); _tmp: user._tmp,
} else { }, userStats);
const resJsonData = _.assign({
delta: taskResponse.delta,
_tmp: user._tmp,
}, userStats);
res.respond(200, resJsonData); res.respond(200, resJsonData);
}
}, },
}; };

View file

@ -1,30 +1,18 @@
import * as Tasks from '../models/task'; // eslint-disable-line import/no-cycle import * as Tasks from '../models/task'; // eslint-disable-line import/no-cycle
const SHARED_COMPLETION = { async function handleSharedCompletion (teamTask) {
default: 'recurringCompletion', if (teamTask.type === 'reward') return;
single: 'singleCompletion', const incompleteTask = await Tasks.Task.findOne({
every: 'allAssignedCompletion', 'group.taskId': teamTask._id,
}; userId: { $exists: true },
completed: false,
async function _deleteUnfinishedTasks (groupMemberTask) { }, { _id: 1 }).exec();
await Tasks.Task.deleteMany({ if (!incompleteTask) {
'group.taskId': groupMemberTask.group.taskId, teamTask.completed = true;
$and: [ teamTask.save();
{ userId: { $exists: true } }, }
{ userId: { $ne: groupMemberTask.userId } },
],
}).exec();
}
async function handleSharedCompletion (masterTask, groupMemberTask) {
if (masterTask.type === 'reward') return;
if (masterTask.type === 'todo') await _deleteUnfinishedTasks(groupMemberTask);
masterTask.completed = groupMemberTask.completed;
masterTask.group.completedBy = groupMemberTask.userId;
await masterTask.save();
} }
export { export {
SHARED_COMPLETION,
handleSharedCompletion, handleSharedCompletion,
}; };

View file

@ -17,7 +17,6 @@ import {
NotAuthorized, NotAuthorized,
} from '../errors'; } from '../errors';
import { import {
SHARED_COMPLETION,
handleSharedCompletion, handleSharedCompletion,
} from '../groupTasks'; } from '../groupTasks';
import shared from '../../../common'; import shared from '../../../common';
@ -67,7 +66,6 @@ async function createTasks (req, res, options = {}) {
if (taskData.requiresApproval) { if (taskData.requiresApproval) {
newTask.group.approval.required = true; newTask.group.approval.required = true;
} }
newTask.group.sharedCompletion = taskData.sharedCompletion || SHARED_COMPLETION.default;
newTask.group.managerNotes = taskData.managerNotes || ''; newTask.group.managerNotes = taskData.managerNotes || '';
} else { } else {
newTask.userId = user._id; newTask.userId = user._id;
@ -296,22 +294,23 @@ async function handleChallengeTask (task, delta, direction) {
} }
} }
async function handleGroupTask (task, delta, direction) { async function handleTeamTask (task, delta, direction) {
if (task.group && task.group.taskId) { if (task.group && task.group.taskId) {
// Wrapping everything in a try/catch block because if an error occurs // Wrapping everything in a try/catch block because if an error occurs
// using `await` it MUST NOT bubble up because the request has already been handled // using `await` it MUST NOT bubble up because the request has already been handled
try { try {
const groupTask = await Tasks.Task.findOne({ const teamTask = await Tasks.Task.findOne({
_id: task.group.taskId, _id: task.group.taskId,
}).exec(); }).exec();
if (groupTask) { if (teamTask) {
await handleSharedCompletion(groupTask, task); const groupDelta = teamTask.group.assignedUsers
? delta / teamTask.group.assignedUsers.length
const groupDelta = groupTask.group.assignedUsers
? delta / groupTask.group.assignedUsers.length
: delta; : delta;
await groupTask.scoreChallengeTask(groupDelta, direction); await teamTask.scoreChallengeTask(groupDelta, direction);
if (task.type === 'daily' || task.type === 'todo') {
await handleSharedCompletion(teamTask);
}
} }
} catch (e) { } catch (e) {
logger.error(e, 'Error scoring group task'); logger.error(e, 'Error scoring group task');
@ -371,17 +370,23 @@ async function scoreTask (user, task, direction, req, res) {
if (!localTask) throw new NotFound('Task not found.'); if (!localTask) throw new NotFound('Task not found.');
} }
const wasCompleted = task.completed; const targetTask = localTask || task;
const wasCompleted = targetTask.completed;
const firstTask = !user.achievements.completedTask; const firstTask = !user.achievements.completedTask;
let delta; let delta;
if (rollbackUser) { if (rollbackUser) {
delta = shared.ops.scoreTask({ task, user: rollbackUser, direction }, req, res.analytics); delta = shared.ops.scoreTask({
task: targetTask,
user: rollbackUser,
direction,
}, req, res.analytics);
rollbackUser.addNotification('GROUP_TASK_NEEDS_WORK', { rollbackUser.addNotification('GROUP_TASK_NEEDS_WORK', {
message: res.t('taskNeedsWork', { taskText: task.text, managerName: user.profile.name }, rollbackUser.preferences.language), message: res.t('taskNeedsWork', { taskText: targetTask.text, managerName: user.profile.name }, rollbackUser.preferences.language),
task: { task: {
id: task._id, id: targetTask._id,
text: task.text, text: targetTask.text,
}, },
group: { group: {
id: group._id, id: group._id,
@ -394,51 +399,51 @@ async function scoreTask (user, task, direction, req, res) {
}); });
await rollbackUser.save(); await rollbackUser.save();
} else { } else {
delta = shared.ops.scoreTask({ task, user, direction }, req, res.analytics); delta = shared.ops.scoreTask({ task: targetTask, user, direction }, req, res.analytics);
} }
// Drop system (don't run on the client, // Drop system (don't run on the client,
// as it would only be discarded since ops are sent to the API, not the results) // as it would only be discarded since ops are sent to the API, not the results)
if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task, delta }, req, res.analytics); if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task: targetTask, delta }, req, res.analytics);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code? // TODO move to common code?
let pullTask; let pullTask;
let pushTask; let pushTask;
if (task.type === 'todo') { if (targetTask.type === 'todo') {
if (!wasCompleted && task.completed) { if (!wasCompleted && task.completed) {
// @TODO: mongoose's push and pull should be atomic and help with // @TODO: mongoose's push and pull should be atomic and help with
// our concurrency issues. If not, we need to use this update $pull and $push // our concurrency issues. If not, we need to use this update $pull and $push
pullTask = localTask ? localTask._id : task._id; pullTask = targetTask._id;
} else if ( } else if (
wasCompleted wasCompleted
&& !task.completed && !targetTask.completed
&& user.tasksOrder.todos.indexOf(task._id) === -1 && user.tasksOrder.todos.indexOf(task._id) === -1
) { ) {
pushTask = localTask ? localTask._id : task._id; pushTask = targetTask._id;
} }
} }
if (task.completed && task.group.id && !task.userId) { if (targetTask.completed && targetTask.group.id && !targetTask.userId) {
task.group.completedBy = user._id; targetTask.group.completedBy = user._id;
} }
setNextDue(task, user); setNextDue(task, user);
if (localTask) { if (localTask) {
localTask.completed = task.completed; localTask.completed = targetTask.completed;
localTask.value = Number(task.value) + Number(delta); localTask.value = Number(targetTask.value) + Number(delta);
await localTask.save(); await localTask.save();
} }
taskScoredWebhook.send(user, { taskScoredWebhook.send(user, {
task, task: targetTask,
direction, direction,
delta, delta,
user, user,
}); });
return { return {
task, task: targetTask,
delta, delta,
direction, direction,
pullTask, pullTask,
@ -529,7 +534,7 @@ export async function scoreTasks (user, taskScorings, req, res) {
return returnDatas.map(data => { return returnDatas.map(data => {
// Handle challenge and group tasks tasks here because the task must have been saved first // Handle challenge and group tasks tasks here because the task must have been saved first
handleChallengeTask(data.task, data.delta, data.direction); handleChallengeTask(data.task, data.delta, data.direction);
handleGroupTask(data.task, data.delta, data.direction); handleTeamTask(data.task, data.delta, data.direction);
return { id: data.task._id, delta: data.delta, _tmp: data._tmp }; return { id: data.task._id, delta: data.delta, _tmp: data._tmp };
}); });

View file

@ -5,7 +5,6 @@ import _ from 'lodash';
import shared from '../../common'; import shared from '../../common';
import baseModel from '../libs/baseModel'; import baseModel from '../libs/baseModel';
import { preenHistory } from '../libs/preening'; import { preenHistory } from '../libs/preening';
import { SHARED_COMPLETION } from '../libs/groupTasks'; // eslint-disable-line import/no-cycle
const { Schema } = mongoose; const { Schema } = mongoose;
@ -142,9 +141,7 @@ export const TaskSchema = new Schema({
requestedDate: { $type: Date }, requestedDate: { $type: Date },
}, },
sharedCompletion: { sharedCompletion: {
$type: String, $type: String, // legacy data
enum: _.values(SHARED_COMPLETION),
default: SHARED_COMPLETION.single,
}, },
managerNotes: { $type: String }, managerNotes: { $type: String },
completedBy: { $type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid for group completing user.'] }, completedBy: { $type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid for group completing user.'] },