diff --git a/bin/syncReadingStreakAchievements.ts b/bin/syncReadingStreakAchievements.ts new file mode 100644 index 0000000000..f8b3a57288 --- /dev/null +++ b/bin/syncReadingStreakAchievements.ts @@ -0,0 +1,86 @@ +import '../src/config'; +import { In } from 'typeorm'; +import createOrGetConnection from '../src/db'; +import { Achievement, AchievementEventType, UserStreak } from '../src/entity'; +import { updateUserAchievementProgress } from '../src/common/achievement'; +import { logger } from '../src/logger'; + +/** + * One-off backfill to retroactively unlock the newly added reading streak + * achievements for users whose max streak already qualifies. + * Idempotent: updateUserAchievementProgress skips already-unlocked achievements. + */ + +const NEW_ACHIEVEMENT_NAMES = ['What time is it?', 'Devil is impressed']; + +const start = async (): Promise => { + const con = await createOrGetConnection(); + + try { + const achievements = await con.getRepository(Achievement).find({ + where: { + eventType: AchievementEventType.ReadingStreak, + name: In(NEW_ACHIEVEMENT_NAMES), + }, + }); + + const foundNames = new Set(achievements.map(({ name }) => name)); + const missing = NEW_ACHIEVEMENT_NAMES.filter( + (name) => !foundNames.has(name), + ); + if (missing.length > 0) { + throw new Error( + `Missing reading streak achievements: ${missing.join(', ')}. Run migrations first.`, + ); + } + + const minTarget = Math.min( + ...achievements.map(({ criteria }) => criteria.targetCount ?? 1), + ); + + const streaks = await con + .getRepository(UserStreak) + .createQueryBuilder('us') + .select(['us.userId', 'us.maxStreak']) + .where('us.maxStreak >= :minTarget', { minTarget }) + .getMany(); + + console.log( + `Found ${streaks.length} users with maxStreak >= ${minTarget} and ${achievements.length} target achievements.`, + ); + + let totalUnlocked = 0; + + for (const { userId, maxStreak } of streaks) { + for (const achievement of achievements) { + const wasUnlocked = await updateUserAchievementProgress( + con, + logger, + userId, + achievement.id, + maxStreak, + achievement.criteria.targetCount ?? 1, + ); + + if (wasUnlocked) { + totalUnlocked++; + } + } + } + + console.log( + `Finished reading streak achievements backfill. Unlocked ${totalUnlocked} achievements.`, + ); + } finally { + await con.destroy(); + } +}; + +start() + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/migration/1783416206466-AddMoreReadingStreakAchievements.ts b/src/migration/1783416206466-AddMoreReadingStreakAchievements.ts new file mode 100644 index 0000000000..b82375834d --- /dev/null +++ b/src/migration/1783416206466-AddMoreReadingStreakAchievements.ts @@ -0,0 +1,46 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMoreReadingStreakAchievements1783416206466 implements MigrationInterface { + name = 'AddMoreReadingStreakAchievements1783416206466'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + INSERT INTO "achievement" ( + "name", + "description", + "image", + "type", + "eventType", + "criteria", + "points" + ) + VALUES + ( + 'What time is it?', + 'Reach a 67-day reading streak', + 'https://media.daily.dev/image/upload/s--2bqSyiqr--/v1783416166/achievements/What_time_is_it', + 'milestone', + 'reading_streak', + '{"targetCount": 67}', + 35 + ), + ( + 'Devil is impressed', + 'Reach a 666-day reading streak', + 'https://media.daily.dev/image/upload/s--W0-BqBQd--/v1783416167/achievements/Devil_is_impressed', + 'milestone', + 'reading_streak', + '{"targetCount": 666}', + 50 + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + DELETE FROM "achievement" + WHERE "eventType" = 'reading_streak' + AND "name" IN ('What time is it?', 'Devil is impressed') + `); + } +}