Thehollidayinn/group plans part 2 (#8262)

* Added all ui components back

* Added group ui items back and initial group approval directive

* Added approval list view with approving functionality

* Added notification display for group approvals

* Fixed linting issues

* Removed expectation from beforeEach

* Moved string to locale

* Added per use group plan for stripe

* Added tests for stripe group plan upgrade

* Removed paypal option

* Abstract sub blocks. Hit group sub block from user settings page. Added group subscriptin beneifts display

* Fixed lint issue

* Added pricing and adjusted styles

* Moved text to translations

* Added group email types

* Fixed typo

* Fixed group plan abstraction and other style issues

* Fixed email unit test

* Added type to group plan to filter our group plans

* Removed dev protection from routes

* Removed hard coding and fixed upgrade plan

* Added error when group has subscription and tries to remove

* Fixed payment unit tests

* Added custom string and moved subscription check up in the logic

* Added ability for old leader to delete subscription the created

* Allowed old guild leader to edit their group subscription

* Fixed linting and tests

* Added group sub page to user sub settings

* Added approval and group tasks requests back. Hid user group sub on profile

* Added group tasks sync after adding to allow for editing

* Fixed promise chain when resolving group

* Added approvals to group promise chain

* Ensured compelted group todos are not delted at cron

* Updated copy and other minor styles

* Added group field to tags and recolored group tag.

* Added chat message when task is claimed

* Preventing task scoring when approval is needed

* Added approval requested indicator

* Updated column with for tasks on group page

* Added checklist sync on assign

* Added sync for checklist items

* Added checkilist sync when task is updated

* Added checklist sync remove

* Sanatized group tasks when updated

* Fixed lint issues

* Added instant scoring of approved task

* Added task modal

* Fixed editing of challenge and group tasks

* Added cancel button

* Added add new checklist option to update sync

* Added remove for checklist

* Added checklist update

* Added difference check and sync for checklist if there is a diff

* Fixed task syncing

* Fixed linting issues

* Fixed styles and karma tests

* Fixed minor style issues

* Fixed obj transfer on scope

* Fixed broken tests

* Added new benefits page

* Updated group page styles

* Updated benefits page style

* Added translations

* Prevented sync with empty trask list

* Added task title to edit modal

* Added new group plans page and upgrade redirect

* Added group plans redirect to upgrade

* Fixed party home page being hidden and home button click

* Fixed dynamic changing of task status and grey popup

* Fixed tag editing

* Hid benifites information if group has subscription

* Added quotes to task name

* Fixed issue with assigning multiple users

* Added new group plans ctrl

* Hid menu from public guilds

* Fixed task sync issue

* Updated placeholder for assign field

* Added correct cost to subscribe details

* Hid create, edit, delete task options from non group leaders

* Prevented some front end modifications to group tasks

* Hid tags option from group original task

* Added refresh for approvals and group tasks

* Prepend new group tasks

* Fix last checklist item sync

* Fixed casing issue with tags

* Added claimed by message on hover

* Prevent user from deleting assigned task

* Added single route for group plan sign up and payments

* Abstracted stripe payments and added initial tests

* Abstracted amazon and added initial tests

* Fixed create group message

* Update group id check and return group

* Updated to use the new returned group

* Fixed linting and promise issues

* Fixed broken leave test after merge issue

* Fixed undefined approval error and editing/deleting challenge tasks

* Add pricing to group plans, removed confirmation, and fixed redirect after payment

* Updated group plan cost text
This commit is contained in:
Keith Holliday
2016-12-21 13:45:45 -06:00
committed by GitHub
parent 55a8eef3e1
commit ea24eeb019
70 changed files with 2002 additions and 736 deletions

View File

@@ -207,6 +207,7 @@
"selenium-server": "2.53.0",
"sinon": "^1.17.2",
"sinon-chai": "^2.8.0",
"sinon-stub-promise": "^4.0.0",
"superagent-defaults": "^0.1.13",
"vinyl-transform": "^1.0.0",
"webpack-dev-middleware": "^1.4.0",

View File

@@ -69,4 +69,16 @@ describe('DELETE /tasks/:id', () => {
expect(syncedTask.group.broken).to.equal('TASK_DELETED');
expect(member2SyncedTask.group.broken).to.equal('TASK_DELETED');
});
it('prevents a user from deleting a task they are assigned to', async () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(member.del(`/tasks/${syncedTask._id}`))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('cantDeleteAssignedGroupTasks'),
});
});
});

View File

@@ -59,9 +59,11 @@ describe('POST /tasks/:id/approve/:userId', () => {
await member.sync();
expect(member.notifications.length).to.equal(1);
expect(member.notifications.length).to.equal(2);
expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED');
expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved'));
expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(member.notifications[1].type).to.equal('SCORED_TASK');
expect(member.notifications[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(syncedTask.group.approval.approved).to.be.true;
expect(syncedTask.group.approval.approvingUser).to.equal(user._id);

View File

@@ -82,7 +82,7 @@ describe('POST /tasks/:taskId', () => {
});
});
it('allows user to assign themselves', async () => {
it('allows user to assign themselves (claim)', async () => {
await member.post(`/tasks/${task._id}/assign/${member._id}`);
let groupTask = await user.get(`/tasks/group/${guild._id}`);
@@ -93,6 +93,14 @@ describe('POST /tasks/:taskId', () => {
expect(syncedTask).to.exist;
});
it('sends a message to the group when a user claims a task', async () => {
await member.post(`/tasks/${task._id}/assign/${member._id}`);
let updateGroup = await user.get(`/groups/${guild._id}`);
expect(updateGroup.chat[0].text).to.equal(t('userIsClamingTask', {username: member.profile.name, task: task.text}));
});
it('assigns a task to a user', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);

View File

@@ -4,15 +4,20 @@ import analytics from '../../../../../website/server/libs/analyticsService';
import notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user';
import { model as Group } from '../../../../../website/server/models/group';
import stripeModule from 'stripe';
import moment from 'moment';
import { translate as t } from '../../../../helpers/api-v3-integration.helper';
import {
generateGroup,
} from '../../../../helpers/api-unit.helper.js';
import i18n from '../../../../../website/common/script/i18n';
import amzLib from '../../../../../website/server/libs/amazonPayments';
describe('payments/index', () => {
let user, group, data, plan;
let stripe = stripeModule('test');
beforeEach(async () => {
user = new User();
user.profile.name = 'sender';
@@ -625,7 +630,40 @@ describe('payments/index', () => {
await api.cancelSubscription(data);
expect(sender.sendTxn).to.be.calledOnce;
expect(sender.sendTxn).to.be.calledWith(user, 'cancel-subscription');
expect(sender.sendTxn).to.be.calledWith(user, 'group-cancel-subscription');
});
it('prevents non group leader from manging subscription', async () => {
let groupMember = new User();
data.user = groupMember;
data.groupId = group._id;
await expect(api.cancelSubscription(data))
.eventually.be.rejected.and.to.eql({
httpCode: 401,
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
name: 'NotAuthorized',
});
});
it('allows old group leader to cancel if they created the subscription', async () => {
data.groupId = group._id;
data.sub = {
key: 'group_monthly',
};
data.paymentMethod = 'Payment Method';
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
let newLeader = new User();
updatedGroup.leader = newLeader._id;
await updatedGroup.save();
await api.cancelSubscription(data);
updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.dateTerminated).to.exist;
});
});
});
@@ -732,4 +770,156 @@ describe('payments/index', () => {
});
});
});
describe('#upgradeGroupPlan', () => {
let spy;
beforeEach(function () {
spy = sinon.stub(stripe.subscriptions, 'update');
spy.returnsPromise().resolves([]);
data.groupId = group._id;
data.sub.quantity = 3;
});
afterEach(function () {
sinon.restore(stripe.subscriptions.update);
});
it('updates a group plan quantity', async () => {
data.paymentMethod = 'Stripe';
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
updatedGroup.memberCount += 1;
await updatedGroup.save();
await api.updateStripeGroupPlan(updatedGroup, stripe);
expect(spy.calledOnce).to.be.true;
expect(updatedGroup.purchased.plan.quantity).to.eql(4);
});
it('does not update a group plan quantity that has a payment method other than stripe', async () => {
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
updatedGroup.memberCount += 1;
await updatedGroup.save();
await api.updateStripeGroupPlan(updatedGroup, stripe);
expect(spy.calledOnce).to.be.false;
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
});
});
describe('payWithStripe', () => {
let spy;
let stripeCreateCustomerSpy;
let createSubSpy;
beforeEach(function () {
spy = sinon.stub(stripe.subscriptions, 'update');
spy.returnsPromise().resolves;
stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create');
let stripCustomerResponse = {
subscriptions: {
data: [{id: 'test-id'}],
},
};
stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse);
createSubSpy = sinon.stub(api, 'createSubscription');
createSubSpy.returnsPromise().resolves({});
data.groupId = group._id;
data.sub.quantity = 3;
});
afterEach(function () {
sinon.restore(stripe.subscriptions.update);
stripe.customers.create.restore();
api.createSubscription.restore();
});
it('subscribes with stripe', async () => {
let token = 'test-token';
let gift;
let sub = data.sub;
let groupId = group._id;
let email = 'test@test.com';
let headers = {};
let coupon;
await api.payWithStripe([
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
], stripe);
expect(stripeCreateCustomerSpy.calledOnce).to.be.true;
expect(createSubSpy.calledOnce).to.be.true;
});
});
describe('subscribeWithAmazon', () => {
let amazonSetBillingAgreementDetailsSpy;
let amazonConfirmBillingAgreementSpy;
let amazongAuthorizeOnBillingAgreementSpy;
let createSubSpy;
beforeEach(function () {
amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails');
amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({});
amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement');
amazonConfirmBillingAgreementSpy.returnsPromise().resolves({});
amazongAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement');
amazongAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({});
createSubSpy = sinon.stub(api, 'createSubscription');
createSubSpy.returnsPromise().resolves({});
});
afterEach(function () {
amzLib.setBillingAgreementDetails.restore();
amzLib.confirmBillingAgreement.restore();
amzLib.authorizeOnBillingAgreement.restore();
api.createSubscription.restore();
});
it('subscribes with stripe', async () => {
let billingAgreementId = 'billingAgreementId';
let sub = data.sub;
let coupon;
let groupId = group._id;
let headers = {};
await api.subscribeWithAmazon([
billingAgreementId,
sub,
coupon,
sub,
user,
groupId,
headers,
]);
expect(amazonSetBillingAgreementDetailsSpy.calledOnce).to.be.true;
expect(amazonConfirmBillingAgreementSpy.calledOnce).to.be.true;
expect(amazongAuthorizeOnBillingAgreementSpy.calledOnce).to.be.true;
expect(createSubSpy.calledOnce).to.be.true;
});
});
});

View File

