Skip to content

Commit

Permalink
Adds invite flow
Browse files Browse the repository at this point in the history
  • Loading branch information
peterpolman committed Jun 19, 2024
1 parent df71120 commit 636ae97
Show file tree
Hide file tree
Showing 15 changed files with 225 additions and 37 deletions.
14 changes: 14 additions & 0 deletions apps/api/src/app/controllers/invites/get.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Participant, QuestInvite, QuestInviteCode } from '@thxnetwork/api/model
import AccountProxy from '@thxnetwork/api/proxies/AccountProxy';
import QuestService from '@thxnetwork/api/services/QuestService';
import { NotFoundError } from '@thxnetwork/api/util/errors';
import PoolService from '@thxnetwork/api/services/PoolService';
import { id } from 'date-fns/locale';
import BrandService from '@thxnetwork/api/services/BrandService';

const validation = [param('code').isString()];

Expand All @@ -14,6 +17,9 @@ const controller = async (req: Request, res: Response) => {
const quest = await QuestInvite.findById(code.questId);
if (!quest) throw new NotFoundError('Quest not found');

const pool = await PoolService.getById(quest.poolId);
if (!pool) throw new NotFoundError('Campaign not found');

const { variant, questId } = quest.requiredQuest;
const requiredQuest = await QuestService.findById(variant, questId);
if (!requiredQuest) throw new NotFoundError('Required Quest not found');
Expand All @@ -24,6 +30,8 @@ const controller = async (req: Request, res: Response) => {
const participant = await Participant.findOne({ poolId: quest.poolId, sub: account.sub });
if (!participant) throw new NotFoundError('Inviter is not a campaign participant');

const brand = await BrandService.get(pool.id);

res.json({
quest,
requiredQuest,
Expand All @@ -32,6 +40,12 @@ const controller = async (req: Request, res: Response) => {
username: account.username,
rank: participant.rank,
},
campaign: {
id: pool.id,
title: pool.settings.title,
slug: pool.settings.slug,
image: brand ? brand.backgroundImgUrl : '',
},
});
};

Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/app/controllers/participants/patch.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AccountProxy from '@thxnetwork/api/proxies/AccountProxy';

const validation = [
param('id').isMongoId(),
body('inviteCode').optional().isString(),
body('isSubscribed').optional().isBoolean(),
body('email').optional().isEmail(),
];
Expand All @@ -14,6 +15,12 @@ const controller = async (req: Request, res: Response) => {
const participant = await Participant.findById(req.params.id);
if (!participant) throw new NotFoundError('Participant not found.');

console.log();

if (req.body.inviteCode && !participant.inviteCode) {
await participant.updateOne({ inviteCode: req.body.inviteCode });
}

// If subscribed is true and email we set the participant flag to true and patch the account
if (req.body.isSubscribed && req.body.email) {
const isSubscribed = JSON.parse(req.body.isSubscribed);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Request, Response } from 'express';
import { body, param } from 'express-validator';
import { JobType, agenda } from '@thxnetwork/api/util/agenda';
import { NotFoundError } from '@thxnetwork/api/util/errors';
import { QuestInvite } from '@thxnetwork/api/models';
import QuestService from '@thxnetwork/api/services/QuestService';

const validation = [param('id').isMongoId(), body('recaptcha').isString()];

async function controller({ params, body, account }: Request, res: Response) {
// Get the quest document
const quest = await QuestInvite.findById(params.id);
if (!quest) throw new NotFoundError('Quest not found');

// Perhaps add current InviteURL to metadata
const data = { metadata: {}, recaptcha: body.recaptcha };

// Running separately to avoid issues when getting validation results from Discord interactions
const isRealUser = await QuestService.isRealUser(quest.variant, { quest, account, data });
if (!isRealUser.result) return res.json({ error: isRealUser.reason });

// Get validation result for this quest entry
const { result, reason } = await QuestService.getValidationResult(quest.variant, { quest, account, data });
if (!result) return res.json({ error: reason });

// Schedule serial job
const job = await agenda.now(JobType.CreateQuestEntry, {
variant: quest.variant,
questId: String(quest._id),
sub: account.sub,
data,
});

res.json({ jobId: job.attrs._id });
}

export { controller, validation };
16 changes: 16 additions & 0 deletions apps/api/src/app/controllers/quests/invite/invite.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import express, { Router } from 'express';
import { assertRequestInput, assertAccount } from '@thxnetwork/api/middlewares';
import { limitInSeconds } from '@thxnetwork/api/util/ratelimiter';
import * as CreateEntries from './entries/post.controller';

const router: express.Router = Router({ mergeParams: true });

router.post(
'/:id/entries',
limitInSeconds(3),
assertRequestInput(CreateEntries.validation),
assertAccount,
CreateEntries.controller,
);

export default router;
2 changes: 2 additions & 0 deletions apps/api/src/app/controllers/quests/quests.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { checkJwt, corsHandler } from '@thxnetwork/api/middlewares';
import express from 'express';
import * as ListQuests from './list.controller';
import * as ListQuestsPublic from './recent/list.controller';
import RouterQuestInvite from './invite/invite.router';
import RouterQuestSocial from './social/social.router';
import RouterQuestWeb3 from './web3/web3.router';
import RouterQuestGitcoin from './gitcoin/gitcoin.router';
Expand All @@ -14,6 +15,7 @@ const router: express.Router = express.Router();
router.get('/', ListQuests.controller);
router.get('/public', ListQuestsPublic.controller);
router.use(checkJwt).use(corsHandler);
router.use('/invite', RouterQuestInvite);
router.use('/social', RouterQuestSocial);
router.use('/web3', RouterQuestWeb3);
router.use('/gitcoin', RouterQuestGitcoin);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/models/Participant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ export const Participant = mongoose.model<ParticipantDocument>(
new mongoose.Schema(
{
sub: String,
inviteCode: String,
poolId: String,
balance: { type: Number, default: 0 },
rank: Number,
score: Number,
questEntryCount: Number,
riskAnalysis: { score: Number, reasons: [String] },
isSubscribed: { type: Boolean, default: false },
invitedBySub: String,
},
{ timestamps: true },
),
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/models/QuestInviteEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const QuestInviteEntry = mongoose.model<QuestInviteEntryDocument>(
new mongoose.Schema(
{
questId: String,
inviteCodeId: String,
sub: String,
code: String,
amount: String,
},
{ timestamps: true },
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/app/services/MailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { logger } from '../util/logger';
const mailTemplatePath = path.join(assetsPath, 'views', 'email');

const send = async (to: string, subject: string, htmlContent: string, link = { src: '', text: '' }) => {
if (!to) return;
if (!to) {
logger.error({ message: 'No recipient e-mail address provided', subject });
return;
}

const html = await ejs.renderFile(
path.join(mailTemplatePath, 'base-template.ejs'),
Expand Down
71 changes: 56 additions & 15 deletions apps/api/src/app/services/QuestInviteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { QuestInviteCode, QuestInviteCodeDocument } from '../models/QuestInviteC
import { serviceMap } from './interfaces/IQuestService';
import PointBalanceService from './PointBalanceService';
import AccountProxy from '../proxies/AccountProxy';
import { logger } from '../util/logger';
import MailService from './MailService';

export default class QuestInviteService implements IQuestService {
models = {
Expand Down Expand Up @@ -36,16 +38,22 @@ export default class QuestInviteService implements IQuestService {
};
}

async isAvailable(options: {
async isAvailable({
quest,
account,
data,
}: {
quest: TQuestInvite;
account?: TAccount;
data: Partial<TQuestInviteEntry>;
}): Promise<TValidationResult> {
return { result: true, reason: '' };
}

async getAmount({ quest }: { quest: TQuestInvite; account: TAccount }): Promise<number> {
return quest.amount;
async getAmount({ quest, account }: { quest: TQuestInvite; account: TAccount }): Promise<number> {
const entries = await this.getEntries({ quest, account });
if (!account || !entries.length) return quest.amount;
return entries.length * quest.amount;
}

async getValidationResult(options: {
Expand Down Expand Up @@ -75,29 +83,51 @@ export default class QuestInviteService implements IQuestService {
entry: TQuestEntry;
account: TAccount;
}) {
// Return early if participant has not been invited
const participant = await Participant.findOne({ poolId: quest.poolId, sub: account.sub });
if (!participant.invitedBySub) return;

// Return early if no QuestInvite for this poolId
const inviteQuests = await QuestInvite.find({ poolId: entry.poolId });
if (!inviteQuests.length) return;

// Return early if participant has not been invited
const participant = await Participant.findOne({ poolId: quest.poolId, sub: account.sub });
if (!participant.inviteCode) return;

// Iterate over invite quests and assert if all quests for this invite quests have been completed
for (const inviteQuest of inviteQuests) {
const Entry = serviceMap[inviteQuest.requiredQuest.variant].models.entry;
const questEntry = await Entry.findOne({ questId: inviteQuest.requiredQuest.questId, sub: account.sub });
if (!questEntry) continue;
const requiredQuestEntry = await Entry.findOne({
questId: inviteQuest.requiredQuest.questId,
sub: account.sub,
});
if (!requiredQuestEntry) continue;

// Transfer points to inviter
const inviter = await AccountProxy.findById(participant.invitedBySub);
if (!inviter) throw new Error('Inviter not found');

// Update entry for invites
// Update entry for invitee
const code = await QuestInviteCode.findOne({ code: participant.inviteCode });
if (!code) {
logger.error('Invite code not found');
return;
}
const inviter = await AccountProxy.findById(code.sub);
if (!inviter) {
logger.error('Inviter not found');
return;
}

// Send notification to inviter
await MailService.send(
inviter.email,
'Invite Quest Completed',
`Your invitee ${account.username} has completed the required quest. You have earned ${inviteQuest.amount} points.`,
);

// Create entry for invitee
await QuestInviteEntry.create({
questId: inviteQuest.id,
inviteCodeId: code.id,
sub: account.sub,
});

// Transfer points to invitee
await PointBalanceService.add(pool, inviter, inviteQuest.amount);
// await PointBalanceService.add(pool, inviter, inviteQuest.amount);
await PointBalanceService.add(pool, account, inviteQuest.amountInvitee);
}
}
Expand All @@ -118,6 +148,17 @@ export default class QuestInviteService implements IQuestService {
});
}

// Get all entries created through a code owned by the account
private async getEntries({ quest, account }: { quest: TQuestInvite; account: TAccount }) {
const codes = await this.getCodes({ quest, account });
const inviteCodeIds = codes.map(({ _id }) => String(_id));

return await QuestInviteEntry.find({
questId: String(quest._id),
inviteCodeId: { $in: inviteCodeIds },
});
}

private async getCodes({ quest, account }: { quest: TQuestInvite; account?: TAccount }) {
if (!account) return [];
const codes = await QuestInviteCode.find({ questId: String(quest._id), sub: account.sub });
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/services/QuestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export default class QuestService {
);

// Defaults: 0.1, 0.3, 0.7 and 0.9. Ranges from 0 (Bot) to 1 (User)
if (riskAnalysis.score >= 0.9) {
if (riskAnalysis.score >= 0.7) {
return { result: true, reasons: '' };
}

Expand Down
10 changes: 10 additions & 0 deletions apps/app/src/components/card/BaseCardQuestInvite.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
<span v-if="requiredQuest.amount" class="me-1">[{{ requiredQuest.amount }}]</span>
<span v-if="quest.amountInvitee" class="text-accent">+ {{ quest.amountInvitee }}</span>
</BaseFormGroup>

<template #button>
<b-button variant="primary" block class="w-100" :disabled="isSubmitting" @click="onClick">
<b-spinner v-if="isSubmitting" small></b-spinner>
<template v-else>
Claim <strong>{{ quest.amount }} points</strong>
</template>
</b-button>
</template>
</BaseCardQuest>
</template>

Expand Down Expand Up @@ -99,6 +108,7 @@ export default defineComponent({
await this.questStore.completeQuest(this.quest);
this.isModalQuestEntryShown = false;
} catch (error) {
console.error(error);
this.error = String(error);
} finally {
this.isSubmitting = false;
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/stores/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const useAuthStore = defineStore('auth', {
this.user = user;
this.isModalLoginShown = false;
},
signin(extraQueryParams?: { [key: string]: any }) {
signin(extraQueryParams: { [key: string]: any } = {}, state = {}) {
const { poolId, config, isMobileDevice } = useAccountStore();
const { entry } = useQRCodeStore();
const returnUrl = window.location.href;
Expand All @@ -63,6 +63,7 @@ export const useAuthStore = defineStore('auth', {
returnUrl,
client_id: CLIENT_ID,
origin: config.origin,
...state,
},
extraQueryParams: {
return_url: returnUrl,
Expand Down
Loading

0 comments on commit 636ae97

Please sign in to comment.