From bdc693b47ac24feb9c9a2dc81f20153627582da9 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 17 Mar 2026 17:10:08 +0100 Subject: [PATCH] feat: quest user to reward cores --- __tests__/common/njord.ts | 54 ++++++++++++++++++- __tests__/quests.ts | 53 ++++++++++++++++++ src/common/njord.ts | 17 ++++-- src/common/utils.ts | 16 +++++- .../1772900000000-QuestCurrencyProvider.ts | 41 ++++++++++++++ src/schema/leaderboard.ts | 9 +++- src/schema/quests.ts | 4 +- 7 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 src/migration/1772900000000-QuestCurrencyProvider.ts diff --git a/__tests__/common/njord.ts b/__tests__/common/njord.ts index 037f52f2d3..b3cb0e4e48 100644 --- a/__tests__/common/njord.ts +++ b/__tests__/common/njord.ts @@ -27,7 +27,7 @@ import { } from '../../src/entity/user/UserTransaction'; import * as redisFile from '../../src/redis'; import { ioRedisPool } from '../../src/redis'; -import { parseBigInt } from '../../src/common'; +import { parseBigInt, questUser } from '../../src/common'; import { TransferError } from '../../src/errors'; import { verifyJwt } from '../../src/auth'; import { serviceClientId } from '../../src/types'; @@ -189,6 +189,58 @@ describe('transferCores', () => { }); }); + it('should treat quest as a system sender', async () => { + const mockTransport = createMockNjordTransport(); + const mockedClient = createClient(Credits, mockTransport); + const clientSpy = jest.spyOn(mockedClient, 'transfer'); + jest + .spyOn(njordCommon, 'getNjordClient') + .mockImplementation(() => mockedClient); + + const transaction = await con.getRepository(UserTransaction).save( + con.getRepository(UserTransaction).create({ + processor: UserTransactionProcessor.Njord, + receiverId: 't-tc-2', + status: UserTransactionStatus.Success, + productId: null, + senderId: questUser.id, + value: 42, + valueIncFees: 42, + fee: 0, + request: {}, + flags: { + note: 'Quest reward test', + }, + }), + ); + + await njordCommon.transferCores({ + ctx: { + userId: 't-tc-1', + } as unknown as AuthContext, + transaction, + entityManager: con.manager, + }); + + expect(clientSpy).toHaveBeenCalledWith( + expect.objectContaining({ + transfers: [ + expect.objectContaining({ + sender: { + id: questUser.id, + type: EntityType.SYSTEM, + }, + receiver: { + id: 't-tc-2', + type: EntityType.USER, + }, + }), + ], + }), + expect.anything(), + ); + }); + it('should throw on njord error', async () => { jest.spyOn(njordCommon, 'getNjordClient').mockImplementation(() => createClient( diff --git a/__tests__/quests.ts b/__tests__/quests.ts index 7ebf654328..915a9c9856 100644 --- a/__tests__/quests.ts +++ b/__tests__/quests.ts @@ -21,6 +21,8 @@ import { UserQuestProfile, UserQuestStatus, } from '../src/entity/user'; +import { UserTransaction } from '../src/entity/user/UserTransaction'; +import { questUser } from '../src/common'; import appFunc from '../src'; import type { Context } from '../src/Context'; import { FastifyInstance } from 'fastify'; @@ -101,6 +103,7 @@ let isPlus = false; const questUserId = '99999999-9999-4999-8999-999999999999'; const questId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const questRewardXpId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const questRewardCoresId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const questRotationId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const userQuestId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const extraQuestId = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; @@ -125,6 +128,7 @@ beforeEach(async () => { await con.createQueryBuilder().delete().from(UserQuestProfile).execute(); await con.createQueryBuilder().delete().from(QuestRotation).execute(); await con.createQueryBuilder().delete().from(QuestReward).execute(); + await con.getRepository(UserTransaction).delete({ receiverId: questUserId }); await con.createQueryBuilder().delete().from(Quest).execute(); await con.getRepository(User).delete({ id: questUserId }); }); @@ -327,4 +331,53 @@ describe('claimQuestReward mutation', () => { expect(res.errors).toHaveLength(1); expect(res.errors?.[0]?.message).toBe('Quest is not completed yet'); }); + + it('should award quest core rewards from the quest account', async () => { + const now = new Date(); + loggedUser = questUserId; + + await seedQuest({ + userQuestStatus: UserQuestStatus.Completed, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }); + + await saveFixtures(con, QuestReward, [ + { + id: questRewardCoresId, + questId, + type: QuestRewardType.Cores, + amount: 5, + metadata: {}, + }, + ]); + + const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, { + variables: { + userQuestId, + }, + }); + + expect(res.errors).toBeUndefined(); + + const transaction = await con + .getRepository(UserTransaction) + .findOneByOrFail({ + receiverId: questUserId, + referenceType: `quest_reward:${userQuestId}`, + }); + + expect(transaction).toMatchObject({ + receiverId: questUserId, + senderId: questUser.id, + value: 5, + valueIncFees: 5, + fee: 0, + referenceId: questId, + referenceType: `quest_reward:${userQuestId}`, + flags: { + note: 'Quest reward: Hold my upvote', + }, + }); + }); }); diff --git a/src/common/njord.ts b/src/common/njord.ts index c6da184db8..aded06919e 100644 --- a/src/common/njord.ts +++ b/src/common/njord.ts @@ -24,7 +24,12 @@ import { UserTransactionStatus, UserTransactionType, } from '../entity/user/UserTransaction'; -import { isSpecialUser, parseBigInt, systemUser } from './utils'; +import { + isSpecialUser, + parseBigInt, + systemEntityIds, + systemUser, +} from './utils'; import { ForbiddenError } from 'apollo-server-errors'; import { checkCoresAccess, @@ -219,11 +224,13 @@ export const transferCores = createAuthProtectedFn( } const senderId = transaction.senderId; - const senderType = - senderId === systemUser.id ? EntityType.SYSTEM : EntityType.USER; + const senderType = systemEntityIds.includes(senderId) + ? EntityType.SYSTEM + : EntityType.USER; const receiverId = transaction.receiverId; - const receiverType = - receiverId === systemUser.id ? EntityType.SYSTEM : EntityType.USER; + const receiverType = systemEntityIds.includes(receiverId) + ? EntityType.SYSTEM + : EntityType.USER; const response = await garmNjordService.execute(async () => { const payload = new TransferRequest({ diff --git a/src/common/utils.ts b/src/common/utils.ts index 810057fd71..183fcb5223 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -33,7 +33,19 @@ export const systemUser = { name: 'System', }; -export const systemUserIds = [systemUser.id, ghostUser.id, playwrightUser.id]; +export const questUser = { + id: 'quest', + username: 'quest', + name: 'Quest', +}; + +export const systemEntityIds = [systemUser.id, questUser.id]; + +export const systemUserIds = [ + ...systemEntityIds, + ghostUser.id, + playwrightUser.id, +]; export const demoCompany = { id: 'e8c7a930-ca69-4cba-b26c-b6c810d6ab7d', @@ -269,7 +281,7 @@ export const isSpecialUser = ({ }: { userId?: string | null; }): boolean => { - return !!userId && [ghostUser.id, systemUser.id].includes(userId); + return !!userId && [ghostUser.id, ...systemEntityIds].includes(userId); }; export const getCurrencySymbol = ({ diff --git a/src/migration/1772900000000-QuestCurrencyProvider.ts b/src/migration/1772900000000-QuestCurrencyProvider.ts new file mode 100644 index 0000000000..4a93355d9e --- /dev/null +++ b/src/migration/1772900000000-QuestCurrencyProvider.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class QuestCurrencyProvider1772900000000 + implements MigrationInterface +{ + name = 'QuestCurrencyProvider1772900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO public.user (id,"name",image,reputation,username,"infoConfirmed","acceptedMarketing",timezone,"notificationEmail",readme,"readmeHtml",flags,"weekStart","followingEmail","followNotifications","subscriptionFlags","cioRegistered","emailConfirmed","coresRole") VALUES ('quest','daily.dev quest user','https://cdn.daily.dev/assets/maskable_icon.png',0,'quest',true,false,'Etc/UTC',false,'Face of quests, [@quest](https://app.daily.dev/quest).','

Face of quests, @quest.

','{}',1,false,false,'{}',false,true,0) ON CONFLICT (id) DO NOTHING`, + ); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION prevent_special_user_delete() + RETURNS trigger AS $$ + BEGIN + IF OLD.id IN ('404', 'system', 'quest') THEN + RETURN NULL; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION prevent_special_user_delete() + RETURNS trigger AS $$ + BEGIN + IF OLD.id IN ('404', 'system') THEN + RETURN NULL; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + `); + + await queryRunner.query(`DELETE FROM public.user WHERE id = 'quest'`); + } +} diff --git a/src/schema/leaderboard.ts b/src/schema/leaderboard.ts index b47ab9d0c5..09acefe547 100644 --- a/src/schema/leaderboard.ts +++ b/src/schema/leaderboard.ts @@ -6,7 +6,13 @@ import { UserQuestProfile } from '../entity/user'; import { UserAchievement } from '../entity/user/UserAchievement'; import { Achievement } from '../entity/Achievement'; import { DataSource, In, Not } from 'typeorm'; -import { getLimit, ghostUser, GQLCompany, systemUser } from '../common'; +import { + getLimit, + ghostUser, + GQLCompany, + questUser, + systemUser, +} from '../common'; import { MODERATORS } from '../config'; import graphorm from '../graphorm'; import type { GQLHotTake } from './userHotTake'; @@ -153,6 +159,7 @@ export const typeDefs = /* GraphQL */ ` const excludedUsers = [ ghostUser.id, systemUser.id, + questUser.id, ...MODERATORS, '6h7QO55AFClNmsV1zBaJt', 'rgFi4sbhpMZwIZZlSjl8d', diff --git a/src/schema/quests.ts b/src/schema/quests.ts index ba8f465c89..7ec7b23d63 100644 --- a/src/schema/quests.ts +++ b/src/schema/quests.ts @@ -9,7 +9,7 @@ import { QUEST_ROTATION_UPDATE_CHANNEL, } from '../common/quest'; import { transferCores } from '../common/njord'; -import { systemUser } from '../common/utils'; +import { questUser } from '../common/utils'; import { Quest, QuestReward, @@ -297,7 +297,7 @@ const applyQuestRewards = async ({ receiverId: ctx.userId, status: UserTransactionStatus.Success, productId: null, - senderId: systemUser.id, + senderId: questUser.id, value: rewardTotals.cores, valueIncFees: rewardTotals.cores, fee: 0,