@@ -1,13 +1,16 @@
import { sleep } from '../../../../helpers/api-unit.helper';
import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user';
import { BadRequest } from '../../../../../website/server/libs/errors';
import {
BadRequest,
} from '../../../../../website/server/libs/errors';
import { quests as questScrolls } from '../../../../../website/common/script/content';
import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook';
import * as email from '../../../../../website/server/libs/email';
import validator from 'validator';
import { TAVERN_ID } from '../../../../../website/common/script/';
import { v4 as generateUUID } from 'uuid';
import shared from '../../../../../website/common';
describe('Group Model', () => {
let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
@@ -638,6 +641,22 @@ describe('Group Model', () => {
expect(party).to.not.exist;
});
it('does not delete a private group when the last member leaves and a subscription is active', async () => {
party.memberCount = 1;
party.purchased.plan.customerId = '110002222333';
await expect(party.leave(participatingMember))
.to.eventually.be.rejected.and.to.eql({
name: 'NotAuthorized',
httpCode: 401,
message: shared.i18n.t('cannotDeleteActiveGroup'),
});
party = await Group.findOne({_id: party._id});
expect(party).to.exist;
expect(party.memberCount).to.eql(1);
});
it('does not delete a public group when the last member leaves', async () => {
party.privacy = 'public';

View File

@@ -2,7 +2,7 @@ import { model as Challenge } from '../../../../../website/server/models/challen
import { model as Group } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user';
import * as Tasks from '../../../../../website/server/models/task';
import { each, find } from 'lodash';
import { each, find, findIndex } from 'lodash';
describe('Group Task Methods', () => {
let guild, leader, challenge, task;
@@ -68,11 +68,29 @@ describe('Group Task Methods', () => {
task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue));
task.group.id = guild._id;
await task.save();
if (task.checklist) {
task.checklist.push({
text: 'Checklist Item 1',
completed: false,
});
}
});
it('syncs an assigned task to a user', async () => {
await guild.syncTask(task, leader);
let updatedLeader = await User.findOne({_id: leader._id});
let tagIndex = findIndex(updatedLeader.tags, {id: guild._id});
let newTag = updatedLeader.tags[tagIndex];
expect(newTag.id).to.equal(guild._id);
expect(newTag.name).to.equal(guild.name);
expect(newTag.group).to.equal(guild._id);
});
it('create tags for a user when task is synced', async () => {
await guild.syncTask(task, leader);
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
@@ -96,15 +114,33 @@ describe('Group Task Methods', () => {
expect(syncedTask.text).to.equal(task.text);
});
it('syncs updated info for assigned task to all users', async () => {
let newMember = new User({
it('syncs checklist items to an assigned user', async () => {
await guild.syncTask(task, leader);
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
if (task.type !== 'daily' && task.type !== 'todo') return;
expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask.checklist[0].text).to.equal(task.checklist[0].text);
});
describe('syncs updated info', async() => {
let newMember;
beforeEach(async () => {
newMember = new User({
guilds: [guild._id],
});
await newMember.save();
await guild.syncTask(task, leader);
await guild.syncTask(task, newMember);
});
it('syncs updated info for assigned task to all users', async () => {
let updatedTaskName = 'Update Task name';
task.text = updatedTaskName;
task.group.approval.required = true;
@@ -130,6 +166,74 @@ describe('Group Task Methods', () => {
expect(syncedMemberTask.group.approval.required).to.equal(true);
});
it('syncs a new checklist item to all assigned users', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
let newCheckListItem = {
text: 'Checklist Item 1',
completed: false,
};
task.checklist.push(newCheckListItem);
await guild.updateTask(task, {newCheckListItem});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask.checklist[1].text).to.equal(task.checklist[1].text);
expect(syncedMemberTask.checklist.length).to.equal(task.checklist.length);
expect(syncedMemberTask.checklist[1].text).to.equal(task.checklist[1].text);
});
it('syncs updated info for checklist in assigned task to all users when flag is passed', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
let updateCheckListText = 'Updated checklist item';
if (task.checklist) {
task.checklist[0].text = updateCheckListText;
}
await guild.updateTask(task, {updateCheckListItems: [task.checklist[0]]});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask.checklist[0].text).to.equal(updateCheckListText);
expect(syncedMemberTask.checklist.length).to.equal(task.checklist.length);
expect(syncedMemberTask.checklist[0].text).to.equal(updateCheckListText);
});
it('removes a checklist item in assigned task to all users when flag is passed with checklist id', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
await guild.updateTask(task, {removedCheckListItemId: task.checklist[0].id});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(0);
expect(syncedMemberTask.checklist.length).to.equal(0);
});
});
it('removes an assigned task and unlinks assignees', async () => {
await guild.syncTask(task, leader);
await guild.removeTask(task);

View File

@@ -275,7 +275,8 @@ describe('Challenges Controller', function() {
describe('editTask', function() {
it('is Tasks.editTask', function() {
inject(function(Tasks) {
expect(scope.editTask).to.eql(Tasks.editTask);
// @TODO: Currently we override the task function in the challenge ctrl, but we should abstract it again
// expect(scope.editTask).to.eql(Tasks.editTask);
});
});
});

View File

@@ -32,7 +32,8 @@ describe('Tasks Controller', function() {
describe('editTask', function() {
it('is Tasks.editTask', function() {
inject(function(Tasks) {
expect(scope.editTask).to.eql(Tasks.editTask);
// @TODO: Currently we override the task function in the challenge ctrl, but we should abstract it again
// expect(scope.editTask).to.eql(Tasks.editTask);
});
});
});

View File

@@ -16,6 +16,8 @@ describe('Tasks Service', function() {
rootScope.charts = {};
tasks = Tasks;
});
rootScope.openModal = function () {};
});
it('calls get user tasks endpoint', function() {
@@ -151,7 +153,6 @@ describe('Tasks Service', function() {
});
describe('editTask', function() {
var task;
beforeEach(function(){

View File

@@ -157,6 +157,16 @@ describe('shared.ops.scoreTask', () => {
expect(ref.afterUser._tmp.quest.progressDelta).to.eql(secondTaskDelta);
});
it('does not modify stats when task need approval', () => {
todo.group.approval.required = true;
options = { user: ref.afterUser, task: todo, direction: 'up', times: 5, cron: false };
scoreTask(options);
expect(ref.afterUser.stats.hp).to.eql(50);
expect(ref.afterUser.stats.exp).to.equal(ref.beforeUser.stats.exp);
expect(ref.afterUser.stats.gp).to.equal(ref.beforeUser.stats.gp);
});
context('habits', () => {
it('up', () => {
options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false };

View File

@@ -13,5 +13,7 @@ chai.use(require('sinon-chai'));
chai.use(require('chai-as-promised'));
global.expect = chai.expect;
global.sinon = require('sinon');
let sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(global.sinon);
global.sandbox = sinon.sandbox.create();
global.Promise = Bluebird;

View File

@@ -8,10 +8,7 @@
// array of keywords and their associated color vars
$stages = (worst $worst) (worse $worse) (bad $bad) (neutral $neutral) (good $good) (better $better) (best $best)
// for each color stage, generate a named class w/ the appropriate color
for $stage in $stages
.task-column:not(.rewards)
.color-{$stage[0]}:not(.completed)
taskContainerStyles($stage)
background-color: $stage[1]
border: 1px solid shade($stage[1],10%)
.priority-multiplier, .task-attributes, .repeat-days, .repeat-frequency
@@ -62,6 +59,17 @@ for $stage in $stages
border-color: darken($stage[1], 61.8%) !important
outline: none;
// for each color stage, generate a named class w/ the appropriate color
for $stage in $stages
.task-column:not(.rewards)
.color-{$stage[0]}:not(.completed)
taskContainerStyles($stage)
.task-modal
&.color-{$stage[0]}:not(.completed)
taskContainerStyles($stage)
// completed has to be outside the loop to override the color class
.completed
color: darken($completed,30%)
@@ -366,6 +374,12 @@ for $stage in $stages
text-align: center
opacity: 0.75
// Group yesno
.group-yesno
label:hover:after, input[type=checkbox]:checked + label:after
content: "" !important
opacity: 1 !important
// secondary task commands
// -----------------------

View File

@@ -139,6 +139,13 @@ window.habitrpg = angular.module('habitrpg',
title: env.t('titlePatrons')
})
.state('options.social.groupPlans', {
url: '/group-plans',
templateUrl: "partials/options.social.groupPlans.html",
controller: 'GroupPlansCtrl',
title: env.t('groupPlansTitle')
})
.state('options.social.guilds', {
url: '/guilds',
templateUrl: "partials/options.social.guilds.html",
@@ -155,38 +162,55 @@ window.habitrpg = angular.module('habitrpg',
templateUrl: "partials/options.social.guilds.create.html",
title: env.t('titleGuilds')
})
.state('options.social.guilds.detail', {
url: '/:gid',
templateUrl: 'partials/options.social.guilds.detail.html',
title: env.t('titleGuilds'),
controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', 'Tasks',
function($scope, Groups, Chat, $stateParams, Members, Challenges, Tasks) {
controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', 'Tasks', 'User', '$location',
function($scope, Groups, Chat, $stateParams, Members, Challenges, Tasks, User, $location) {
$scope.groupPanel = 'chat';
$scope.upgrade = false;
// @TODO: Move this to service or single http request
Groups.Group.get($stateParams.gid)
.then(function (response) {
$scope.obj = $scope.group = response.data.data;
Chat.markChatSeen($scope.group._id);
Members.getGroupMembers($scope.group._id)
return Chat.markChatSeen($scope.group._id);
})
.then (function () {
return Members.getGroupMembers($scope.group._id);
})
.then(function (response) {
$scope.group.members = response.data.data;
});
Members.getGroupInvites($scope.group._id)
return Members.getGroupInvites($scope.group._id);
})
.then(function (response) {
$scope.group.invites = response.data.data;
});
Challenges.getGroupChallenges($scope.group._id)
return Challenges.getGroupChallenges($scope.group._id);
})
.then(function (response) {
$scope.group.challenges = response.data.data;
});
//@TODO: Add this back when group tasks go live
// Tasks.getGroupTasks($scope.group._id);
// .then(function (response) {
// var tasks = response.data.data;
// tasks.forEach(function (element, index, array) {
// if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
// $scope.group[element.type + 's'].push(element);
// })
// });
return Tasks.getGroupTasks($scope.group._id);
})
.then(function (response) {
var tasks = response.data.data;
tasks.forEach(function (element, index, array) {
if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
$scope.group[element.type + 's'].unshift(element);
});
$scope.group.approvals = [];
if (User.user._id === $scope.group.leader._id) {
return Tasks.getGroupApprovals($scope.group._id);
}
})
.then(function (response) {
if (response) $scope.group.approvals = response.data.data;
});
}]
})

View File

@@ -1,21 +1,23 @@
habitrpg.controller('GroupApprovalsCtrl', ['$scope', 'Tasks',
function ($scope, Tasks) {
$scope.approvals = [];
// Tasks.getGroupApprovals($scope.group._id)
// .then(function (response) {
// $scope.approvals = response.data.data;
// });
$scope.approve = function (taskId, userId, $index) {
if (!confirm(env.t('confirmTaskApproval'))) return;
$scope.approve = function (taskId, userId, username, $index) {
if (!confirm(env.t('confirmTaskApproval', {username: username}))) return;
Tasks.approve(taskId, userId)
.then(function (response) {
$scope.approvals.splice($index, 1);
$scope.group.approvals.splice($index, 1);
});
};
$scope.approvalTitle = function (approval) {
return env.t('approvalTitle', {text: approval.text, userName: approval.userId.profile.name});
};
$scope.refreshApprovals = function () {
$scope.loading = true;
Tasks.getGroupApprovals($scope.group._id)
.then(function (response) {
if (response) $scope.group.approvals = response.data.data;
$scope.loading = false;
});
};
}]);

View File

@@ -27,12 +27,17 @@
}));
var currentTags = [];
_.each(scope.task.group.assignedUsers, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) })
_.each(scope.task.group.assignedUsers, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) });
var allowedTags = [];
_.each(scope.task.group.members, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) });
var taggle = new Taggle('taggle', {
tags: currentTags,
allowedTags: currentTags,
allowedTags: allowedTags,
allowDuplicates: false,
preserveCase: true,
placeholder: window.env.t('assignFieldPlaceholder'),
onBeforeTagAdd: function(event, tag) {
return confirm(window.env.t('confirmAddTag', {tag: tag}));
},

View File

@@ -1,17 +1,65 @@
habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', function ($scope, Shared, Tasks, User) {
$scope.editTask = Tasks.editTask;
function handleGetGroupTasks (response) {
var group = $scope.obj;
var tasks = response.data.data;
if (tasks.length === 0) return;
// @TODO: We need to get the task information from createGroupTasks rather than resyncing
group['habits'] = [];
group['dailys'] = [];
group['todos'] = [];
group['rewards'] = [];
tasks.forEach(function (element, index, array) {
if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
$scope.group[element.type + 's'].unshift(element);
})
$scope.loading = false;
};
$scope.refreshTasks = function () {
$scope.loading = true;
Tasks.getGroupTasks($scope.group._id)
.then(handleGetGroupTasks);
};
/*
* Task Edit functions
*/
$scope.toggleBulk = Tasks.toggleBulk;
$scope.cancelTaskEdit = Tasks.cancelTaskEdit;
$scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
function addTask (listDef, taskTexts) {
taskTexts.forEach(function (taskText) {
var task = Shared.taskDefaults({text: taskText, type: listDef.type});
//If the group has not been created, we bulk add tasks on save
var group = $scope.obj;
if (group._id) Tasks.createGroupTasks(group._id, task);
if (!group._id) return;
Tasks.createGroupTasks(group._id, task)
.then(function () {
// Set up default group info on task. @TODO: Move this to Tasks.createGroupTasks
task.group = {
id: group._id,
approval: {required: false, approved: false, requested: false},
assignedUsers: [],
};
if (!group[task.type + 's']) group[task.type + 's'] = [];
group[task.type + 's'].unshift(task);
return Tasks.getGroupTasks($scope.group._id);
})
.then(handleGetGroupTasks);
});
};
@@ -28,8 +76,21 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
};
$scope.saveTask = function(task, stayOpen, isSaveAndClose) {
// Check if we have a lingering checklist that the enter button did not trigger on
var lastIndex = task._edit.checklist.length - 1;
var lastCheckListItem = task._edit.checklist[lastIndex];
if (lastCheckListItem && !lastCheckListItem.id && lastCheckListItem.text) {
Tasks.addChecklistItem(task._id, lastCheckListItem)
.then(function (response) {
task._edit.checklist[lastIndex] = response.data.data.checklist[lastIndex];
task.checklist[lastIndex] = response.data.data.checklist[lastIndex];
Tasks.saveTask(task, stayOpen, isSaveAndClose);
Tasks.updateTask(task._id, task);
});
} else {
Tasks.saveTask (task, stayOpen, isSaveAndClose);
Tasks.updateTask(task._id, task);
}
};
$scope.shouldShow = function(task, list, prefs){
@@ -63,9 +124,23 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
*/
$scope.addChecklist = Tasks.addChecklist;
$scope.addChecklistItem = Tasks.addChecklistItemToUI;
$scope.addChecklistItem = function addChecklistItemToUI(task, $event, $index) {
if (task._edit.checklist[$index].justAdded) return;
task._edit.checklist[$index].justAdded = true;
if (!task._edit.checklist[$index].id) {
Tasks.addChecklistItem (task._id, task._edit.checklist[$index])
.then(function (response) {
task._edit.checklist[$index] = response.data.data.checklist[$index];
})
}
Tasks.addChecklistItemToUI(task, $event, $index);
};
$scope.removeChecklistItem = Tasks.removeChecklistItemFromUI;
$scope.removeChecklistItem = function (task, $event, $index, force) {
if (!task._edit.checklist[$index].id) return;
Tasks.removeChecklistItem (task._id, task._edit.checklist[$index].id);
Tasks.removeChecklistItemFromUI(task, $event, $index, force);
};
$scope.swapChecklistItems = Tasks.swapChecklistItems;
@@ -78,4 +153,34 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
//@TODO: Currently the api save of the task is separate, so whenever we need to save the task we need to call the respective api
Tasks.updateTask(task._id, task);
};
$scope.checkGroupAccess = function (group) {
if (!group || !group.leader) return true;
if (User.user._id !== group.leader._id) return false;
return true;
};
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
if ($scope.group) {
var memberIdToProfileNameMap = _.object(_.map($scope.group.members, function(item) {
return [item.id, item.profile.name]
}));
var claimingUsers = [];
task.group.assignedUsers.forEach(function (userId) {
claimingUsers.push(memberIdToProfileNameMap[userId]);
})
if (claimingUsers.length > 0) content += window.env.t('claimedBy', {claimingUsers: claimingUsers.join(', ')});
}
return content;
};
}]);

View File

@@ -34,7 +34,10 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User',
return User.user.challenges.indexOf(challenge._id) !== -1;
}
$scope.editTask = Tasks.editTask;
$scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
$scope.cancelTaskEdit = Tasks.cancelTaskEdit;
$scope.canEdit = function(task) {
@@ -313,6 +316,15 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User',
$scope.toggleBulk = Tasks.toggleBulk;
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
return content;
};
/*
--------------------------
Subscription

View File

@@ -45,4 +45,8 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared',
User.addTag({body:{name: $scope._newTag.name, id: Shared.uuid()}});
$scope._newTag.name = '';
};
$scope.showChallengeClass = function (tag) {
return tag.challenge || tag.group;
};
}]);

View File

@@ -0,0 +1,43 @@
"use strict";
/*
A controller to manage the Group Plans page
*/
angular.module('habitrpg')
.controller("GroupPlansCtrl", ['$scope', '$window', 'Groups', 'Payments',
function($scope, $window, Groups, Payments) {
$scope.PAGES = {
BENEFITS: 'benefits',
CREATE_GROUP: 'create-group',
UPGRADE_GROUP: 'upgrade-group',
};
$scope.activePage = $scope.PAGES.BENEFITS;
$scope.newGroup = {
type: 'guild',
privacy: 'private',
};
$scope.PAYMENTS = {
AMAZON: 'amazon',
STRIPE: 'stripe',
};
$scope.changePage = function (page) {
$scope.activePage = page;
$window.scrollTo(0, 0);
};
$scope.newGroupIsReady = function () {
return $scope.newGroup.name && $scope.newGroup.description;
};
$scope.createGroup = function () {
$scope.changePage($scope.PAGES.UPGRADE_GROUP);
};
$scope.upgradeGroup = function (paymentType) {
var subscriptionKey = 'group_monthly'; // @TODO: Get from content API?
if (paymentType === $scope.PAYMENTS.STRIPE) Payments.showStripe({subscription: subscriptionKey, coupon: null, groupToCreate: $scope.newGroup});
if (paymentType === $scope.PAYMENTS.AMAZON) Payments.amazonPayments.init({type: 'subscription', subscription: subscriptionKey, coupon: null, groupToCreate: $scope.newGroup});
};
}]);

View File

@@ -34,7 +34,7 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r
Groups.Group.create(group)
.then(function (response) {
var createdGroup = response.data.data;
$rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id);
$rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id + '?upgrade=true');
});
}
}

View File

@@ -11,6 +11,7 @@ angular.module('habitrpg')
function selectNotificationValue(mysteryValue, invitationValue, cardValue, unallocatedValue, messageValue, noneValue, groupApprovalRequested, groupApproved) {
var user = $scope.user;
if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) {
return mysteryValue;
} else if ((user.invitations.party && user.invitations.party.id) || (user.invitations.guilds && user.invitations.guilds.length > 0)) {

View File

@@ -1,8 +1,8 @@
'use strict';
habitrpg.controller('NotificationCtrl',
['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', 'Achievement', 'Social',
function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics, Achievement, Social) {
['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', 'Achievement', 'Social', 'Tasks',
function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics, Achievement, Social, Tasks) {
$rootScope.$watch('user.stats.hp', function (after, before) {
if (after <= 0){
@@ -87,6 +87,8 @@ habitrpg.controller('NotificationCtrl',
if (!after || after.length === 0) return;
var notificationsToRead = [];
var scoreTaskNotification;
after.forEach(function (notification) {
if (lastShownNotifications.indexOf(notification.id) !== -1) {
return;
@@ -141,6 +143,9 @@ habitrpg.controller('NotificationCtrl',
trasnferGroupNotification(notification);
markAsRead = false;
break;
case 'SCORED_TASK':
scoreTaskNotification = notification;
break;
case 'LOGIN_INCENTIVE':
Notification.showLoginIncentive(User.user, notification.data, Social.loadWidgets);
break;
@@ -159,7 +164,17 @@ habitrpg.controller('NotificationCtrl',
if (markAsRead) notificationsToRead.push(notification.id);
});
User.readNotifications(notificationsToRead);
var userReadNotifsPromise = User.readNotifications(notificationsToRead);
if (userReadNotifsPromise) {
userReadNotifsPromise.then(function () {
if (scoreTaskNotification) {
Notification.markdown(scoreTaskNotification.data.message);
User.score({params:{task: scoreTaskNotification.data.scoreTask, direction: "up"}});
}
});
}
User.user.notifications = []; // reset the notifications
}

View File

@@ -10,6 +10,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','
$scope.type = 'party';
$scope.text = window.env.t('party');
$scope.group = {loadingParty: true};
$scope.groupPanel = 'chat';
$scope.inviteOrStartParty = Groups.inviteOrStartParty;
$scope.loadWidgets = Social.loadWidgets;

View File

@@ -51,11 +51,17 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
$scope.toggleBulk = Tasks.toggleBulk;
$scope.editTask = Tasks.editTask;
$scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
$scope.canEdit = function(task) {
// can't edit challenge tasks
return !task.challenge.id;
return !task.challenge.id && (!task.group || !task.group.id);
}
$scope.checkGroupAccess = function (group) {
return true;
}
$scope.doubleClickTask = function (obj, task) {
@@ -228,6 +234,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
$scope.shouldShow = function(task, list, prefs) {
if (task._editing) // never hide a task while being edited
return true;
var shouldDo = task.type == 'daily' ? habitrpgShared.shouldDo(new Date, task, prefs) : true;
switch (list.view) {
case "yellowred": // Habits
@@ -324,4 +331,13 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
return notes;
};
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
return content;
};
}]);

View File

@@ -37,12 +37,23 @@ function($rootScope, User, $http, Content) {
panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'),
token: function(res) {
var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below
if (data.groupToCreate) {
url = '/api/v3/groups/create-plan?a=a';
res.groupToCreate = data.groupToCreate;
res.paymentType = 'Stripe';
}
if (data.gift) url += '&gift=' + Payments.encodeGift(data.uuid, data.gift);
if (data.subscription) url += '&sub='+sub.key;
if (data.coupon) url += '&coupon='+data.coupon;
if (data.groupId) url += '&groupId=' + data.groupId;
$http.post(url, res).success(function() {
$http.post(url, res).success(function(response) {
if (response && response.data && response.data._id) {
$rootScope.hardRedirect('/#/options/groups/guilds/' + response.data._id);
} else {
window.location.reload(true);
}
}).error(function(res) {
alert(res.message);
});
@@ -116,6 +127,10 @@ function($rootScope, User, $http, Content) {
Payments.amazonPayments.groupId = data.groupId;
}
if (data.groupToCreate) {
Payments.amazonPayments.groupToCreate = data.groupToCreate;
}
Payments.amazonPayments.gift = data.gift;
Payments.amazonPayments.type = data.type;
@@ -255,14 +270,24 @@ function($rootScope, User, $http, Content) {
} else if(Payments.amazonPayments.type === 'subscription') {
var url = '/amazon/subscribe';
if (Payments.amazonPayments.groupToCreate) {
url = '/api/v3/groups/create-plan';
}
$http.post(url, {
billingAgreementId: Payments.amazonPayments.billingAgreementId,
subscription: Payments.amazonPayments.subscription,
coupon: Payments.amazonPayments.coupon,
groupId: Payments.amazonPayments.groupId,
}).success(function(){
groupToCreate: Payments.amazonPayments.groupToCreate,
paymentType: 'Amazon',
}).success(function(response) {
Payments.amazonPayments.reset();
if (response && response.data && response.data._id) {
$rootScope.hardRedirect('/#/options/groups/guilds/' + response.data._id);
} else {
window.location.reload(true);
}
}).error(function(res){
alert(res.message);
Payments.amazonPayments.reset();

View File

@@ -245,12 +245,31 @@ angular.module('habitrpg')
});
};
function editTask(task, user) {
task._editing = true;
task._tags = !user.preferences.tagsCollapsed;
task._advanced = !user.preferences.advancedCollapsed;
task._edit = angular.copy(task);
function editTask(task, user, taskStatus, scopeInc) {
var modalScope = $rootScope.$new();
modalScope.task = task;
modalScope.task._editing = true;
modalScope.task._tags = !user.preferences.tagsCollapsed;
modalScope.task._advanced = !user.preferences.advancedCollapsed;
modalScope.task._edit = angular.copy(task);
if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false;
modalScope.taskStatus = taskStatus;
if (scopeInc) {
modalScope.saveTask = scopeInc.saveTask;
modalScope.addChecklist = scopeInc.addChecklist;
modalScope.addChecklistItem = scopeInc.addChecklistItem;
modalScope.removeChecklistItem = scopeInc.removeChecklistItem;
modalScope.swapChecklistItems = scopeInc.swapChecklistItems;
modalScope.navigateChecklist = scopeInc.navigateChecklist;
modalScope.checklistCompletion = scopeInc.checklistCompletion;
modalScope.canEdit = scopeInc.canEdit;
modalScope.updateTaskTags = scopeInc.updateTaskTags;
modalScope.obj = scopeInc.obj;
}
modalScope.cancelTaskEdit = cancelTaskEdit;
$rootScope.openModal('task-edit', {scope: modalScope, backdrop: 'static'});
}
function cancelTaskEdit(task) {

View File

@@ -329,7 +329,7 @@ angular.module('habitrpg')
},
readNotifications: function (notificationIds) {
UserNotifications.readNotifications(notificationIds);
return UserNotifications.readNotifications(notificationIds);
},
addTag: function(data) {

View File

@@ -109,6 +109,7 @@
"js/controllers/tavernCtrl.js",
"js/controllers/tasksCtrl.js",
"js/controllers/userCtrl.js",
"js/controllers/groupPlansCtrl.js",
"js/components/groupTasks/groupTasksController.js",
"js/components/groupTasks/groupTasksDirective.js",

View File

@@ -53,6 +53,7 @@
"help": "Help",
"user": "User",
"market": "Market",
"groupPlansTitle": "Group Plans",
"subscriberItem": "Mystery Item",
"newSubscriberItem": "New Mystery Item",
"subscriberItemText": "Each month, subscribers will receive a mystery item. This is usually released about one week before the end of the month. See the wiki's 'Mystery Item' page for more information.",
@@ -194,5 +195,7 @@
"you": "(you)",
"enableDesktopNotifications": "Enable Desktop Notifications",
"online": "online",
"onlineCount": "<%= count %> online"
"onlineCount": "<%= count %> online",
"loading": "Loading...",
"userIdRequired": "User ID is required"
}

View File

@@ -113,7 +113,9 @@
"toUserIDRequired": "A User ID is required",
"gemAmountRequired": "A number of gems is required",
"notAuthorizedToSendMessageToThisUser": "Can't send message to this user.",
"privateMessageGiftGemsMessage": "Hello <%= receiverName %>, <%= senderName %> has sent you <%= gemAmount %> gems!",
"privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ",
"privateMessageGiftGemsMessage": "<%= gemAmount %> gems!",
"privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ",
"cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.",
"badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.",
"abuseFlag": "Report violation of Community Guidelines",
@@ -170,8 +172,8 @@
"requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.",
"partyUpName": "Party Up",
"partyOnName": "Party On",
"partyUpText": "Joined a Party with another person! Have fun battling monsters and supporting each other.",
"partyOnText": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!",
"partyUpAchievement": "Joined a Party with another person! Have fun battling monsters and supporting each other.",
"partyOnAchievement": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!",
"largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.",
"groupIdRequired": "\"groupId\" must be a valid UUID",
"groupNotFound": "Group not found or you don't have access.",
@@ -210,15 +212,46 @@
"to": "To:",
"from": "From:",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"confirmAddTag": "Do you really want to add \"<%= tag %>\"?",
"confirmAddTag": "Do you want to assign this task to \"<%= tag %>\"?",
"confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?",
"groupHomeTitle": "Home",
"assignTask": "Assign Task",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"claim": "Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"yourTaskHasBeenApproved": "Your task has been approved",
"yourTaskHasBeenApproved": "Your task \"<%= taskText %>\" has been approved",
"userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>",
"confirmTaskApproval": "Are you sure you want to approve this task?",
"approve": "Approve",
"approvalTitle": "<%= text %> for user: <%= userName %>"
"approvalTitle": "<%= text %> for user: <%= userName %>",
"confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?",
"groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member",
"groupBenefitsTitle": "How a group plan can help you",
"groupBenefitsDescription": "Weve just launched the beta version of our group plans! Upgrading to a group plan unlocks some unique features to optimize the social side of Habitica.",
"groupBenefitOneTitle": "Create a shared task list",
"groupBenefitOneDescription": "Set up a shared task list for the group that everyone can easily view and edit.",
"groupBenefitTwoTitle": "Assign tasks to group members",
"groupBenefitTwoDescription": "Want a coworker to answer a critical email? Need your roommate to pick up the groceries? Just assign them the tasks you create, and theyll automatically appear in that persons task dashboard.",
"groupBenefitThreeTitle": "Claim a task that you are working on",
"groupBenefitThreeDescription": "Stake your claim on any group task with a simple click. Make it clear what everybody is working on!",
"groupBenefitFourTitle": "Mark tasks that require special approval",
"groupBenefitFourDescription": "Need to verify that a task really did get done before that user gets their rewards? Just adjust the approval settings for added control.",
"groupBenefitFiveTitle": "Chat privately with your group",
"groupBenefitFiveDescription": "Stay in the loop about important decisions in our easy-to-use chatroom!",
"createAGroup": "Create A Group",
"assignFieldPlaceholder": "Type a group member's profile name",
"cannotDeleteActiveGroup": "You cannot remove a group with an active subscription",
"groupTasksTitle": "Group Tasks List",
"approvalsTitle": "Tasks Awaiting Approval",
"upgradeTitle": "Upgrade",
"blankApprovalsDescription": "When your group completes tasks that need your approval, theyll appear here! Adjust approval requirement settings under task editing.",
"userIsClamingTask": "<%= username %> has claimed \"<%= task %>\"",
"approvalRequested": "Approval Requested",
"refreshApprovals": "Refresh Approvals",
"refreshGroupTasks": "Refresh Group Tasks",
"claimedBy": "\n\nClaimed by: <%= claimingUsers %>",
"cantDeleteAssignedGroupTasks": "Can't delete group tasks that are assigned to you.",
"confirmGuildPlanCreation": "Create this group?"
}

View File

@@ -22,6 +22,7 @@ import gear from './gear';
import appearances from './appearance';
import backgrounds from './appearance/backgrounds.js'
import spells from './spells';
import subscriptionBlocks from './subscriptionBlocks';
import faq from './faq';
import timeTravelers from './time-travelers';
@@ -33,6 +34,7 @@ api.itemList = ITEM_LIST;
api.gear = gear;
api.spells = spells;
api.subscriptionBlocks = subscriptionBlocks;
api.mystery = timeTravelers.mystery;
api.timeTravelerStore = timeTravelers.timeTravelerStore;
@@ -2808,35 +2810,6 @@ api.appearances = appearances;
api.backgrounds = backgrounds;
api.subscriptionBlocks = {
basic_earned: {
months: 1,
price: 5
},
basic_3mo: {
months: 3,
price: 15
},
basic_6mo: {
months: 6,
price: 30
},
google_6mo: {
months: 6,
price: 24,
discount: true,
original: 30
},
basic_12mo: {
months: 12,
price: 48
},
};
_.each(api.subscriptionBlocks, function(b, k) {
return b.key = k;
});
api.userDefaults = {
habits: [
{

View File

@@ -0,0 +1,39 @@
/* eslint-disable camelcase */
import _ from 'lodash';
let subscriptionBlocks = {
basic_earned: {
months: 1,
price: 5,
},
basic_3mo: {
months: 3,
price: 15,
},
basic_6mo: {
months: 6,
price: 30,
},
google_6mo: {
months: 6,
price: 24,
discount: true,
original: 30,
},
basic_12mo: {
months: 12,
price: 48,
},
group_monthly: {
type: 'group',
months: 1,
price: 9,
quantity: 3, // Default quantity for Stripe - The same as having 3 user subscriptions
},
};
_.each(subscriptionBlocks, function createKeys (b, k) {
return b.key = k;
});
module.exports = subscriptionBlocks;

View File

@@ -179,6 +179,8 @@ module.exports = function scoreTask (options = {}, req = {}) {
exp: user.stats.exp,
};
if (task.group && task.group.approval && task.group.approval.required && !task.group.approval.approved) return;
// This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
// the API consumer, then cleared afterwards
user._tmp = {};

View File

@@ -21,6 +21,9 @@ import { encrypt } from '../../libs/encryption';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import pusher from '../../libs/pusher';
import common from '../../../common';
import payments from '../../libs/payments';
import shared from '../../../common';
/**
* @apiDefine GroupBodyInvalid
@@ -106,6 +109,95 @@ api.createGroup = {
},
};
/**
* @api {post} /api/v3/groups/create-plan Create a Group and then redirect to the correct payment
* @apiName CreateGroupPlan
* @apiGroup Group
*
* @apiSuccess {Object} data The created group
*/
api.createGroupPlan = {
method: 'POST',
url: '/groups/create-plan',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body.groupToCreate));
req.checkBody('paymentType', res.t('paymentTypeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// @TODO: Change message
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
group.leader = user._id;
user.guilds.push(group._id);
let results = await Bluebird.all([user.save(), group.save()]);
let savedGroup = results[1];
// Analytics
let analyticsObject = {
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
};
res.analytics.track('join group', analyticsObject);
if (req.body.paymentType === 'Stripe') {
let token = req.body.id;
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
let groupId = savedGroup._id;
let email = req.body.email;
let headers = req.headers;
let coupon = req.query.coupon;
await payments.payWithStripe([
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
]);
} else if (req.body.paymentType === 'Amazon') {
let billingAgreementId = req.body.billingAgreementId;
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
let coupon = req.body.coupon;
let groupId = savedGroup._id;
let headers = req.headers;
await payments.subscribeWithAmazon([
billingAgreementId,
sub,
coupon,
user,
groupId,
headers,
]);
}
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise
let response = savedGroup.toJSON();
// the leader is the authenticated user
response.leader = {
_id: user._id,
profile: {name: user.profile.name},
};
res.respond(201, response); // do not remove chat flags data as we've just created the group
},
};
/**
* @api {get} /api/v3/groups Get groups for a user
* @apiName GetGroups
@@ -303,6 +395,8 @@ api.joinGroup = {
group.memberCount += 1;
if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
let promises = [group.save(), user.save()];
if (inviter) {
@@ -459,6 +553,8 @@ api.leaveGroup = {
await group.leave(user, req.query.keep);
if (group.purchased.plan && group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
_removeMessagesFromMember(user, group._id);
await user.save();
@@ -535,6 +631,7 @@ api.removeGroupMember = {
if (isInGroup) {
group.memberCount -= 1;
if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
if (group.quest && group.quest.leader === member._id) {
group.quest.key = undefined;

View File

@@ -245,7 +245,7 @@ api.updateTask = {
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
let oldCheckList = task.checklist;
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
@@ -254,6 +254,8 @@ api.updateTask = {
if (!challenge && task.userId && task.challenge && task.challenge.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else if (!group && task.userId && task.group && task.group.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else {
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
}
@@ -270,7 +272,15 @@ api.updateTask = {
let savedTask = await task.save();
if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask);
let updateCheckListItems = _.remove(sanitizedObj.checklist, function getCheckListsToUpdate (checklist) {
let indexOld = _.findIndex(oldCheckList, function findIndex (check) {
return check.id === checklist.id;
});
if (indexOld !== -1) return checklist.text !== oldCheckList[indexOld].text;
return false; // Only return changes. Adding and remove are handled differently
});
await group.updateTask(savedTask, {updateCheckListItems});
}
res.respond(200, savedTask);
@@ -508,13 +518,16 @@ api.addChecklistItem = {
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
let newCheckListItem = Tasks.Task.sanitizeChecklist(req.body);
task.checklist.push(newCheckListItem);
let savedTask = await task.save();
newCheckListItem.id = savedTask.checklist[savedTask.checklist.length - 1].id;
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask);
await group.updateTask(savedTask, {newCheckListItem});
}
},
};
@@ -676,7 +689,7 @@ api.removeChecklistItem = {
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask);
await group.updateTask(savedTask, {removedCheckListItemId: req.params.itemId});
}
},
};
@@ -941,6 +954,8 @@ api.deleteTask = {
throw new NotFound(res.t('taskNotFound'));
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
} else if (task.group.id && task.group.assignedUsers.indexOf(user._id) !== -1) {
throw new NotAuthorized(res.t('cantDeleteAssignedGroupTasks'));
}
if (task.type !== 'todo' || !task.completed) {

View File

@@ -1,5 +1,4 @@
import { authWithHeaders } from '../../../middlewares/auth';
import ensureDevelpmentMode from '../../../middlewares/ensureDevelpmentMode';
import Bluebird from 'bluebird';
import * as Tasks from '../../../models/task';
import { model as Group } from '../../../models/group';
@@ -22,7 +21,6 @@ let api = {};
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiName CreateGroupTasks
* @apiGroup Task
* @apiIgnore
*
* @apiParam {UUID} groupId The id of the group the new task(s) will belong to
*
@@ -31,7 +29,7 @@ let api = {};
api.createGroupTasks = {
method: 'POST',
url: '/tasks/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
@@ -55,7 +53,6 @@ api.createGroupTasks = {
* @api {get} /api/v3/tasks/group/:groupId Get a group's tasks
* @apiName GetGroupTasks
* @apiGroup Task
* @apiIgnore
*
* @apiParam {UUID} groupId The id of the group from which to retrieve the tasks
* @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
@@ -65,7 +62,7 @@ api.createGroupTasks = {
api.getGroupTasks = {
method: 'GET',
url: '/tasks/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
@@ -97,7 +94,7 @@ api.getGroupTasks = {
api.assignTask = {
method: 'POST',
url: '/tasks/:taskId/assign/:assignedUserId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -120,12 +117,22 @@ api.assignTask = {
throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned'));
}
let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields});
let groupFields = `${requiredGroupFields} chat`;
let group = await Group.getGroup({user, groupId: task.group.id, fields: groupFields});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.leader !== user._id && user._id !== assignedUserId) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
await group.syncTask(task, assignedUser);
// User is claiming the task
if (user._id === assignedUserId) {
let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text});
group.sendChat(message, user);
}
let promises = [];
promises.push(group.syncTask(task, assignedUser));
promises.push(group.save());
await Bluebird.all(promises);
res.respond(200, task);
},
@@ -145,7 +152,7 @@ api.assignTask = {
api.unassignTask = {
method: 'POST',
url: '/tasks/:taskId/unassign/:assignedUserId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -194,7 +201,7 @@ api.unassignTask = {
api.approveTask = {
method: 'POST',
url: '/tasks/:taskId/approve/:userId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -226,10 +233,15 @@ api.approveTask = {
task.group.approval.approved = true;
assignedUser.addNotification('GROUP_TASK_APPROVED', {
message: res.t('yourTaskHasBeenApproved'),
message: res.t('yourTaskHasBeenApproved', {taskText: task.text}),
groupId: group._id,
});
assignedUser.addNotification('SCORED_TASK', {
message: res.t('yourTaskHasBeenApproved', {taskText: task.text}),
scoreTask: task,
});
await Bluebird.all([assignedUser.save(), task.save()]);
res.respond(200, task);
@@ -241,7 +253,6 @@ api.approveTask = {
* @apiVersion 3.0.0
* @apiName GetGroupApprovals
* @apiGroup Task
* @apiIgnore
*
* @apiParam {UUID} groupId The id of the group from which to retrieve the approvals
*
@@ -250,7 +261,7 @@ api.approveTask = {
api.getGroupApprovals = {
method: 'GET',
url: '/approvals/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();

View File

@@ -91,6 +91,8 @@ api.checkout = {
let orderReferenceId = req.body.orderReferenceId;
let amount = 5;
// @TODO: Make thise use payment.subscribeWithAmazon
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
if (gift) {

View File

@@ -49,6 +49,9 @@ api.checkout = {
let groupId = req.query.groupId;
let coupon;
let response;
let subscriptionId;
// @TODO: Update this to use payments.payWithStripe
if (!token) throw new BadRequest('Missing req.body.id');
@@ -59,12 +62,20 @@ api.checkout = {
if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
}
response = await stripe.customers.create({
let customerObject = {
email: req.body.email,
metadata: { uuid: user._id },
card: token,
plan: sub.key,
});
};
if (groupId) {
customerObject.quantity = sub.quantity;
}
response = await stripe.customers.create(customerObject);
if (groupId) subscriptionId = response.subscriptions.data[0].id;
} else {
let amount = 500; // $5
@@ -91,6 +102,7 @@ api.checkout = {
sub,
headers: req.headers,
groupId,
subscriptionId,
});
} else {
let method = 'buyGems';
@@ -149,7 +161,9 @@ api.subscribeEdit = {
throw new NotFound(res.t('groupNotFound'));
}
if (!group.leader === user._id) {
let allowedManagers = [group.leader, group.purchased.plan.owner];
if (allowedManagers.indexOf(user._id) === -1) {
throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription'));
}
customerId = group.purchased.plan.customerId;

View File

@@ -11,11 +11,23 @@ import {
model as Group,
basicFields as basicGroupFields,
} from '../models/group';
import { model as Coupon } from '../models/coupon';
import { model as User } from '../models/user';
import {
NotAuthorized,
NotFound,
} from './errors';
import slack from './slack';
import nconf from 'nconf';
import stripeModule from 'stripe';
import amzLib from './amazonPayments';
import {
BadRequest,
} from './errors';
import cc from 'coupon-code';
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
let api = {};
@@ -49,6 +61,7 @@ api.createSubscription = async function createSubscription (data) {
let groupId;
let itemPurchased = 'Subscription';
let purchaseType = 'subscribe';
let emailType = 'subscription-begins';
// If we are buying a group subscription
if (data.groupId) {
@@ -66,7 +79,9 @@ api.createSubscription = async function createSubscription (data) {
recipient = group;
itemPurchased = 'Group-Subscription';
purchaseType = 'group-subscribe';
emailType = 'group-subscription-begins';
groupId = group._id;
recipient.purchased.plan.quantity = data.sub.quantity;
}
plan = recipient.purchased.plan;
@@ -98,11 +113,16 @@ api.createSubscription = async function createSubscription (data) {
// Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? today : undefined,
owner: data.user._id,
}).defaults({ // allow non-override if a plan was previously used
gemsBought: 0,
dateCreated: today,
mysteryItems: [],
}).value();
if (data.subscriptionId) {
plan.subscriptionId = data.subscriptionId;
}
}
// Block sub perks
@@ -119,7 +139,7 @@ api.createSubscription = async function createSubscription (data) {
}
if (!data.gift) {
txnEmail(data.user, 'subscription-begins');
txnEmail(data.user, emailType);
}
analytics.trackPurchase({
@@ -208,12 +228,29 @@ api.createSubscription = async function createSubscription (data) {
});
};
api.updateStripeGroupPlan = async function updateStripeGroupPlan (group, stripeInc) {
if (group.purchased.plan.paymentMethod !== 'Stripe') return;
let stripeApi = stripeInc || stripe;
let plan = shared.content.subscriptionBlocks.group_monthly;
await stripeApi.subscriptions.update(
group.purchased.plan.subscriptionId,
{
plan: plan.key,
quantity: group.memberCount + plan.quantity - 1,
}
);
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
};
// Sets their subscription to be cancelled later
api.cancelSubscription = async function cancelSubscription (data) {
let plan;
let group;
let cancelType = 'unsubscribe';
let groupId;
let emailType = 'cancel-subscription';
// If we are buying a group subscription
if (data.groupId) {
@@ -224,10 +261,13 @@ api.cancelSubscription = async function cancelSubscription (data) {
throw new NotFound(shared.i18n.t('groupNotFound'));
}
if (!group.leader === data.user._id) {
let allowedManagers = [group.leader, group.purchased.plan.owner];
if (allowedManagers.indexOf(data.user._id) === -1) {
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription'));
}
plan = group.purchased.plan;
emailType = 'group-cancel-subscription';
} else {
plan = data.user.purchased.plan;
}
@@ -252,7 +292,7 @@ api.cancelSubscription = async function cancelSubscription (data) {
await data.user.save();
}
txnEmail(data.user, 'cancel-subscription');
txnEmail(data.user, emailType);
if (group) {
cancelType = 'group-unsubscribe';
@@ -343,4 +383,180 @@ api.buyGems = async function buyGems (data) {
await data.user.save();
};
/**
* Allows for purchasing a user subscription, group subscription or gems with Stripe
*
* @param options
* @param options.token The stripe token generated on the front end
* @param options.user The user object who is purchasing
* @param options.gift The gift details if any
* @param options.sub The subscription data to purchase
* @param options.groupId The id of the group purchasing a subscription
* @param options.email The email enter by the user on the Stripe form
* @param options.headers The request headers to store on analytics
* @return undefined
*/
api.payWithStripe = async function payWithStripe (options, stripeInc) {
let [
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
] = options;
let response;
let subscriptionId;
// @TODO: We need to mock this, but curently we don't have correct Dependency Injection
let stripeApi = stripe;
if (stripeInc) stripeApi = stripeInc;
if (!token) throw new BadRequest('Missing req.body.id');
if (sub) {
if (sub.discount) {
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
coupon = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}).exec();
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
}
let customerObject = {
email,
metadata: { uuid: user._id },
card: token,
plan: sub.key,
};
if (groupId) {
customerObject.quantity = sub.quantity;
}
response = await stripeApi.customers.create(customerObject);
if (groupId) subscriptionId = response.subscriptions.data[0].id;
} else {
let amount = 500; // $5
if (gift) {
if (gift.type === 'subscription') {
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
} else {
amount = `${gift.gems.amount / 4 * 100}`;
}
}
response = await stripe.charges.create({
amount,
currency: 'usd',
card: token,
});
}
if (sub) {
await this.createSubscription({
user,
customerId: response.id,
paymentMethod: 'Stripe',
sub,
headers,
groupId,
subscriptionId,
});
} else {
let method = 'buyGems';
let data = {
user,
customerId: response.id,
paymentMethod: 'Stripe',
gift,
};
if (gift) {
let member = await User.findById(gift.uuid).exec();
gift.member = member;
if (gift.type === 'subscription') method = 'createSubscription';
data.paymentMethod = 'Gift';
}
await this[method](data);
}
};
/**
* Allows for purchasing a user subscription or group subscription with Amazon
*
* @param options
* @param options.billingAgreementId The Amazon billingAgreementId generated on the front end
* @param options.user The user object who is purchasing
* @param options.sub The subscription data to purchase
* @param options.coupon The coupon to discount the sub
* @param options.groupId The id of the group purchasing a subscription
* @param options.headers The request headers to store on analytics
* @return undefined
*/
api.subscribeWithAmazon = async function subscribeWithAmazon (options) {
let [
billingAgreementId,
sub,
coupon,
user,
groupId,
headers,
] = options;
if (!sub) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
if (sub.discount) { // apply discount
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}).exec();
if (!result) throw new NotAuthorized(shared.i18n.t('invalidCoupon'));
}
await amzLib.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: 'Habitica Subscription',
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: shared.uuid(),
StoreName: 'Habitica',
CustomInformation: 'Habitica Subscription',
},
},
});
await amzLib.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await amzLib.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: sub.price,
},
SellerAuthorizationNote: 'Habitica Subscription Payment',
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: 'Habitica Subscription Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'Habitica',
},
});
await this.createSubscription({
user,
customerId: billingAgreementId,
paymentMethod: 'Amazon Payments',
sub,
headers,
groupId,
});
};
module.exports = api;

View File

@@ -137,6 +137,7 @@ async function cronAsync (req, res) {
// Clear old completed todos - 30 days for free users, 90 for subscribers
// Do not delete challenges completed todos TODO unless the task is broken?
// Do not delete group completed todos
Tasks.Task.remove({
userId: user._id,
type: 'todo',
@@ -145,6 +146,7 @@ async function cronAsync (req, res) {
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(),
},
'challenge.id': {$exists: false},
'group.id': {$exists: false},
}).exec();
res.locals.wasModified = true; // TODO remove after v2 is retired

View File

@@ -13,6 +13,7 @@ import { groupChatReceivedWebhook } from '../libs/webhook';
import {
InternalServerError,
BadRequest,
NotAuthorized,
} from '../libs/errors';
import baseModel from '../libs/baseModel';
import { sendTxn as sendTxnEmail } from '../libs/email';
@@ -859,6 +860,11 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
let group = this;
let update = {};
let plan = group.purchased.plan;
if (group.memberCount <= 1 && group.privacy === 'private' && plan && plan.customerId && !plan.dateTerminated) {
throw new NotAuthorized(shared.i18n.t('cannotDeleteActiveGroup'));
}
let challenges = await Challenge.find({
_id: {$in: user.challenges},
group: group._id,
@@ -899,6 +905,13 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
promises.push(group.remove());
return await Bluebird.all(promises);
}
} else if (group.leader === user._id) { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id};
query._id = {$ne: user._id};
let seniorMember = await User.findOne(query).select('_id').exec();
// could be missing in case of public guild (that can have 0 members) with 1 member who is leaving
if (seniorMember) update.$set = {leader: seniorMember._id};
}
// otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
update.$inc = {memberCount: -1};
@@ -915,7 +928,16 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
return await Bluebird.all(promises);
};
schema.methods.updateTask = async function updateTask (taskToSync) {
/**
* Updates all linked tasks for a group task
*
* @param taskToSync The group task that will be synced
* @param options.newCheckListItem The new checklist item that needs to be synced to all assigned users
* @param options.removedCheckListItem The removed checklist item that needs to be removed from all assigned users
*
* @return The created tasks
*/
schema.methods.updateTask = async function updateTask (taskToSync, options = {}) {
let group = this;
let updateCmd = {$set: {}};
@@ -926,14 +948,51 @@ schema.methods.updateTask = async function updateTask (taskToSync) {
}
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
let taskSchema = Tasks[taskToSync.type];
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update({
let updateQuery = {
userId: {$exists: true},
'group.id': group.id,
'group.taskId': taskToSync._id,
}, updateCmd, {multi: true}).exec();
};
if (options.newCheckListItem) {
let newCheckList = {completed: false};
newCheckList.linkId = options.newCheckListItem.id;
newCheckList.text = options.newCheckListItem.text;
updateCmd.$push = { checklist: newCheckList };
}
if (options.removedCheckListItemId) {
updateCmd.$pull = { checklist: {linkId: {$in: [options.removedCheckListItemId]} } };
}
if (options.updateCheckListItems && options.updateCheckListItems.length > 0) {
let checkListIdsToRemove = [];
let checkListItemsToAdd = [];
options.updateCheckListItems.forEach(function gatherChecklists (updateCheckListItem) {
checkListIdsToRemove.push(updateCheckListItem.id);
let newCheckList = {completed: false};
newCheckList.linkId = updateCheckListItem.id;
newCheckList.text = updateCheckListItem.text;
checkListItemsToAdd.push(newCheckList);
});
updateCmd.$pull = { checklist: {linkId: {$in: checkListIdsToRemove} } };
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
delete updateCmd.$pull;
updateCmd.$push = { checklist: { $each: checkListItemsToAdd } };
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
return;
}
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
};
schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
@@ -952,11 +1011,13 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
if (userTags[i].name !== group.name) {
// update the name - it's been changed since
userTags[i].name = group.name;
userTags[i].group = group._id;
}
} else {
userTags.push({
id: group._id,
name: group.name,
group: group._id,
});
}
@@ -982,6 +1043,17 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
}
matchingTask.group.approval.required = taskToSync.group.approval.required;
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
// sync checklist
if (taskToSync.checklist) {
taskToSync.checklist.forEach(function syncCheckList (element) {
let newCheckList = {completed: false};
newCheckList.linkId = element.id;
newCheckList.text = element.text;
matchingTask.checklist.push(newCheckList);
});
}
if (!matchingTask.notes) matchingTask.notes = taskToSync.notes; // don't override the notes, but provide it if not provided
if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id); // add tag if missing

