Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion __tests__/common/njord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 53 additions & 0 deletions __tests__/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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 });
});
Expand Down Expand Up @@ -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',
},
});
});
});
17 changes: 12 additions & 5 deletions src/common/njord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 14 additions & 2 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Copy link
Contributor

@capJavert capJavert Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AmarTrebinjac only this should be enough, and then on any transaction you make you just put it questUser.id, thats it

Njord will auto create new user on its side with first transaction, no need to do changes in njord you did.

Copy link
Contributor

@capJavert capJavert Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

be aware we have user named quest https://app.daily.dev/quest, so you need to rename their username to quest1 or something and add it to disallow handle.


export const systemUserIds = [
...systemEntityIds,
ghostUser.id,
playwrightUser.id,
];

export const demoCompany = {
id: 'e8c7a930-ca69-4cba-b26c-b6c810d6ab7d',
Expand Down Expand Up @@ -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 = ({
Expand Down
41 changes: 41 additions & 0 deletions src/migration/1772900000000-QuestCurrencyProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class QuestCurrencyProvider1772900000000
implements MigrationInterface
{
name = 'QuestCurrencyProvider1772900000000';

public async up(queryRunner: QueryRunner): Promise<void> {
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).','<p>Face of quests, <a href="https://app.daily.dev/quest" target="_blank" rel="noopener nofollow">@quest</a>.</p>','{}',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<void> {
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'`);
}
}
9 changes: 8 additions & 1 deletion src/schema/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,6 +159,7 @@ export const typeDefs = /* GraphQL */ `
const excludedUsers = [
ghostUser.id,
systemUser.id,
questUser.id,
...MODERATORS,
'6h7QO55AFClNmsV1zBaJt',
'rgFi4sbhpMZwIZZlSjl8d',
Expand Down
4 changes: 2 additions & 2 deletions src/schema/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading