Invite system created, mostly done, but timeboxed for now

This commit is contained in:
Thaum Rystra
2020-05-07 14:52:18 +02:00
parent 4ca47d3a62
commit 31185a4b12
14 changed files with 440 additions and 122 deletions

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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 };