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

@@ -48,3 +48,4 @@ aldeed:collection2@3.0.0
aldeed:schema-index
akryum:vue-component
accounts-patreon
bozhao:link-accounts

View File

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

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

View File

@@ -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){

View File

@@ -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.
</v-alert>
<v-fade-transition
mode="out-in"

View File

@@ -44,109 +44,32 @@
<v-list-tile-title>
{{ email.address }}
</v-list-tile-title>
<v-list-tile-action>
<v-tooltip
v-if="email.verified"
left
>
<span>Verified</span>
<v-icon slot="activator">
assignment_turned_in
</v-icon>
</v-tooltip>
<v-tooltip left>
<span>Verify Account</span>
<v-btn
slot="activator"
flat
icon
@click="verifyEmail(email.address)"
>
<v-icon>assignment_late</v-icon>
</v-btn>
</v-tooltip>
</v-list-tile-action>
</v-list-tile>
<v-list-tile>
<v-list-tile-action>
<v-tooltip right>
<span>Add email address</span>
<v-btn
slot="activator"
flat
icon
>
<v-icon>add</v-icon>
</v-btn>
</v-tooltip>
</v-list-tile-action>
</v-list-tile>
<v-subheader>
Patreon reward tier
Patreon
</v-subheader>
<v-list-tile>
<v-list-tile-title>
${{ entitledCents/100 }}
</v-list-tile-title>
</v-list-tile>
<v-subheader>
API Key
</v-subheader>
<v-list-tile v-if="user && user.apiKey">
<v-list-tile v-if="showApiKey">
{{ user.apiKey }}
</v-list-tile>
<v-list-tile-title>
{{ "•".repeat(user.apiKey.length) }}
</v-list-tile-title>
<v-list-tile-action>
<v-btn
flat
icon
@click="showApiKey=!showApiKey"
>
<v-icon>{{ showApiKey ? 'visibility_off' : 'visibility' }}</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
<v-list-tile v-else>
<v-btn
flat
color="accent"
@click="generateKey"
>
Generate API Key
</v-btn>
</v-list-tile>
<!--
<v-subheader>
Google Account
</v-subheader>
<v-list-tile v-if="googleAccount">
<v-list-tile-avatar>
<img src="googleAccount.picture">
</v-list-tile-avatar>
<v-list-tile-content>
<template v-if="user.services.patreon">
<v-list-tile>
<v-list-tile-title>
{{ googleAccount.name }}
Pledged amount: ${{ entitledCents/100 }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ googleAccount.email }}
</v-list-tile-sub-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-else="googleAccount">
<v-btn
flat
color="accent"
>
Connect Google
<v-list-tile-action>
<v-btn icon>
<v-icon>refresh</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
<v-list-tile>
<v-list-tile-title>
Tier: {{ tier.name }}
</v-list-tile-title>
</v-list-tile>
</template>
<v-list-tile v-else>
<v-btn @click="linkWithPatreon">
Link Patreon
</v-btn>
</v-list-tile>
-->
</v-list>
<v-card-actions>
<v-spacer />
@@ -164,7 +87,10 @@
<script>
import router from '/imports/ui/router.js';
import {getEntitledCentsOfUser} from '/imports/api/users/Users.js';
import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js';
import Invites from '/imports/api/users/Invites.js';
import linkWithPatreon from '/imports/api/users/linkWithPatreon.js'
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
export default {
meteor: {
@@ -194,7 +120,10 @@
}},
computed: {
entitledCents(){
return getEntitledCentsOfUser(this.user);
return getEntitledCents(this.user);
},
tier(){
return getUserTier(this.user);
},
},
methods: {
@@ -216,6 +145,7 @@
if(error) this.emailVerificationError = error.reason;
});
},
linkWithPatreon,
},
}
</script>

View File

@@ -141,7 +141,6 @@
},
methods: {
getEffectIcon,
log: console.log,
}
};
</script>

View File

@@ -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: {

View File

@@ -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,