View File

@@ -1,8 +1,12 @@
import mongoose from 'mongoose';
import baseModel from '../libs/baseModel';
import validator from 'validator';
export let schema = new mongoose.Schema({
planId: String,
subscriptionId: String,
owner: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']},
quantity: {type: Number, default: 1},
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
customerId: String, // Billing Agreement Id in case of Amazon Payments
dateCreated: Date,

View File

@@ -14,6 +14,7 @@ export let schema = new Schema({
},
name: {type: String, required: true},
challenge: {type: String},
group: {type: String},
}, {
strict: true,
minimize: false, // So empty objects are returned

View File

@@ -206,6 +206,7 @@ let dailyTodoSchema = () => {
text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation
_id: false,
id: {type: String, default: shared.uuid, required: true, validate: [validator.isUUID, 'Invalid uuid.']},
linkId: {type: String},
}],
};
};

View File

@@ -16,6 +16,7 @@ const NOTIFICATION_TYPES = [
'GROUP_TASK_APPROVED',
'LOGIN_INCENTIVE',
'GROUP_INVITE_ACCEPTED',
'SCORED_TASK',
];
const Schema = mongoose.Schema;

View File

@@ -34,8 +34,9 @@
button(type='button', ng-click='User.deleteTag({params:{id:tag.id}})')
span.glyphicon.glyphicon-trash
ul(ng-if='!_editing', hrpg-sort-tags)
li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: tag.challenge}', ng-repeat='tag in user.tags', bindonce='user.tags')
li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: showChallengeClass(tag)}', ng-repeat='tag in user.tags', bindonce='user.tags')
a(ng-click='toggleFilter(tag)')
span.glyphicon.glyphicon-bullhorn(ng-if="::tag.challenge")
markdown(text='tag.name')
| {{tag}}
// <li class="{#unless activeFilters(users[_userId].filters)}hidden{/}">

View File

@@ -367,64 +367,4 @@ script(id='partials/options.settings.notifications.html', type="text/ng-template
small=env.t('unsubscribeAllEmailsText')
script(id='partials/options.settings.subscription.html',type='text/ng-template')
//-h2=env.t('individualSub')
.container-fluid(ng-init='_subscription={key:"basic_earned"}')
h3= env.t('benefits')
.row
.col-md-6
+subPerks()
.container-fluid.slight-vertical-padding(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4=env.t('subscribeUsing')
.row.text-center
.col-xs-12.col-md-3
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-12.col-md-3
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-12.col-md-3
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))
.col-md-6
table.table.alert.alert-info(ng-if='user.purchased.plan.customerId')
tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!user.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='user.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[user.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[user.purchased.plan.planId].months}}', plan: '{{user.purchased.plan.paymentMethod}}'})
tr(ng-if='user.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{user.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{user.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{user.purchased.plan.consecutive.trinkets}}
div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')= env.t("resubscribe")
.form-group.reduce-top-margin
.radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit: "discount==true" | orderBy:"months"')
label
input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key')
span(ng-show='block.original')
span.label.label-success.line-through
| ${{:: block.original }}
=env.t('subscriptionRateText', {price:'{{::block.price}}', months: '{{::block.months}}'})
span(ng-hide='block.original')
=env.t('subscriptionRateText', {price: '{{::block.price}}', months: '{{block.months}}'})
.form-inline
.form-group
input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder'))
.form-group
button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply")
div(ng-if='user.purchased.plan.customerId')
.btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub')
include ./settings/subscription

View File

@@ -0,0 +1,63 @@
script(id='partials/options.settings.subscription.html',type='text/ng-template', ng-init="groupPane = 'subscription'")
//-h2=env.t('individualSub')
//- +groupSubscription
.container-fluid(ng-init='_subscription={key:"basic_earned"}')
h3= env.t('benefits')
.row
.col-md-6
+subPerks()
.col-md-6
table.table.alert.alert-info(ng-if='user.purchased.plan.customerId')
tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!user.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='user.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[user.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[user.purchased.plan.planId].months}}', plan: '{{user.purchased.plan.paymentMethod}}'})
tr(ng-if='user.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{user.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{user.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{user.purchased.plan.consecutive.trinkets}}
div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')= env.t("resubscribe")
.form-group.reduce-top-margin
.radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit: "discount==true" | orderBy:"months"', ng-if="block.type !== 'group'")
label
input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key')
span(ng-show='block.original')
span.label.label-success.line-through
| ${{:: block.original }}
=env.t('subscriptionRateText', {price:'{{::block.price}}', months: '{{::block.months}}'})
span(ng-hide='block.original')
=env.t('subscriptionRateText', {price: '{{::block.price}}', months: '{{block.months}}'})
.form-inline
.form-group
input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder'))
.form-group
button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply")
div(ng-if='user.purchased.plan.customerId')
.btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub')
.container-fluid.slight-vertical-padding(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
small.muted=env.t('subscribeUsing')
.row.text-center
.col-xs-4
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-4
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-4
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))

