From 31185a4b120e87c8be8c0e40e74062c700796845 Mon Sep 17 00:00:00 2001 From: Thaum Rystra Date: Thu, 7 May 2020 14:52:18 +0200 Subject: [PATCH] Invite system created, mostly done, but timeboxed for now --- app/.meteor/packages | 1 + app/.meteor/versions | 1 + app/imports/api/users/Invites.js | 153 ++++++++++++++++++ app/imports/api/users/Users.js | 11 +- app/imports/api/users/linkWithPatreon.js | 20 +++ .../api/users/patreon/getEntitledCents.js | 9 ++ app/imports/api/users/patreon/patreon.js | 137 ++++++++++++++++ app/imports/api/users/patreon/tiers.js | 66 ++++++++ app/imports/server/publications/users.js | 27 ++-- app/imports/ui/layouts/AppLayout.vue | 4 +- app/imports/ui/pages/Account.vue | 126 ++++----------- .../ui/properties/forms/EffectForm.vue | 1 - app/imports/ui/router.js | 5 +- app/packages/patreon-oauth/patreon_server.js | 1 + 14 files changed, 440 insertions(+), 122 deletions(-) create mode 100644 app/imports/api/users/Invites.js create mode 100644 app/imports/api/users/linkWithPatreon.js create mode 100644 app/imports/api/users/patreon/getEntitledCents.js create mode 100644 app/imports/api/users/patreon/patreon.js create mode 100644 app/imports/api/users/patreon/tiers.js diff --git a/app/.meteor/packages b/app/.meteor/packages index 0dd00e08..ffb76079 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -48,3 +48,4 @@ aldeed:collection2@3.0.0 aldeed:schema-index akryum:vue-component accounts-patreon +bozhao:link-accounts diff --git a/app/.meteor/versions b/app/.meteor/versions index 7205fac5..49e4995e 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -21,6 +21,7 @@ binary-heap@1.0.11 blaze@2.3.4 blaze-tools@1.0.10 boilerplate-generator@1.7.0 +bozhao:link-accounts@2.1.1 caching-compiler@1.2.2 caching-html-compiler@1.1.3 callback-hook@1.3.0 diff --git a/app/imports/api/users/Invites.js b/app/imports/api/users/Invites.js new file mode 100644 index 00000000..ba621b2d --- /dev/null +++ b/app/imports/api/users/Invites.js @@ -0,0 +1,153 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { getUserTier } from '/imports/api/users/patreon/tiers.js'; + +let Invites= new Mongo.Collection('invites'); + +let InviteSchema = new SimpleSchema({ + inviter: { + type: String, + regEx: SimpleSchema.RegEx.Id, + index: 1, + }, + invitee: { + type: String, + regEx: SimpleSchema.RegEx.Id, + optional: true, + index: 1, + }, + inviteToken: { + type: String, + optional: true, + index: 1, + unique: 1, + }, + isFunded: { + type: Boolean, + }, + isRedundant: { + type: Boolean, + optional: true, + }, + // The timestamp of when the invitee was confirmed + // Older invites have priority over newer ones + dateConfirmed: { + type: Date, + optional: true, + }, +}); + +if (Meteor.isServer){ + Accounts.onLogin(function({user}){ + alignInvitesWithPatreonTier(user); + }); +} + +function alignInvitesWithPatreonTier(user){ + const tier = getUserTier(user); + let availableInvites = tier.invites; + let currentlyFundedInvites = []; + let currenltyUnfundedInvites = []; + Invites.find({ + inviter: user._id + }).forEach(invite => { + if (invite.isFunded){ + currentlyFundedInvites.push(invite); + } else { + currenltyUnfundedInvites.push(invite); + } + }); + + // Return early if no work needs doing to skip sorting + if (currentlyFundedInvites.length === availableInvites) return; + + // Sort the invites by date forwards and backwards + currentlyFundedInvites.sort((a, b) => a.dateConfirmed - b.dateConfirmed); + currenltyUnfundedInvites.sort((a, b) => b.dateConfirmed - a.dateConfirmed); + + // Defund or delete excess invites + while (currentlyFundedInvites.length > availableInvites){ + let inviteToDefund = currentlyFundedInvites.pop(); + if (inviteToDefund.invitee){ + Invites.update(inviteToDefund._id, {$set: {isFunded: false}}); + } else { + Invites.remove(inviteToDefund._id); + } + } + // Fund unfunded invites or insert new ones + while (currentlyFundedInvites.length < availableInvites){ + if (currenltyUnfundedInvites.length){ + let inviteToFund = currenltyUnfundedInvites.pop(); + currentlyFundedInvites.push(inviteToFund); + Invites.update(inviteToFund._id, {$set: {isFunded: true}}); + } else { + let inviteId = Invites.insert({inviter: user._id, isFunded: true}); + currentlyFundedInvites.push({_id: inviteId}); + } + } +} + +const getInviteToken = new ValidatedMethod({ + name: 'Invites.methods.getToken', + validate: new SimpleSchema({ + inviteId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + run({inviteId}) { + let invite = Invites.findOne(inviteId); + if (this.userId !== invite.inviter) { + throw new Meteor.Error('Invites.methods.getToken.denied', + 'You need to be the inviter of the invite to create a token'); + } + if (invite.inviteToken){ + return invite.inviteToken; + } else { + let inviteToken = Random.id(5); + Invites.update(inviteId, {$set: {inviteToken}}) + return inviteToken; + } + }, +}); + +const acceptInviteToken = new ValidatedMethod({ + name: 'Invites.methods.acceptToken', + validate: new SimpleSchema({ + inviteToken: { + type: String, + }, + }).validator(), + run({inviteToken}) { + if (this.userId) { + throw new Meteor.Error('Invites.methods.acceptToken.denied', + 'You need to be the logged in to accept a token'); + } + let invite = Invites.findOne({inviteToken}); + if (!invite){ + throw new Meteor.Error('Invites.methods.acceptToken.notFound', + 'No invite could be found for this token'); + } + // If the invitee is already filled, fix unexpected case by deleting the token + if (invite.invitee){ + Invites.update(invite._id, { + $unset: {inviteToken: 1} + }); + throw new Meteor.Error('Invites.methods.acceptToken.alreadyAccepted', + 'This invite already has an invitee, and shouldn\'t have a token'); + } + if (this.userId === invite.inviter){ + throw new Meteor.Error('Invites.methods.acceptToken.ownToken', + 'You can\'t accept your own invite'); + } + Invites.update(invite._id, { + $set: {invitee: this.userId}, + $unset: {inviteToken: 1}, + }); + }, +}); + +Invites.attachSchema(InviteSchema); + +export default Invites; +export { alignInvitesWithPatreonTier, getInviteToken, acceptInviteToken }; diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js index e13e2864..35b6b585 100644 --- a/app/imports/api/users/Users.js +++ b/app/imports/api/users/Users.js @@ -1,4 +1,5 @@ import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; const userSchema = new SimpleSchema({ username: { @@ -140,13 +141,3 @@ Meteor.users.findUserByUsernameOrEmail = new ValidatedMethod({ return user && user._id; } }); - -export function getEntitledCentsOfUser(user){ - if (!user) return 0; - const patreon = user.services && user.services.patreon; - if (!patreon) return 0; - let entitledCents = patreon.entitledCents || 0; - let overrideCents = patreon.entitledCentsOverride || 0; - if (overrideCents > entitledCents) entitledCents = overrideCents; - return entitledCents; -} diff --git a/app/imports/api/users/linkWithPatreon.js b/app/imports/api/users/linkWithPatreon.js new file mode 100644 index 00000000..05ec62fb --- /dev/null +++ b/app/imports/api/users/linkWithPatreon.js @@ -0,0 +1,20 @@ +// Adds accounts-patreon support to bozhao:link-accounts +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +export default function linkWithPatreon(options, callback) { + if (!Meteor.userId()) { + throw new Meteor.Error(402, 'Please login to an existing account before link.'); + } + if (!Package['patreon-oauth']) { + throw new Meteor.Error(403, 'Please include patreon-oauth package'); + } + + if (!callback && typeof options === 'function') { + callback = options; + options = null; + } + + const credentialRequestCompleteCallback = Accounts.oauth.linkCredentialRequestCompleteHandler(callback); + Package['patreon-oauth'].Patreon.requestCredential(options, credentialRequestCompleteCallback); +} diff --git a/app/imports/api/users/patreon/getEntitledCents.js b/app/imports/api/users/patreon/getEntitledCents.js new file mode 100644 index 00000000..2ee92af1 --- /dev/null +++ b/app/imports/api/users/patreon/getEntitledCents.js @@ -0,0 +1,9 @@ +export default function getEntitledCents(user){ + if (!user) return 0; + const patreon = user.services && user.services.patreon; + if (!patreon) return 0; + let entitledCents = patreon.entitledCents || 0; + let overrideCents = patreon.entitledCentsOverride || 0; + if (overrideCents > entitledCents) entitledCents = overrideCents; + return entitledCents; +} diff --git a/app/imports/api/users/patreon/patreon.js b/app/imports/api/users/patreon/patreon.js new file mode 100644 index 00000000..0d0c1fb7 --- /dev/null +++ b/app/imports/api/users/patreon/patreon.js @@ -0,0 +1,137 @@ +import request from 'request'; + +if (!Meteor.isServer) throw 'Server only, do not import this code in the client'; + +const config = ServiceConfiguration.configurations.findOne({service: 'patreon'}); +const getIdentity = function(accessToken, callback){ + request({ + uri: 'https://www.patreon.com/api/oauth2/v2/identity', + headers:{ + Authorization: 'Bearer ' + accessToken, + }, + qs: { + 'include': 'memberships', + 'fields[member]': 'currently_entitled_amount_cents', + } + }, callback); +}; + +// Should return a new access token for the user +// callback is called with (error, response, body) +const refreshAccessToken = Meteor.wrapAsync(function(refreshToken, userId, callback){ + request({ + method: 'POST', + uri: 'https://www.patreon.com/api/oauth2/token', + qs: { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.secret, + } + }, Meteor.bindEnvironment((error, response, body) => { + // Should return an access token, valid for 1 month, which needs to be + // stored and used to make requests on behalf of the user + if (error){ + if (callback){ + callback(error); + return; + } else { + throw error; + } + } + let token; + try { + token = JSON.parse(body); + writePatreonToken(userId, token); + callback(undefined, token.access_token); + } catch(error) { + if (callback){ + callback(error); + return; + } else { + throw error; + } + } + })); +}); + +const updateIdentity = Meteor.wrapAsync(function(accessToken, userId, callback){ + getIdentity(accessToken, Meteor.bindEnvironment((error, response, body) => { + if (error){ + throw error; + } + try { + let identity = JSON.parse(body); + let membership = identity.included[0]; + let entitledAmount = membership && membership.attributes + .currently_entitled_amount_cents || 0; + writeEntitledCents(userId, entitledAmount); + if (callback) callback(); + } catch(error) { + if(callback) { + callback(error); + } else { + throw error; + } + } + })); +}); + +const updatePatreonDetails = function(user){ + if (!user) { + throw new Meteor.Error('no-user', 'User must be provided to update patreon details'); + } + if (!user.services.patreon || !user.services.patreon.accessToken){ + throw new Meteor.Error('no-patreon-access', 'Patreon access token not found for this user'); + } + let accessToken = user.services.patreon.accessToken; + if (user.services.patreon.tokenExpiryDate < new Date()){ + // Token expired, refresh it before continuing + accessToken = refreshAccessToken(user.services.patreon.refreshToken, user._id); + } + updateIdentity(accessToken, user._id); +} + +Meteor.methods({ + updateMyPatreonDetails(){ + const userId = this.userId; + if (!userId) throw new Meteor.Error('not-logged-in', 'You must be logged in to update Patreon details'); + const user = Meteor.users.findOne(userId, {fields: {patreon: 1}}); + updatePatreonDetails(user); + }, +}); + +const writePatreonToken = function(userId, { + access_token, refresh_token, expires_in +}){ + // The expiry date is now plus `expires_in` seconds + let expiryDate = new Date(); + expiryDate.setSeconds(expiryDate.getSeconds() + expires_in); + // Expire a day early so we don't accidentally miss it + expiryDate.setDate(expiryDate.getDate() - 1); + + // Write + Meteor.users.update(userId, { + $set: { + 'patreon.accessToken': access_token, + 'patreon.refreshToken': refresh_token, + 'patreon.tokenExpiryDate': expiryDate, + }, + $unset: { + 'patreon.error': 1, + }, + }); +}; + +const writeEntitledCents = function(userId, amount){ + Meteor.users.update(userId, { + $set: { + 'services.patreon.entitledCents': amount, + }, + $unset: { + 'patreon.error': 1, + }, + }); +}; + +export { updatePatreonDetails }; diff --git a/app/imports/api/users/patreon/tiers.js b/app/imports/api/users/patreon/tiers.js new file mode 100644 index 00000000..8c669156 --- /dev/null +++ b/app/imports/api/users/patreon/tiers.js @@ -0,0 +1,66 @@ +import { findLast } from 'lodash'; +import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js'; + +const TIERS = [ + { + //cost per user $0 + name: 'Commoner', + minimumEntitledCents: 0, + invites: 0, + paidBenefits: false, + }, { + //cost per user $0 + name: 'Wanderer', + minimumEntitledCents: 300, + invites: 0, + paidBenefits: false, + }, { + //cost per user $5 + name: 'Adventurer', + minimumEntitledCents: 500, + invites: 0, + paidBenefits: true, + }, { + //cost per user $3.33 + name: 'Hero', + minimumEntitledCents: 1000, + invites: 2, + paidBenefits: true, + }, { + //cost per user $3.333 + name: 'Legend', + minimumEntitledCents: 2000, + invites: 5, + paidBenefits: true, + }, { + //cost per user $3.125 + name: 'Paragon', + minimumEntitledCents: 5000, + invites: 15, + paidBenefits: true, + }, +]; + +const GUEST_TIER = { + name: 'Companion', + guest: true, + invites: 0, + paidBenefits: true, +} + +export function getTierByEntitledCents(entitledCents = 0){ + return findLast(TIERS, tier => entitledCents >= tier.minimumEntitledCents); +} + +export function getUserTier(user){ + if (!user) throw 'user must be provided'; + if (typeof user === 'string'){ + user = Meteor.users.findOne(user); + if (!user) throw 'User not found'; + } + const entitledCents = getEntitledCents(user); + return getTierByEntitledCents(entitledCents); +} + +export default TIERS; +export { GUEST_TIER }; diff --git a/app/imports/server/publications/users.js b/app/imports/server/publications/users.js index 06fe0f3e..4dea49f3 100644 --- a/app/imports/server/publications/users.js +++ b/app/imports/server/publications/users.js @@ -1,15 +1,24 @@ import '/imports/api/users/Users.js'; +import Invites from '/imports/api/users/Invites.js'; Meteor.publish('user', function(){ - return Meteor.users.find(this.userId, {fields: { - roles: 1, - username: 1, - apiKey: 1, - darkMode: 1, - 'services.patreon.id': 1, - 'services.patreon.entitledCents': 1, - 'services.patreon.entitledCentsOverride': 1, - }}); + return [ + Meteor.users.find(this.userId, {fields: { + roles: 1, + username: 1, + apiKey: 1, + darkMode: 1, + 'services.patreon.id': 1, + 'services.patreon.entitledCents': 1, + 'services.patreon.entitledCentsOverride': 1, + }}), + Invites.find({ + $or: [ + {inviter: this.userId}, + {invitee: this.userId} + ], + }), + ]; }); Meteor.publish('userPublicProfiles', function(ids){ diff --git a/app/imports/ui/layouts/AppLayout.vue b/app/imports/ui/layouts/AppLayout.vue index 23488a22..504468de 100644 --- a/app/imports/ui/layouts/AppLayout.vue +++ b/app/imports/ui/layouts/AppLayout.vue @@ -58,8 +58,8 @@ dismissible :value="true" > - This version of DiceCloud is in beta. Data stored here may be destroyed by - future updates. Don't store anything important here yet. + This version of DiceCloud is in beta. Some data stored here may be destroyed by + future updates. {{ email.address }} - - - Verified - - assignment_turned_in - - - - Verify Account - - assignment_late - - - - - - - - Add email address - - add - - - - - - Patreon reward tier + Patreon - - - ${{ entitledCents/100 }} - - - - - API Key - - - - {{ user.apiKey }} - - - {{ "•".repeat(user.apiKey.length) }} - - - - {{ showApiKey ? 'visibility_off' : 'visibility' }} - - - - - - Generate API Key - - - @@ -164,7 +87,10 @@ diff --git a/app/imports/ui/properties/forms/EffectForm.vue b/app/imports/ui/properties/forms/EffectForm.vue index af21c992..78d2cee8 100644 --- a/app/imports/ui/properties/forms/EffectForm.vue +++ b/app/imports/ui/properties/forms/EffectForm.vue @@ -141,7 +141,6 @@ }, methods: { getEffectIcon, - log: console.log, } }; diff --git a/app/imports/ui/router.js b/app/imports/ui/router.js index 9b3cdfa1..61417fa4 100644 --- a/app/imports/ui/router.js +++ b/app/imports/ui/router.js @@ -1,5 +1,5 @@ import { RouterFactory, nativeScrollBehavior } from 'meteor/akryum:vue-router2'; -import { getEntitledCentsOfUser } from '/imports/api/users/Users.js'; +import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js'; // Components import Home from '/imports/ui/pages/Home.vue'; @@ -49,7 +49,7 @@ function ensurePatronTier5(to, from, next){ next('/sign-in'); return; } - let entitledCents = getEntitledCentsOfUser(user); + let entitledCents = getEntitledCents(user); if (entitledCents < 500){ next('/patreon-level-too-low'); } else { @@ -143,6 +143,7 @@ RouterFactory.configure(factory => { meta: { title: 'Account', }, + beforeEnter: ensureLoggedIn, },{ path: '/feedback', components: { diff --git a/app/packages/patreon-oauth/patreon_server.js b/app/packages/patreon-oauth/patreon_server.js index 8086acf3..aa75fe90 100644 --- a/app/packages/patreon-oauth/patreon_server.js +++ b/app/packages/patreon-oauth/patreon_server.js @@ -12,6 +12,7 @@ OAuth.registerService('patreon', 2, null, query => { email: identity.data.attributes.email, entitledCents: identity.included[0] && identity.included[0].attributes.currently_entitled_amount_cents || 0, + lastUpdatedIdentity: new Date(), accessToken, refreshToken, scope,