From 636ae97832c1a3119348ea037bb21792cd28b1df Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 19 Jun 2024 19:51:38 +0200 Subject: [PATCH] Adds invite flow --- .../app/controllers/invites/get.controller.ts | 14 +++ .../participants/patch.controller.ts | 7 ++ .../quests/invite/entries/post.controller.ts | 37 ++++++++ .../quests/invite/invite.router.ts | 16 ++++ .../app/controllers/quests/quests.router.ts | 2 + apps/api/src/app/models/Participant.ts | 2 +- apps/api/src/app/models/QuestInviteEntry.ts | 2 +- apps/api/src/app/services/MailService.ts | 5 +- .../src/app/services/QuestInviteService.ts | 71 +++++++++++---- apps/api/src/app/services/QuestService.ts | 2 +- .../components/card/BaseCardQuestInvite.vue | 10 +++ apps/app/src/stores/Auth.ts | 3 +- apps/app/src/views/Invite.vue | 87 +++++++++++++++---- libs/common/src/lib/types/Participant.d.ts | 2 +- .../src/lib/types/QuestInviteEntry.d.ts | 2 +- 15 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 apps/api/src/app/controllers/quests/invite/entries/post.controller.ts create mode 100644 apps/api/src/app/controllers/quests/invite/invite.router.ts diff --git a/apps/api/src/app/controllers/invites/get.controller.ts b/apps/api/src/app/controllers/invites/get.controller.ts index 74585448b..d4c3dd58e 100644 --- a/apps/api/src/app/controllers/invites/get.controller.ts +++ b/apps/api/src/app/controllers/invites/get.controller.ts @@ -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()]; @@ -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'); @@ -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, @@ -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 : '', + }, }); }; diff --git a/apps/api/src/app/controllers/participants/patch.controller.ts b/apps/api/src/app/controllers/participants/patch.controller.ts index 4e06eb598..d7c5e6de8 100644 --- a/apps/api/src/app/controllers/participants/patch.controller.ts +++ b/apps/api/src/app/controllers/participants/patch.controller.ts @@ -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(), ]; @@ -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); diff --git a/apps/api/src/app/controllers/quests/invite/entries/post.controller.ts b/apps/api/src/app/controllers/quests/invite/entries/post.controller.ts new file mode 100644 index 000000000..48fde09d4 --- /dev/null +++ b/apps/api/src/app/controllers/quests/invite/entries/post.controller.ts @@ -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 }; diff --git a/apps/api/src/app/controllers/quests/invite/invite.router.ts b/apps/api/src/app/controllers/quests/invite/invite.router.ts new file mode 100644 index 000000000..72c309e5a --- /dev/null +++ b/apps/api/src/app/controllers/quests/invite/invite.router.ts @@ -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; diff --git a/apps/api/src/app/controllers/quests/quests.router.ts b/apps/api/src/app/controllers/quests/quests.router.ts index 8d3a44425..d303fe461 100644 --- a/apps/api/src/app/controllers/quests/quests.router.ts +++ b/apps/api/src/app/controllers/quests/quests.router.ts @@ -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'; @@ -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); diff --git a/apps/api/src/app/models/Participant.ts b/apps/api/src/app/models/Participant.ts index 352ef7677..59adbcfc0 100644 --- a/apps/api/src/app/models/Participant.ts +++ b/apps/api/src/app/models/Participant.ts @@ -7,6 +7,7 @@ export const Participant = mongoose.model( new mongoose.Schema( { sub: String, + inviteCode: String, poolId: String, balance: { type: Number, default: 0 }, rank: Number, @@ -14,7 +15,6 @@ export const Participant = mongoose.model( questEntryCount: Number, riskAnalysis: { score: Number, reasons: [String] }, isSubscribed: { type: Boolean, default: false }, - invitedBySub: String, }, { timestamps: true }, ), diff --git a/apps/api/src/app/models/QuestInviteEntry.ts b/apps/api/src/app/models/QuestInviteEntry.ts index 9f3ab4602..d00d0ae45 100644 --- a/apps/api/src/app/models/QuestInviteEntry.ts +++ b/apps/api/src/app/models/QuestInviteEntry.ts @@ -7,8 +7,8 @@ export const QuestInviteEntry = mongoose.model( new mongoose.Schema( { questId: String, + inviteCodeId: String, sub: String, - code: String, amount: String, }, { timestamps: true }, diff --git a/apps/api/src/app/services/MailService.ts b/apps/api/src/app/services/MailService.ts index 0aafd427e..7c1d6224d 100644 --- a/apps/api/src/app/services/MailService.ts +++ b/apps/api/src/app/services/MailService.ts @@ -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'), diff --git a/apps/api/src/app/services/QuestInviteService.ts b/apps/api/src/app/services/QuestInviteService.ts index b50615c5c..374ab78ce 100644 --- a/apps/api/src/app/services/QuestInviteService.ts +++ b/apps/api/src/app/services/QuestInviteService.ts @@ -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 = { @@ -36,7 +38,11 @@ export default class QuestInviteService implements IQuestService { }; } - async isAvailable(options: { + async isAvailable({ + quest, + account, + data, + }: { quest: TQuestInvite; account?: TAccount; data: Partial; @@ -44,8 +50,10 @@ export default class QuestInviteService implements IQuestService { return { result: true, reason: '' }; } - async getAmount({ quest }: { quest: TQuestInvite; account: TAccount }): Promise { - return quest.amount; + async getAmount({ quest, account }: { quest: TQuestInvite; account: TAccount }): Promise { + const entries = await this.getEntries({ quest, account }); + if (!account || !entries.length) return quest.amount; + return entries.length * quest.amount; } async getValidationResult(options: { @@ -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); } } @@ -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 }); diff --git a/apps/api/src/app/services/QuestService.ts b/apps/api/src/app/services/QuestService.ts index 728f65444..2dd16976d 100644 --- a/apps/api/src/app/services/QuestService.ts +++ b/apps/api/src/app/services/QuestService.ts @@ -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: '' }; } diff --git a/apps/app/src/components/card/BaseCardQuestInvite.vue b/apps/app/src/components/card/BaseCardQuestInvite.vue index 6cf77f3bc..440c704db 100644 --- a/apps/app/src/components/card/BaseCardQuestInvite.vue +++ b/apps/app/src/components/card/BaseCardQuestInvite.vue @@ -51,6 +51,15 @@ [{{ requiredQuest.amount }}] + {{ quest.amountInvitee }} + + @@ -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; diff --git a/apps/app/src/stores/Auth.ts b/apps/app/src/stores/Auth.ts index cadae11a5..50155b2b8 100644 --- a/apps/app/src/stores/Auth.ts +++ b/apps/app/src/stores/Auth.ts @@ -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; @@ -63,6 +63,7 @@ export const useAuthStore = defineStore('auth', { returnUrl, client_id: CLIENT_ID, origin: config.origin, + ...state, }, extraQueryParams: { return_url: returnUrl, diff --git a/apps/app/src/views/Invite.vue b/apps/app/src/views/Invite.vue index e58f73936..a2e5ff2f3 100644 --- a/apps/app/src/views/Invite.vue +++ b/apps/app/src/views/Invite.vue @@ -6,6 +6,7 @@ Hi!👋 You have been invited by {{ invite.account.username }}! + {{ error }} @@ -13,8 +14,8 @@ - Complete the "{{ invite.requiredQuest.title }}" quest - {{ invite.requiredQuest.amount }} + Complete quest "{{ invite.requiredQuest.title }}" in + {{ decode(invite.campaign.title) }}
  • @@ -29,9 +30,11 @@ @@ -41,12 +44,20 @@ import { mapStores } from 'pinia'; import { defineComponent } from 'vue'; import { useAuthStore } from '../stores/Auth'; import { useAccountStore } from '../stores/Account'; +import { decode } from 'html-entities'; +import { WIDGET_URL } from '../config/secrets'; export default defineComponent({ data() { return { + decode, error: '', - invite: null as { account: TAccount; quest: TQuestInvite; requiredQuest: TBaseQuest } | null, + invite: null as { + account: TAccount; + quest: TQuestInvite; + requiredQuest: TBaseQuest; + campaign: { title: string; slug: string; id: string; image: string }; + } | null, isLoading: false, }; }, @@ -55,6 +66,9 @@ export default defineComponent({ isShown() { return true; }, + campaignPath() { + return this.invite ? `/c/${this.invite.campaign.slug}` : ''; + }, isAlertErrorShown() { return !!this.error; }, @@ -62,20 +76,63 @@ export default defineComponent({ return !this.error; }, }, - async mounted() { - try { - this.isLoading = true; - this.invite = await this.accountStore.api.request.get(`/v1/invites/${this.$route.params.code}`); - } catch (error) { - this.error = String(error); - } finally { - this.isLoading = false; - } + watch: { + 'accountStore.isAuthenticated': { + handler: async function (isAuthenticated: boolean) { + if (!this.invite) { + await this.getInvite(); + } + if (!isAuthenticated) return; + + if (this.invite) { + await this.setInviteCode(); + } + }, + immediate: true, + }, }, methods: { + async setInviteCode() { + try { + if (!this.invite) { + throw new Error('No invite found'); + } + + // Create or read participant info for this user + await this.accountStore.getParticipants(this.invite.quest.poolId); + + // Get participant info from state + const participant = this.accountStore.participants.find( + (p) => this.invite && p.poolId === this.invite.quest.poolId, + ); + if (!participant) { + throw new Error('No participant found'); + } + + // Update participant with invite code + await this.accountStore.api.request.patch(`/v1/participants/${participant._id}/`, { + data: { inviteCode: this.$route.params.code }, + }); + } catch (error) { + console.error(error); + } + }, + async getInvite() { + try { + this.isLoading = true; + this.invite = await this.accountStore.api.request.get(`/v1/invites/${this.$route.params.code}`); + } catch (error) { + this.error = String(error); + } finally { + this.isLoading = false; + } + }, onClickSignin() { if (!this.invite) return; - this.authStore.signin({ poolId: this.invite.quest.poolId, inviteCode: this.$route.params.code }); + this.authStore.signin( + { return_url: WIDGET_URL + this.campaignPath, poolId: this.invite.quest.poolId }, + { inviteCode: this.$route.params.code }, + ); }, }, }); diff --git a/libs/common/src/lib/types/Participant.d.ts b/libs/common/src/lib/types/Participant.d.ts index b11823ec4..564cc5dcc 100644 --- a/libs/common/src/lib/types/Participant.d.ts +++ b/libs/common/src/lib/types/Participant.d.ts @@ -8,7 +8,7 @@ type TParticipant = { questEntryCount: number; isSubscribed: boolean; balance: number; - invitedBySub: string; + inviteCode: string; createdAt: Date; updatedAt: Date; }; diff --git a/libs/common/src/lib/types/QuestInviteEntry.d.ts b/libs/common/src/lib/types/QuestInviteEntry.d.ts index 22b29bc0d..9dc8efc37 100644 --- a/libs/common/src/lib/types/QuestInviteEntry.d.ts +++ b/libs/common/src/lib/types/QuestInviteEntry.d.ts @@ -1,6 +1,6 @@ type TQuestInviteEntry = { questId: string; + inviteCodeId: string; sub: string; - code: string; amount: string; };