View File

@@ -1,28 +0,0 @@
form.col-md-12.form-horizontal(ng-submit='create(newGroup)')
.form-group
label.control-label(for='new-group-name')=env.t('newGroupName', {groupType: "{{text}}"})
input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder=env.t('newGroupName', {groupType: "{{text}}"}), ng-model='newGroup.name')
.form-group
label(for='new-group-description')=env.t('description')
textarea.form-control#new-group-description.option-content(cols='3', placeholder=env.t('description'), ng-model='newGroup.description')
.form-group(ng-show='type=="guild"')
.radio
label
input(type='radio', name='new-group-privacy', value='public', ng-model='newGroup.privacy')
=env.t('public')
.radio
label
input(type='radio', name='new-group-privacy', value='private', ng-model='newGroup.privacy')
=env.t('inviteOnly')
br
input.btn.btn-default(type='submit', ng-disabled='!newGroup.privacy && !newGroup.name', value=env.t('create'))
span.gem-cost= '4 ' + env.t('gems')
p
small=env.t('gemCost')
.form-group
.checkbox
label
input(type='checkbox', ng-model='newGroup.leaderOnly.challenges')
=env.t('leaderOnlyChallenges')
.form-group(ng-show='type=="party"')
input.btn.btn-default.form-control(type='submit', value=env.t('create'))

