diff --git a/__tests__/njord.ts b/__tests__/njord.ts index ffad107d36..9163646733 100644 --- a/__tests__/njord.ts +++ b/__tests__/njord.ts @@ -184,6 +184,17 @@ describe('award user mutation', () => { balance: { amount: expect.any(Number) }, }, }); + + const { transactionId } = res.data.award; + const transaction = await con.getRepository(UserTransaction).findOneOrFail({ + where: { id: transactionId }, + }); + + expect(transaction.referenceType).toEqual(UserTransactionType.User); + expect(transaction.referenceId).toEqual('t-awum-2'); + expect(transaction.senderId).toEqual('t-awum-1'); + expect(transaction.receiverId).toEqual('t-awum-2'); + expect(transaction.status).toEqual(UserTransactionStatus.Success); }); it('should not award when user does not have access to cores', async () => { diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index 24f9367ded..801d51a409 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -145,7 +145,11 @@ import { UserTransaction, UserTransactionProcessor, UserTransactionStatus, + UserTransactionType, } from '../../../src/entity/user/UserTransaction'; +import { Quest, QuestEventType, QuestType } from '../../../src/entity/Quest'; +import { QuestRotation } from '../../../src/entity/QuestRotation'; +import { UserQuest, UserQuestStatus } from '../../../src/entity/user/UserQuest'; import * as redisFile from '../../../src/redis'; import { getRedisKeysByPattern, @@ -8055,6 +8059,191 @@ describe('user transaction achievement progress', () => { }); }); +describe('user transaction award given quest progress', () => { + let senderId: string; + let receiverId: string; + let productId: string; + let questId: string; + let rotationId: string; + + beforeEach(async () => { + senderId = randomUUID(); + receiverId = randomUUID(); + productId = randomUUID(); + questId = randomUUID(); + rotationId = randomUUID(); + + await saveFixtures(con, User, [ + { + id: senderId, + bio: null, + name: 'Award Sender', + image: 'https://daily.dev/award-sender.jpg', + email: `award-sender-${senderId}@daily.dev`, + createdAt: new Date(), + username: `sender${senderId.slice(0, 8)}`, + infoConfirmed: true, + }, + { + id: receiverId, + bio: null, + name: 'Award Receiver', + image: 'https://daily.dev/award-receiver.jpg', + email: `award-receiver-${receiverId}@daily.dev`, + createdAt: new Date(), + username: `recv${receiverId.slice(0, 8)}`, + infoConfirmed: true, + }, + ]); + + await con.getRepository(Product).save({ + id: productId, + type: ProductType.Award, + image: 'https://daily.dev/product.jpg', + name: 'Award Given Quest Product', + value: 10, + flags: {}, + }); + + const now = new Date(); + const periodStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const periodEnd = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000); + + await con.getRepository(Quest).save({ + id: questId, + name: `Give 5 awards ${questId}`, + description: 'Give 5 awards this week', + type: QuestType.Weekly, + eventType: QuestEventType.AwardGiven, + criteria: { targetCount: 5 }, + active: true, + }); + + await con.getRepository(QuestRotation).save({ + id: rotationId, + questId, + type: QuestType.Weekly, + plusOnly: false, + slot: 0, + periodStart, + periodEnd, + }); + }); + + const buildTransactionChange = ( + id: string, + referenceType: string | null, + referenceId: string | null, + ) => ({ + before: { + id, + productId, + referenceId, + referenceType, + status: UserTransactionStatus.Processing, + receiverId, + senderId, + value: 10, + valueIncFees: 10, + fee: 0, + request: {}, + flags: {}, + processor: UserTransactionProcessor.Njord, + }, + after: { + id, + productId, + referenceId, + referenceType, + status: UserTransactionStatus.Success, + receiverId, + senderId, + value: 10, + valueIncFees: 10, + fee: 0, + request: {}, + flags: {}, + processor: UserTransactionProcessor.Njord, + }, + op: 'u' as const, + table: 'user_transaction', + }); + + it('should increment award_given quest progress for user-type awards', async () => { + const transactionId = randomUUID(); + + await con.getRepository(UserTransaction).save({ + id: transactionId, + productId, + referenceId: receiverId, + referenceType: UserTransactionType.User, + status: UserTransactionStatus.Success, + receiverId, + senderId, + value: 10, + valueIncFees: 10, + fee: 0, + request: {}, + flags: {}, + processor: UserTransactionProcessor.Njord, + }); + + await expectSuccessfulBackground( + worker, + mockChangeMessage( + buildTransactionChange( + transactionId, + UserTransactionType.User, + receiverId, + ), + ), + ); + + const userQuest = await con.getRepository(UserQuest).findOneBy({ + rotationId, + userId: senderId, + }); + + expect(userQuest).not.toBeNull(); + expect(userQuest!.progress).toEqual(1); + expect(userQuest!.status).toEqual(UserQuestStatus.InProgress); + }); + + it('should not increment award_given quest progress when referenceType is null', async () => { + const transactionId = randomUUID(); + + await con.getRepository(UserTransaction).save({ + id: transactionId, + productId, + referenceId: null, + referenceType: null, + status: UserTransactionStatus.Success, + receiverId, + senderId, + value: 10, + valueIncFees: 10, + fee: 0, + request: {}, + flags: {}, + processor: UserTransactionProcessor.Njord, + }); + + await expectSuccessfulBackground( + worker, + mockChangeMessage( + buildTransactionChange(transactionId, null, null), + ), + ); + + const userQuest = await con.getRepository(UserQuest).findOneBy({ + rotationId, + userId: senderId, + }); + + expect(userQuest).toBeNull(); + }); +}); + describe('post analytics achievement progress', () => { let impressionsAchievementId: string; let authorId: string; diff --git a/bin/backfillUserAwardQuestProgress.ts b/bin/backfillUserAwardQuestProgress.ts new file mode 100644 index 0000000000..e9ddf0db33 --- /dev/null +++ b/bin/backfillUserAwardQuestProgress.ts @@ -0,0 +1,189 @@ +import '../src/config'; +import { parseArgs } from 'node:util'; +import { In } from 'typeorm'; +import createOrGetConnection from '../src/db'; +import { Quest, QuestEventType, QuestType } from '../src/entity/Quest'; +import { QuestRotation } from '../src/entity/QuestRotation'; +import { UserQuest, UserQuestStatus } from '../src/entity/user/UserQuest'; +import { UserTransactionStatus } from '../src/entity/user/UserTransaction'; +import { ProductType } from '../src/entity/Product'; + +type AwardCountRow = { + userId: string; + count: string; +}; + +const TERMINAL_STATUSES = [UserQuestStatus.Claimed]; + +const start = async (): Promise => { + const { values } = parseArgs({ + options: { apply: { type: 'boolean', default: false } }, + }); + const apply = values.apply === true; + + const con = await createOrGetConnection(); + const now = new Date(); + + try { + const rotations = await con + .getRepository(QuestRotation) + .createQueryBuilder('qr') + .innerJoin( + Quest, + 'q', + 'q.id = qr."questId" AND q.active = true AND q."eventType" = :eventType', + { eventType: QuestEventType.AwardGiven }, + ) + .where('qr.type = :type', { type: QuestType.Weekly }) + .andWhere('qr."periodStart" <= :now', { now }) + .andWhere('qr."periodEnd" > :now', { now }) + .getMany(); + + if (!rotations.length) { + console.log('No active weekly AwardGiven rotations found.'); + return; + } + + const quests = await con.getRepository(Quest).find({ + where: { id: In(rotations.map((r) => r.questId)) }, + }); + const questById = new Map(quests.map((q) => [q.id, q])); + + let totalUsers = 0; + let totalUpdated = 0; + let totalCompleted = 0; + + for (const rotation of rotations) { + const quest = questById.get(rotation.questId); + if (!quest) { + continue; + } + const targetCount = Math.max( + 1, + Math.floor(quest.criteria?.targetCount ?? 1), + ); + + const rows: AwardCountRow[] = await con + .createQueryBuilder() + .select('ut."senderId"', 'userId') + .addSelect('COUNT(*)', 'count') + .from('user_transaction', 'ut') + .innerJoin('product', 'p', 'p.id = ut."productId"') + .where('ut.status = :success', { + success: UserTransactionStatus.Success, + }) + .andWhere('ut."referenceType" IS NULL') + .andWhere('ut."senderId" IS NOT NULL') + .andWhere('ut."senderId" <> ut."receiverId"') + .andWhere('p.type = :productType', { productType: ProductType.Award }) + .andWhere('ut."createdAt" >= :start', { start: rotation.periodStart }) + .andWhere('ut."createdAt" < :end', { end: rotation.periodEnd }) + .groupBy('ut."senderId"') + .getRawMany(); + + console.log( + `Rotation ${rotation.id} (quest="${quest.name}", target=${targetCount}, period=${rotation.periodStart.toISOString()}..${rotation.periodEnd.toISOString()}): ${rows.length} users to backfill`, + ); + + let rotationUpdated = 0; + let rotationCompleted = 0; + + for (const row of rows) { + const missed = Math.min(Number(row.count), targetCount); + if (missed <= 0) { + continue; + } + + totalUsers++; + + if (!apply) { + console.log( + ` [dry-run] user=${row.userId} +${missed} (from ${row.count} user awards)`, + ); + continue; + } + + const updateResult = await con + .createQueryBuilder() + .update(UserQuest) + .set({ + progress: () => + 'least(:targetCount, greatest(0, "progress") + :missed)', + status: () => + `CASE WHEN least(:targetCount, greatest(0, "progress") + :missed) >= :targetCount THEN :completed ELSE "status" END`, + completedAt: () => + `CASE WHEN least(:targetCount, greatest(0, "progress") + :missed) >= :targetCount THEN coalesce("completedAt", :now) ELSE "completedAt" END`, + }) + .where('"rotationId" = :rotationId', { rotationId: rotation.id }) + .andWhere('"userId" = :userId', { userId: row.userId }) + .andWhere('"status" NOT IN (:...terminal)', { + terminal: TERMINAL_STATUSES, + }) + .returning(['status']) + .setParameters({ + targetCount, + missed, + completed: UserQuestStatus.Completed, + now, + }) + .execute(); + + if ((updateResult.affected ?? 0) > 0) { + rotationUpdated++; + const status = updateResult.raw?.[0]?.status; + if (status === UserQuestStatus.Completed) { + rotationCompleted++; + } + continue; + } + + const progress = Math.min(targetCount, missed); + const status = + progress >= targetCount + ? UserQuestStatus.Completed + : UserQuestStatus.InProgress; + + const insertResult = await con + .createQueryBuilder() + .insert() + .into(UserQuest) + .values({ + rotationId: rotation.id, + userId: row.userId, + progress, + status, + completedAt: status === UserQuestStatus.Completed ? now : null, + claimedAt: null, + }) + .orIgnore() + .execute(); + + if (insertResult.identifiers.length > 0) { + rotationUpdated++; + if (status === UserQuestStatus.Completed) { + rotationCompleted++; + } + } + } + + console.log( + ` → updated=${rotationUpdated}, newlyCompleted=${rotationCompleted}`, + ); + totalUpdated += rotationUpdated; + totalCompleted += rotationCompleted; + } + + console.log( + `Done. mode=${apply ? 'APPLY' : 'DRY-RUN'} users=${totalUsers} updated=${totalUpdated} newlyCompleted=${totalCompleted}`, + ); + } finally { + await con.destroy(); + } +}; + +start() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/common/njord.ts b/src/common/njord.ts index c6da184db8..16ecc356b7 100644 --- a/src/common/njord.ts +++ b/src/common/njord.ts @@ -653,6 +653,10 @@ export const awardUser = async ( receiverId, note, flags, + entityReference: { + id: receiverId, + type: UserTransactionType.User, + }, }); try { diff --git a/src/entity/user/UserTransaction.ts b/src/entity/user/UserTransaction.ts index 196b7d13c9..c6cd14fb06 100644 --- a/src/entity/user/UserTransaction.ts +++ b/src/entity/user/UserTransaction.ts @@ -51,6 +51,7 @@ export enum UserTransactionType { SquadBoost = 'squad_boost', Post = 'post', Comment = 'comment', + User = 'user', BriefGeneration = 'brief_generation', } diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index c642baf816..6181489d28 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1785,7 +1785,8 @@ const onUserTransactionChange = async ( if ( (transaction.referenceType === UserTransactionType.Post || - transaction.referenceType === UserTransactionType.Comment) && + transaction.referenceType === UserTransactionType.Comment || + transaction.referenceType === UserTransactionType.User) && transaction.senderId && transaction.receiverId !== transaction.senderId ) { @@ -1795,14 +1796,6 @@ const onUserTransactionChange = async ( transaction.receiverId, AchievementEventType.AwardReceived, ); - } - - if ( - (transaction.referenceType === UserTransactionType.Post || - transaction.referenceType === UserTransactionType.Comment) && - transaction.senderId && - transaction.receiverId !== transaction.senderId - ) { await checkAchievementProgress( con, logger,