View File

@@ -1,3 +1,6 @@
include ./groups/group-subscription
include ./groups/group-plans-benefits
a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter', popover-title=env.t('guildBankPop1'), popover=env.t('guildBankPop2'), popover-placement='left')
// <span class="task-action-btn tile flush bright add-gems-btn"></span>
span.task-action-btn.tile.flush.neutral
@@ -6,7 +9,21 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
=' ' + env.t('guildGems')
.container-fluid
.row
ul.options-menu(ng-show='group.privacy === "private"')
li
a(ng-click="groupPanel = 'chat'")=env.t('groupHomeTitle')
li(ng-show='group.purchased.active')
a(ng-click="groupPanel = 'tasks'")=env.t('groupTasksTitle')
li(ng-show='group.purchased.active && group.leader._id === user._id')
a(ng-click="groupPanel = 'approvals'")=env.t('approvalsTitle')
li
a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && group.purchased.plan.customerId')=env.t('subscription')
a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && !group.purchased.plan.customerId')=env.t('upgradeTitle')
.tab-content
.tab-pane.active
.row(ng-show="groupPanel == 'chat'")
.col-md-4
// ------ Bosses -------
@@ -148,20 +165,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
.popover-content
markdown(text='group._editing ? groupCopy.leaderMessage : group.leaderMessage')
ul.options-menu(ng-init="groupPane = 'chat'", ng-hide="true")
//- li
//- a(ng-click="groupPane = 'chat'")=env.t('chat')
//- li
//- a(ng-click="groupPane = 'tasks'", ng-show='group.purchased.active')=env.t('tasks')
//- li
//- a(ng-click="groupPane = 'approvals'", ng-show='group.purchased.active && group.leader._id === user._id')=env.t('approvals')
//- li
//- a(ng-click="groupPane = 'subscription'", ng-show='group.leader._id === user._id')=env.t('subscription')
.tab-content
.tab-pane.active
div(ng-controller='ChatCtrl', ng-show="groupPane == 'chat'")
div(ng-controller='ChatCtrl')
.alert.alert-info.alert-sm(ng-if='group.memberCount > Shared.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF')=env.t('largeGroupNote')
h3=env.t('chat')
include ./chat-box
@@ -170,44 +174,9 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
h4(ng-if='group.chat.length < 1 && group.type === "party"')=env.t('partyChatEmpty')
h4(ng-if='group.chat.length < 1 && group.type === "guild"')=env.t('guildChatEmpty')
group-tasks(ng-show="groupPane == 'tasks'")
group-tasks(ng-show="groupPanel == 'tasks'")
group-approvals(ng-show="groupPane == 'approvals'", ng-if="group.leader._id === user._id", group="group")
group-approvals(ng-show="groupPanel == 'approvals'", ng-if="group.leader._id === user._id", group="group")
//TODO: This can be a directive and the group/user can be an object passed via attribute
div(ng-show="groupPane == 'subscription'")
.col-md-12
table.table.alert.alert-info(ng-if='group.purchased.plan.customerId')
tr(ng-if='group.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(group.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!group.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='group.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[group.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[group.purchased.plan.planId].months}}', plan: '{{group.purchased.plan.paymentMethod}}'})
tr(ng-if='group.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{group.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{group.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{group.purchased.plan.consecutive.trinkets}}
+groupSubscription
div(ng-if='group.purchased.plan.customerId')
.btn.btn-primary(ng-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit({groupId: group.id})')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!group.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription({group: group})')=env.t('cancelSub')
.container-fluid.slight-vertical-padding(ng-if='!group.purchased.plan.customerId || (group.purchased.plan.customerId && group.purchased.plan.dateTerminated)', ng-init="_subscription.key='group_monthly'")
small.muted=env.t('subscribeUsing')
.row.text-center
.col-xs-4
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-4
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-4
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))

View File

@@ -0,0 +1,29 @@
mixin groupCreateForm()
form.col-md-12.form-horizontal(ng-submit='create(newGroup)')
.form-group
label.control-label(for='new-group-name')=env.t('newGroupName', {groupType: "{{text}}"})
input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder=env.t('newGroupName', {groupType: "{{text}}"}), ng-model='newGroup.name')
.form-group
label(for='new-group-description')=env.t('description')
textarea.form-control#new-group-description.option-content(cols='3', placeholder=env.t('description'), ng-model='newGroup.description')
.form-group(ng-show='type=="guild"')
.radio
label
input(type='radio', name='new-group-privacy', value='public', ng-model='newGroup.privacy')
=env.t('public')
.radio
label
input(type='radio', name='new-group-privacy', value='private', ng-model='newGroup.privacy')
=env.t('inviteOnly')
br
input.btn.btn-default(type='submit', ng-disabled='!newGroup.privacy && !newGroup.name', value=env.t('create'))
span.gem-cost= '4 ' + env.t('gems')
p
small=env.t('gemCost')
.form-group
.checkbox
label
input(type='checkbox', ng-model='newGroup.leaderOnly.challenges')
=env.t('leaderOnlyChallenges')
.form-group(ng-show='type=="party"')
input.btn.btn-default.form-control(type='submit', value=env.t('create'))

View File

@@ -0,0 +1,31 @@
mixin groupPlansBenefits()
h2.text-center=env.t('groupBenefitsTitle')
.row(style="font-size: 2rem;")
.col-md-6.col-md-offset-3=env.t('groupBenefitsDescription')
.row
.col-md-5.col-md-offset-4
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitOneTitle')
span=env.t('groupBenefitOneDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitTwoTitle')
span=env.t('groupBenefitTwoDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitThreeTitle')
span=env.t('groupBenefitThreeDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitFourTitle')
span=env.t('groupBenefitFourDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitFiveTitle')
span=env.t('groupBenefitFiveDescription')

View File

@@ -0,0 +1,40 @@
include ./create-group
script(type='text/ng-template', id='partials/options.social.groupPlans.html')
div(ng-show='activePage === PAGES.BENEFITS')
+groupPlansBenefits
br
br
.row
.col-sm-6.col-sm-offset-3
a.btn.btn-primary.btn-lg.btn-block(ng-click="changePage(PAGES.CREATE_GROUP)")=env.t('createAGroup')
div(ng-show='activePage === PAGES.CREATE_GROUP')
h2.text-center=env.t('createAGroup')
.col-xs-12
+groupCreateForm
br
br
.row
.col-sm-6.col-sm-offset-3
a.btn.btn-primary.btn-lg.btn-block(ng-click="createGroup()", ng-disabled="!newGroupIsReady()")=env.t('create')
div(ng-show='activePage === PAGES.UPGRADE_GROUP')
h2.text-center=env.t('upgradeTitle')
.row.text-center
.col-md-6.col-md-offset-3
a.purchase.btn.btn-primary(ng-click='upgradeGroup(PAYMENTS.STRIPE)')=env.t('card')
a.purchase(ng-click='upgradeGroup(PAYMENTS.AMAZON)')
img(src='https://payments.amazon.com/gp/cba/button', alt=env.t('amazonPayments'))
//- .col-xs-4
//- a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
//- img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.row
.col-md-6.col-md-offset-3
br
.text-center=env.t('groupSubscriptionPrice')

View File

@@ -0,0 +1,50 @@
// @TODO: This can be a directive and the group/user can be an object passed via attribute
mixin groupSubscription()
div(ng-show="groupPanel == 'subscription'")
.col-md-12
.col-md-12
div(ng-hide="group.purchased.plan.customerId")
+groupPlansBenefits
.row
.col-md-12
br
table.table.alert.alert-info(ng-if='group.purchased.plan.customerId')
tr(ng-if='group.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(group.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!group.purchased.plan.dateTerminated'): td
h3=env.t('subscribed')
p(ng-if='group.purchased.plan.planId')=env.t('groupSubscriptionPrice')
tr(ng-if='group.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{group.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{group.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{group.purchased.plan.consecutive.trinkets}}
div(ng-if='group.purchased.plan.customerId')
.btn.btn-primary(ng-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit({groupId: group.id})')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!group.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription({group: group})')=env.t('cancelSub')
.row
.col-md-12(ng-if='!group.purchased.plan.customerId || (group.purchased.plan.customerId && group.purchased.plan.dateTerminated)', ng-init="_subscription.key='group_monthly'")
.row.text-center
h3 Upgrade My Group
div
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})', ng-disabled='!_subscription.key')= env.t('card')
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))
//- .col-xs-4
//- a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
//- img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.row(ng-if='!group.purchased.plan.customerId')
.col-md-6.col-md-offset-3
br
.text-center=env.t('groupSubscriptionPrice')

View File

@@ -1,6 +1,13 @@
script(type='text/ng-template', id='partials/groups.tasks.approvals.html')
.panel-group(ng-repeat="approval in approvals")
.row(style="margin-bottom: 2rem;")
.col-md-12
button.btn.btn-primary(ng-click='refreshApprovals()', ng-hide="loading")=env.t('refreshApprovals')
button.btn.btn-primary(ng-disabled="true", ng-show="loading")=env.t('loading')
.well(ng-show="group.approvals.length === 0")=env.t('blankApprovalsDescription')
.panel-group(ng-repeat="approval in group.approvals")
.panel.panel-default
.panel-heading
span {{approvalTitle(approval)}}
a.btn.btn-sm.btn-success.pull-right(ng-click="approve(approval.group.taskId, approval.userId._id)")=env.t('approve')
a.btn.btn-sm.btn-success.pull-right(ng-click="approve(approval.group.taskId, approval.userId._id, approval.userId.profile.name, $index)")=env.t('approve')

View File

@@ -4,4 +4,10 @@ include ./group-members-autocomplete
include ./group-tasks-approvals
script(type='text/ng-template', id='partials/groups.tasks.html')
.row(style="margin-bottom: 2rem;")
.col-md-12
button.btn.btn-primary(ng-click='refreshTasks()', ng-hide="loading")=env.t('refreshGroupTasks')
button.btn.btn-primary(ng-disabled="true", ng-show="loading")=env.t('loading')
.row
habitrpg-tasks(main=false)

View File

@@ -7,6 +7,8 @@ include ./quests/index
include ./chat-message
include ./party
include ./groups/group-tasks
include ./groups/group-plans
include ./groups/create-group
script(type='text/ng-template', id='partials/options.social.inbox.html')
.options-blurbmenu
@@ -65,7 +67,7 @@ script(type='text/ng-template', id='partials/options.social.guilds.detail.html')
script(type='text/ng-template', id='partials/options.social.guilds.create.html')
div.col-xs-12
include ./create-group
+groupCreateForm
script(type='text/ng-template', id='partials/options.social.guilds.html')
ul.options-submenu
@@ -105,6 +107,9 @@ script(type='text/ng-template', id='partials/options.social.html')
li(ng-class="{ active: $state.includes('options.social.hall') }")
a(ui-sref='options.social.hall.heroes')
=env.t('hall')
li(ng-class="{ active: $state.includes('options.social.groupPlans') }")
a(ui-sref='options.social.groupPlans')
=env.t('groupPlansTitle')
.tab-content
.tab-pane.active

View File

@@ -36,6 +36,8 @@ nav.toolbar(ng-controller='MenuCtrl')
a(ui-sref='options.social.challenges')=env.t('challenges')
li
a(ui-sref='options.social.hall.heroes')=env.t('hall')
li
a(ui-sref='options.social.groupPlans')=env.t('groupPlansTitle')
ul.toolbar-submenu
li
a(ui-sref='options.inventory.drops')=env.t('market')
@@ -120,6 +122,8 @@ nav.toolbar(ng-controller='MenuCtrl')
a(ui-sref='options.social.challenges')=env.t('challenges')
li
a(ui-sref='options.social.hall.heroes')=env.t('hall')
li
a(ui-sref='options.social.groupPlans')=env.t('groupPlansTitle')
li.toolbar-button-dropdown
a(ui-sref='options.inventory.drops', data-close-menu)
span=env.t('inventory')

View File

@@ -24,6 +24,7 @@ include ./enable-desktop-notifications.jade
include ./login-incentives.jade
include ./login-incentives-reward-unlocked.jade
include ./generic.jade
include ./tasks-edit.jade
//- Settings
script(type='text/ng-template', id='modals/change-day-start.html')

View File

@@ -0,0 +1,7 @@
script(type='text/ng-template', id='modals/task-edit.html')
.modal-content.task-modal(style='min-width:28em', class='{{taskStatus}}', id="task-{{task._id}}")
.modal-body.text-center(style='padding-bottom:0')
| {{scope}}
include ../tasks/edit/index
.modal-footer(style='margin-top:0')
.container-fluid

View File

@@ -1,5 +1,6 @@
div(ng-if='task._editing')
.task-options
h2 {{task._edit.text}}
// Broken Challenge
.well(ng-if='task.challenge.broken')
@@ -33,7 +34,7 @@ div(ng-if='task._editing')
include ./checklist
form(ng-submit='saveTask(task,false,true)')
form
include ./text_notes
include ./habits/plus_minus
@@ -49,4 +50,11 @@ div(ng-if='task._editing')
include ./advanced_options
.save-close
button(type='submit')=env.t('saveAndClose')
button(type='submit', ng-click='saveTask(task,false,true); $close()')=env.t('saveAndClose')
br
br
.save-close
button(ng-click='cancelTaskEdit(task); $close()')=env.t('cancel')

View File

@@ -1,5 +1,5 @@
fieldset.option-group(ng-if='!$state.includes("options.social.challenges")')
fieldset.option-group(ng-if='!$state.includes("options.social.challenges") && !obj.leader')
p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags')
label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags')
label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags', style='text-align: left;')
input(type='checkbox', ng-checked="task.tags.indexOf(tag.id) !== -1", ng-click="updateTaskTags(tag.id, task)")
markdown(text='tag.name')

View File

@@ -6,7 +6,7 @@ include ./task_view/mixins
script(id='templates/habitrpg-tasks.html', type="text/ng-template")
.tasks-lists.container-fluid
.row
.col-md-3.col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-3": obj.type }')
.col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-3": !obj.type }')
.task-column(class='{{::list.type}}s')
include ./task_view/graph

View File

@@ -4,6 +4,13 @@
span(ng-if='task.type=="todo" && task.date')
span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}}
// Approval requested
| &nbsp;
span(ng-show='task.group.approval.requested && !task.group.approval.approved')
span(tooltip=env.t('approvalRequested'))
span=env.t('approvalRequested')
| &nbsp;
// Streak
| &nbsp;
span(ng-show='task.streak') {{task.streak}}&nbsp;
@@ -24,18 +31,21 @@
a.badge(ng-if='task.checklist[0]', ng-class='{"badge-success":checklistCompletion(task.checklist) == task.checklist.length}', ng-click='collapseChecklist(task)', tooltip=env.t('expandCollapse'))
|{{checklistCompletion(task.checklist)}}/{{task.checklist.length}}
span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)')
// edit
a(ng-hide='task._editing', ng-click='editTask(task, user)', tooltip=env.t('edit'))
a(ng-hide='task._editing || (checkGroupAccess && !checkGroupAccess(obj))', ng-click='editTask(task, user, Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main))', tooltip=env.t('edit'))
| &nbsp;
span.glyphicon.glyphicon-pencil(ng-hide='task._editing')
| &nbsp;
a(ng-hide='!task._editing', ng-click='cancelTaskEdit(task)', tooltip=env.t('cancel'))
span.glyphicon.glyphicon-remove(ng-hide='!task._editing')
| &nbsp;
// save
a(ng-hide='!task._editing', ng-click='saveTask(task)', tooltip=env.t('save'))
span.glyphicon.glyphicon-ok(ng-hide='!task._editing')
| &nbsp;
//challenges
span(ng-if='task.challenge.id')
span(ng-if='task.challenge.broken')
@@ -44,8 +54,9 @@
span(ng-if='!task.challenge.broken')
span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge'))
| &nbsp;
// delete
a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-click='removeTask(task, obj)', tooltip=env.t('delete'))
a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-hide="(checkGroupAccess && !checkGroupAccess(obj))" ng-click='removeTask(task, obj)', tooltip=env.t('delete'))
span.glyphicon.glyphicon-trash
| &nbsp;

View File

@@ -5,13 +5,11 @@ li(id='task-{{::task._id}}',
ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)',
ng-show='shouldShow(task, list, user.preferences)',
popover-trigger='mouseenter', popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}',
data-popover-html="{{task.popoverOpen ? '' : task.notes | markdown}}")
data-popover-html="{{::taskPopover(task) | markdown}}")
ng-form(name='taskForm')
include ./meta_controls
include ./task_view/index
include ./edit/index
div(class='{{obj._id}}{{task._id}}-chart', ng-show='charts[obj._id+task._id]')

View File

@@ -1,4 +1,4 @@
form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(list, obj)', novalidate)
form.task-add(name='new{{list.type}}form', ng-hide='obj._locked || (checkGroupAccess && !checkGroupAccess(obj))', ng-submit='addTask(list, obj)', novalidate)
textarea(rows='6', focus-element='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(list, obj)"}', required)
input(type='text', focus-element='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required)
button(type='submit', ng-disabled='new{{list.type}}form.$invalid')

View File

@@ -27,7 +27,7 @@
span.reward-cost {{task.value}}
// Daily & Todos
span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"')
span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"', ng-class='{"group-yesno": !!obj.leader}')
input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox',
ng-model='task.completed', ng-if='$state.includes("tasks")',
ng-change='changeCheck(task)'