feat(stats): add device and rating achievement evaluators with session source tracking

Introduce devices and rating evaluators backed by a new reading_sessions.source
column, so badges can reward multi-device reading and book ratings. Wire the
source through Kobo and reading-session services and seed the new badges.
This commit is contained in:
neon
2026-06-15 04:03:54 +00:00
parent 49da34ee48
commit 1b8454f557
38 changed files with 12356 additions and 57 deletions
@@ -1,6 +1,25 @@
import { describe, it, expect } from 'vitest'
import { resolveLucideIcon } from '../utils/resolveLucideIcon'
import { BookOpen, HelpCircle, Trophy, Flame } from 'lucide-vue-next'
import {
ArrowLeftRight,
BookHeart,
BookOpen,
BookX,
Flame,
Gauge,
Gavel,
HelpCircle,
Medal,
Orbit,
Palette,
PenTool,
Rabbit,
Smartphone,
Star,
StarHalf,
ThumbsDown,
Trophy,
} from 'lucide-vue-next'
describe('resolveLucideIcon', () => {
it('resolves kebab-case icon name to component', () => {
@@ -20,4 +39,21 @@ describe('resolveLucideIcon', () => {
it('handles multi-segment icon names', () => {
expect(resolveLucideIcon('flame')).toBe(Flame)
})
it('resolves the new achievement icons without falling back to HelpCircle', () => {
expect(resolveLucideIcon('gauge')).toBe(Gauge)
expect(resolveLucideIcon('rabbit')).toBe(Rabbit)
expect(resolveLucideIcon('book-heart')).toBe(BookHeart)
expect(resolveLucideIcon('book-x')).toBe(BookX)
expect(resolveLucideIcon('gavel')).toBe(Gavel)
expect(resolveLucideIcon('star')).toBe(Star)
expect(resolveLucideIcon('star-half')).toBe(StarHalf)
expect(resolveLucideIcon('medal')).toBe(Medal)
expect(resolveLucideIcon('thumbs-down')).toBe(ThumbsDown)
expect(resolveLucideIcon('pen-tool')).toBe(PenTool)
expect(resolveLucideIcon('palette')).toBe(Palette)
expect(resolveLucideIcon('smartphone')).toBe(Smartphone)
expect(resolveLucideIcon('orbit')).toBe(Orbit)
expect(resolveLucideIcon('arrow-left-right')).toBe(ArrowLeftRight)
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { BookOpen, Compass, Flame, Library } from 'lucide-vue-next'
import { BookOpen, Compass, Flame, Library, TabletSmartphone } from 'lucide-vue-next'
import type { AchievementCategory, AchievementCategoryGroup } from '@bookorbit/types'
import { groupAchievements } from '../composables/useGroupedAchievements'
import type { DisplayItem, FilterState } from '../types'
@@ -18,6 +18,7 @@ const CATEGORY_ICONS: Record<AchievementCategory, Component> = {
library: Library,
exploration: Compass,
dedication: Flame,
devices: TabletSmartphone,
}
const CATEGORY_ACCENT: Record<AchievementCategory, string> = {
@@ -25,6 +26,7 @@ const CATEGORY_ACCENT: Record<AchievementCategory, string> = {
library: 'bg-emerald-500',
exploration: 'bg-orange-500',
dedication: 'bg-red-500',
devices: 'bg-violet-500',
}
const CATEGORY_ICON_COLOR: Record<AchievementCategory, string> = {
@@ -32,6 +34,7 @@ const CATEGORY_ICON_COLOR: Record<AchievementCategory, string> = {
library: 'text-emerald-400',
exploration: 'text-orange-400',
dedication: 'text-red-400',
devices: 'text-violet-400',
}
const CategoryIcon = computed<Component>(() => CATEGORY_ICONS[props.group.key])
@@ -1,9 +1,12 @@
import {
Activity,
ArrowLeftRight,
ArrowUpDown,
Award,
BookHeart,
BookmarkCheck,
BookOpen,
BookX,
Calendar,
CalendarCheck,
CalendarRange,
@@ -17,6 +20,8 @@ import {
Files,
Flame,
FolderOpen,
Gauge,
Gavel,
Globe,
Heart,
HelpCircle,
@@ -24,20 +29,29 @@ import {
Hourglass,
Library,
ListOrdered,
Medal,
Minimize2,
Moon,
Mountain,
Orbit,
Palette,
PartyPopper,
PenLine,
PenTool,
Play,
Rabbit,
Repeat,
Scroll,
ScrollText,
Smartphone,
Sparkles,
Sprout,
Star,
StarHalf,
Sun,
Sunrise,
Swords,
ThumbsDown,
Timer,
Trophy,
Zap,
@@ -46,10 +60,13 @@ import type { Component } from 'vue'
const ICON_MAP: Record<string, Component> = {
activity: Activity,
'arrow-left-right': ArrowLeftRight,
'arrow-up-down': ArrowUpDown,
award: Award,
'book-heart': BookHeart,
'bookmark-check': BookmarkCheck,
'book-open': BookOpen,
'book-x': BookX,
calendar: Calendar,
'calendar-check': CalendarCheck,
'calendar-range': CalendarRange,
@@ -63,26 +80,37 @@ const ICON_MAP: Record<string, Component> = {
files: Files,
flame: Flame,
'folder-open': FolderOpen,
gauge: Gauge,
gavel: Gavel,
globe: Globe,
heart: Heart,
highlighter: Highlighter,
hourglass: Hourglass,
library: Library,
'list-ordered': ListOrdered,
medal: Medal,
'minimize-2': Minimize2,
moon: Moon,
mountain: Mountain,
orbit: Orbit,
palette: Palette,
'party-popper': PartyPopper,
'pen-line': PenLine,
'pen-tool': PenTool,
play: Play,
rabbit: Rabbit,
repeat: Repeat,
scroll: Scroll,
'scroll-text': ScrollText,
smartphone: Smartphone,
sparkles: Sparkles,
sprout: Sprout,
star: Star,
'star-half': StarHalf,
sun: Sun,
sunrise: Sunrise,
swords: Swords,
'thumbs-down': ThumbsDown,
timer: Timer,
trophy: Trophy,
zap: Zap,
+2 -1
View File
@@ -1,4 +1,4 @@
export type AchievementCategory = "reading" | "library" | "exploration" | "dedication";
export type AchievementCategory = "reading" | "library" | "exploration" | "dedication" | "devices";
export type AchievementRarity = "common" | "rare" | "epic" | "legendary";
@@ -7,6 +7,7 @@ export const ACHIEVEMENT_CATEGORY_LABELS: Record<AchievementCategory, string> =
library: "Library",
exploration: "Exploration",
dedication: "Dedication",
devices: "Devices",
};
export interface AchievementItem {
@@ -0,0 +1,4 @@
ALTER TABLE "achievements" DROP CONSTRAINT "achievements_category_chk";--> statement-breakpoint
ALTER TABLE "reading_sessions" ADD COLUMN "source" varchar(10) DEFAULT 'web' NOT NULL;--> statement-breakpoint
ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_source_chk" CHECK ("reading_sessions"."source" in ('web', 'kobo', 'koreader'));--> statement-breakpoint
ALTER TABLE "achievements" ADD CONSTRAINT "achievements_category_chk" CHECK ("achievements"."category" in ('reading', 'library', 'exploration', 'dedication', 'devices'));
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,13 @@
"when": 1781330635947,
"tag": "0019_add_lubimyczytac_provider_id",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781495568135,
"tag": "0020_achievements_devices_and_session_source",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -21,7 +21,7 @@ export const achievements = pgTable(
(t) => [
index('achievements_category_sort_idx').on(t.category, t.sortOrder),
index('achievements_group_key_idx').on(t.groupKey),
check('achievements_category_chk', sql`${t.category} in ('reading', 'library', 'exploration', 'dedication')`),
check('achievements_category_chk', sql`${t.category} in ('reading', 'library', 'exploration', 'dedication', 'devices')`),
check('achievements_rarity_chk', sql`${t.rarity} in ('common', 'rare', 'epic', 'legendary')`),
check('achievements_tier_chk', sql`${t.tier} is null or (${t.tier} >= 1 and ${t.tier} <= 4)`),
],
+6
View File
@@ -6,6 +6,8 @@ import { bookFiles, books } from './books';
import { libraries } from './libraries';
import { users } from './auth';
export type ReadingSessionSource = 'web' | 'kobo' | 'koreader';
export const userBookStatus = pgTable(
'user_book_status',
{
@@ -136,12 +138,16 @@ export const readingSessions = pgTable(
// Nullable: CBX with no percentage tracking may omit these.
progressDelta: real('progress_delta'),
endProgress: real('end_progress'),
// Which client produced the session. Kobo analytics also writes sessions here, so this is the only
// way to tell a genuine web-reader session apart from a device-origin one.
source: varchar('source', { length: 10 }).$type<ReadingSessionSource>().notNull().default('web'),
},
(t) => [
uniqueIndex('rs_user_session_id_uidx').on(t.userId, t.sessionId),
index('rs_user_started_at_idx').on(t.userId, t.startedAt),
index('rs_book_file_started_at_idx').on(t.bookFileId, t.startedAt),
index('rs_user_book_file_idx').on(t.userId, t.bookFileId),
check('reading_sessions_source_chk', sql`${t.source} in ('web', 'kobo', 'koreader')`),
check('reading_sessions_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} >= 0`),
check('reading_sessions_end_progress_range_chk', sql`${t.endProgress} is null or (${t.endProgress} >= 0 and ${t.endProgress} <= 100)`),
check('reading_sessions_ended_after_started_chk', sql`${t.endedAt} >= ${t.startedAt}`),
@@ -14,6 +14,15 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
function makeAchievementService() {
return {
handleEvent: vi.fn().mockResolvedValue(undefined),
ensureCatalogueSeeded: vi.fn().mockResolvedValue(undefined),
};
}
function makeAppSettings(overrides: Record<string, unknown> = {}) {
return {
getValue: vi.fn().mockResolvedValue(null),
setValue: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
@@ -29,12 +38,14 @@ describe('AchievementBackfillService', () => {
let service: AchievementBackfillService;
let repo: ReturnType<typeof makeRepo>;
let achievementService: ReturnType<typeof makeAchievementService>;
let appSettings: ReturnType<typeof makeAppSettings>;
beforeEach(() => {
vi.clearAllMocks();
repo = makeRepo();
achievementService = makeAchievementService();
service = new AchievementBackfillService(repo as never, achievementService as never);
appSettings = makeAppSettings();
service = new AchievementBackfillService(repo as never, achievementService as never, appSettings as never);
});
it('throws ForbiddenException for non-superuser callers', async () => {
@@ -61,13 +72,14 @@ describe('AchievementBackfillService', () => {
});
it('correctly counts awards granted based on earned key delta', async () => {
repo.findAllUserIds.mockResolvedValue([1, 2]);
// With Promise.allSettled, both processUser calls start in parallel:
// call order: user1-before, user2-before, user1-after, user2-after
repo.findUserEarnedKeys
.mockResolvedValueOnce(new Set(['books_finished_1'])) // user 1 before (size 1)
.mockResolvedValueOnce(new Set()) // user 2 before (size 0)
.mockResolvedValueOnce(new Set(['books_finished_1', 'marathoner'])) // user 1 after (size 2)
.mockResolvedValueOnce(new Set(['bookmarked'])); // user 2 after (size 1)
// Keyed by user (not global call order) so the assertion does not depend on batch interleaving.
// user 1: 1 key before -> 2 after (delta 1); user 2: 0 before -> 1 after (delta 1); total 2.
const earnedByUser: Record<number, Set<string>[]> = {
1: [new Set(['books_finished_1']), new Set(['books_finished_1', 'marathoner'])],
2: [new Set(), new Set(['bookmarked'])],
};
const callCounts: Record<number, number> = { 1: 0, 2: 0 };
repo.findUserEarnedKeys.mockImplementation((userId: number) => Promise.resolve(earnedByUser[userId][callCounts[userId]++]));
const result = await service.runBackfill(makeSuperuser());
@@ -96,4 +108,63 @@ describe('AchievementBackfillService', () => {
expect(result.usersProcessed).toBe(2);
expect(achievementService.handleEvent).toHaveBeenCalledTimes(3);
});
describe('onModuleInit auto-backfill', () => {
const flush = () => new Promise((resolve) => setImmediate(resolve));
it('backfills all users and stores the seed signature when none is stored', async () => {
repo.findAllUserIds.mockResolvedValue([1, 2]);
service.onModuleInit();
await flush();
expect(achievementService.handleEvent).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BACKFILL, { userId: 1 });
expect(achievementService.handleEvent).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BACKFILL, { userId: 2 });
expect(appSettings.setValue).toHaveBeenCalledWith('achievements_backfill_signature', expect.any(String));
});
it('waits for the catalogue seed before awarding, so badge FKs resolve', async () => {
repo.findAllUserIds.mockResolvedValue([1]);
const order: string[] = [];
achievementService.ensureCatalogueSeeded.mockImplementation(() => {
order.push('seed');
return Promise.resolve();
});
achievementService.handleEvent.mockImplementation(() => {
order.push('handle');
return Promise.resolve();
});
service.onModuleInit();
await flush();
expect(order).toEqual(['seed', 'handle']);
});
it('skips the backfill when the stored signature already matches the current seed', async () => {
appSettings.setValue.mockResolvedValue(undefined);
// First run computes and stores the current signature, which we then feed back as the stored value.
service.onModuleInit();
await flush();
const storedSignature = appSettings.setValue.mock.calls[0]?.[1];
vi.clearAllMocks();
appSettings.getValue.mockResolvedValue(storedSignature);
service.onModuleInit();
await flush();
expect(repo.findAllUserIds).not.toHaveBeenCalled();
expect(achievementService.handleEvent).not.toHaveBeenCalled();
expect(appSettings.setValue).not.toHaveBeenCalled();
});
it('does not store the signature when backfill fails, so it retries on the next start', async () => {
repo.findAllUserIds.mockRejectedValue(new Error('DB down'));
service.onModuleInit();
await flush();
expect(appSettings.setValue).not.toHaveBeenCalled();
});
});
});
@@ -1,26 +1,42 @@
import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { createHash } from 'crypto';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { AchievementRepository } from './achievement.repository';
import { AchievementService } from './achievement.service';
import { ACHIEVEMENT_EVENT_BACKFILL } from './achievement-events.service';
import { ACHIEVEMENT_SEED } from './seed/achievement-seed';
const BATCH_SIZE = 50;
const BACKFILL_SIGNATURE_KEY = 'achievements_backfill_signature';
export interface BackfillResult {
usersProcessed: number;
awardsGranted: number;
}
// Changes to the set of seeded achievement keys mean some users may now qualify for badges that did
// not exist before, so the signature gates a one-off retroactive catch-up after each such change.
function computeSeedSignature(): string {
const keys = ACHIEVEMENT_SEED.map((a) => a.key).sort();
return createHash('sha1').update(keys.join(',')).digest('hex');
}
@Injectable()
export class AchievementBackfillService {
export class AchievementBackfillService implements OnModuleInit {
private readonly logger = new Logger(AchievementBackfillService.name);
constructor(
private readonly repo: AchievementRepository,
private readonly achievementService: AchievementService,
private readonly appSettings: AppSettingsService,
) {}
onModuleInit(): void {
void this.runAutoBackfill();
}
async runBackfill(requestingUser: RequestUser): Promise<BackfillResult> {
if (!requestingUser.isSuperuser) {
throw new ForbiddenException('Only superusers can trigger achievement backfill');
@@ -29,23 +45,59 @@ export class AchievementBackfillService {
const event = 'achievement.backfill';
const startedAt = Date.now();
const userIds = await this.repo.findAllUserIds();
const totalUsers = userIds.length;
this.logger.log(`[${event}] [start] totalUsers=${totalUsers} - backfill started`);
this.logger.log(`[${event}] [start] totalUsers=${userIds.length} - backfill started`);
let usersProcessed = 0;
let awardsGranted = 0;
const usersProcessed = await this.forEachUserInBatches(userIds, event, async (userId) => {
// Read awardsGranted only after the await resolves; `awardsGranted += await ...` would capture
// the pre-await value and lose concurrent batch increments.
const count = await this.processUser(userId);
awardsGranted += count;
});
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
const batch = userIds.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((userId) =>
this.processUser(userId).then((count) => {
awardsGranted += count;
}),
),
this.logger.log(
`[${event}] [end] usersProcessed=${usersProcessed} awardsGranted=${awardsGranted} durationMs=${Date.now() - startedAt} - backfill completed`,
);
return { usersProcessed, awardsGranted };
}
// Retroactively awards badges for everything users already did before a newly added achievement existed.
// Gated by a seed signature so it runs at most once per change to the achievement set, and awards silently
// (the BACKFILL event suppresses notifications) to avoid flooding users with historical badges.
private async runAutoBackfill(): Promise<void> {
const event = 'achievement.auto_backfill';
try {
const signature = computeSeedSignature();
const stored = await this.appSettings.getValue(BACKFILL_SIGNATURE_KEY);
if (stored === signature) return;
// onModuleInit hooks run concurrently (Nest awaits them with Promise.all), so wait for the
// catalogue seed explicitly: awarding before the new achievement rows exist would fail the FK.
await this.achievementService.ensureCatalogueSeeded();
const startedAt = Date.now();
const userIds = await this.repo.findAllUserIds();
this.logger.log(`[${event}] [start] totalUsers=${userIds.length} - achievement auto-backfill started`);
const usersProcessed = await this.forEachUserInBatches(userIds, event, (userId) =>
this.achievementService.handleEvent(ACHIEVEMENT_EVENT_BACKFILL, { userId }),
);
await this.appSettings.setValue(BACKFILL_SIGNATURE_KEY, signature);
this.logger.log(`[${event}] [end] usersProcessed=${usersProcessed} durationMs=${Date.now() - startedAt} - achievement auto-backfill completed`);
} catch (error) {
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.error(`[${event}] [fail] error="${errorMessage}" - achievement auto-backfill failed`);
}
}
private async forEachUserInBatches(userIds: number[], event: string, onUser: (userId: number) => Promise<unknown>): Promise<number> {
let usersProcessed = 0;
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
const batch = userIds.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(batch.map((userId) => onUser(userId)));
for (const result of results) {
if (result.status === 'fulfilled') {
usersProcessed++;
@@ -54,17 +106,8 @@ export class AchievementBackfillService {
this.logger.warn(`[${event}] [fail] batchStart=${i} error="${errorMessage}" - user batch processing failed`);
}
}
if (i > 0 && i % 100 === 0) {
this.logger.log(`[${event}] usersProcessed=${usersProcessed} awardsGranted=${awardsGranted} - backfill in progress`);
}
}
this.logger.log(
`[${event}] [end] usersProcessed=${usersProcessed} awardsGranted=${awardsGranted} durationMs=${Date.now() - startedAt} - backfill completed`,
);
return { usersProcessed, awardsGranted };
return usersProcessed;
}
private async processUser(userId: number): Promise<number> {
@@ -54,9 +54,10 @@ export interface BookRatingChangedPayload {
export interface BookProgressChangedPayload {
userId: number;
bookId: number;
bookFileId: number;
// Optional: Kobo syncs progress at the book level and has no single relevant file id.
bookFileId?: number;
progress: number;
source: 'koreader' | 'web_reader';
source: 'koreader' | 'kobo' | 'web_reader';
}
export type AchievementEventPayload =
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { NotificationModule } from '../notification/notification.module';
import { AchievementController } from './achievement.controller';
import { AchievementService } from './achievement.service';
@@ -12,9 +13,11 @@ import { LibraryEvaluator } from './evaluators/library.evaluator';
import { ExplorationEvaluator } from './evaluators/exploration.evaluator';
import { DedicationEvaluator } from './evaluators/dedication.evaluator';
import { MilestonesEvaluator } from './evaluators/milestones.evaluator';
import { RatingEvaluator } from './evaluators/rating.evaluator';
import { DevicesEvaluator } from './evaluators/devices.evaluator';
@Module({
imports: [NotificationModule],
imports: [NotificationModule, AppSettingsModule],
controllers: [AchievementController],
providers: [
AchievementService,
@@ -27,6 +30,8 @@ import { MilestonesEvaluator } from './evaluators/milestones.evaluator';
ExplorationEvaluator,
DedicationEvaluator,
MilestonesEvaluator,
RatingEvaluator,
DevicesEvaluator,
],
exports: [AchievementEventsService],
})
@@ -38,11 +43,15 @@ export class AchievementModule {
private readonly exploration: ExplorationEvaluator,
private readonly dedication: DedicationEvaluator,
private readonly milestones: MilestonesEvaluator,
private readonly rating: RatingEvaluator,
private readonly devices: DevicesEvaluator,
) {
this.registry.register(this.reading);
this.registry.register(this.library);
this.registry.register(this.exploration);
this.registry.register(this.dedication);
this.registry.register(this.milestones);
this.registry.register(this.rating);
this.registry.register(this.devices);
}
}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, countDistinct, desc, eq, gt, gte, isNotNull, lt, ne, notInArray, sql, sum } from 'drizzle-orm';
import { and, count, countDistinct, desc, eq, gt, gte, isNotNull, lt, lte, ne, notInArray, sql, sum } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -8,6 +8,7 @@ import {
achievements,
userAchievements,
userBookStatus,
userBookRatings,
readingSessions,
annotations,
collections,
@@ -19,6 +20,8 @@ import {
bookAuthors,
userReadingDailyStats,
userLibraryAccess,
koreaderDeviceProgress,
koboReadingStates,
users,
} from '../../db/schema';
import type { AchievementRow, NewAchievement, UserAchievementRow } from '../../db/schema';
@@ -889,4 +892,211 @@ export class AchievementRepository {
.limit(1);
return !!row;
}
// ── Ratings ──
async countRatings(userId: number): Promise<number> {
const [{ value }] = await this.db.select({ value: count() }).from(userBookRatings).where(eq(userBookRatings.userId, userId));
return value;
}
async countRatingsAtMost(userId: number, max: number): Promise<number> {
const [{ value }] = await this.db
.select({ value: count() })
.from(userBookRatings)
.where(and(eq(userBookRatings.userId, userId), lte(userBookRatings.rating, max)));
return value;
}
async countDistinctRatingValues(userId: number): Promise<number> {
const [{ value }] = await this.db
.select({ value: countDistinct(userBookRatings.rating) })
.from(userBookRatings)
.where(eq(userBookRatings.userId, userId));
return value;
}
async existsRatingValue(userId: number, value: number): Promise<boolean> {
const [row] = await this.db
.select({ bookId: userBookRatings.bookId })
.from(userBookRatings)
.where(and(eq(userBookRatings.userId, userId), eq(userBookRatings.rating, value)))
.limit(1);
return !!row;
}
// ── Reading velocity (pages) ──
async getPageCountByBookFile(bookFileId: number): Promise<number | null> {
const [row] = await this.db
.select({ pageCount: bookMetadata.pageCount })
.from(bookFiles)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(eq(bookFiles.id, bookFileId))
.limit(1);
return row?.pageCount ?? null;
}
async getMaxSessionPages(userId: number): Promise<number> {
const [result] = await this.db
.select({
value: sql<number>`coalesce(max(${bookMetadata.pageCount} * least(greatest(${readingSessions.progressDelta}, 0), 100) / 100.0), 0)::float`,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(
and(
eq(readingSessions.userId, userId),
isNotNull(bookMetadata.pageCount),
gt(bookMetadata.pageCount, 0),
isNotNull(readingSessions.progressDelta),
gt(readingSessions.progressDelta, 0),
sql`${readingSessions.progressDelta} <= 100`,
gt(readingSessions.durationSeconds, 0),
),
);
return Math.floor(Number(result?.value ?? 0));
}
async getPagesOnDay(userId: number, day: Date): Promise<number> {
const dayStart = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()));
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
const [result] = await this.db
.select({
value: sql<number>`coalesce(sum(${bookMetadata.pageCount} * least(greatest(${readingSessions.progressDelta}, 0), 100) / 100.0), 0)::float`,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(
and(
eq(readingSessions.userId, userId),
gte(readingSessions.startedAt, dayStart),
lt(readingSessions.startedAt, dayEnd),
isNotNull(bookMetadata.pageCount),
gt(bookMetadata.pageCount, 0),
isNotNull(readingSessions.progressDelta),
gt(readingSessions.progressDelta, 0),
sql`${readingSessions.progressDelta} <= 100`,
gt(readingSessions.durationSeconds, 0),
),
);
return Math.floor(Number(result?.value ?? 0));
}
async getMaxPagesInADay(userId: number): Promise<number> {
const result = await this.db.execute<{ max_pages: number }>(sql`
SELECT COALESCE(MAX(day_pages), 0)::float AS max_pages FROM (
SELECT SUM(bm.page_count * LEAST(GREATEST(rs.progress_delta, 0), 100) / 100.0) AS day_pages
FROM reading_sessions rs
JOIN book_files bf ON bf.id = rs.book_file_id
JOIN book_metadata bm ON bm.book_id = bf.book_id
WHERE rs.user_id = ${userId}
AND bm.page_count IS NOT NULL AND bm.page_count > 0
AND rs.progress_delta IS NOT NULL AND rs.progress_delta > 0 AND rs.progress_delta <= 100
AND rs.duration_seconds > 0
GROUP BY DATE_TRUNC('day', rs.started_at AT TIME ZONE 'UTC')
) daily
`);
const rows = (result as unknown as { rows: Array<{ max_pages: number }> }).rows;
return Math.floor(Number(rows[0]?.max_pages ?? 0));
}
// ── Annotation enrichment ──
async getAnnotationNoteLength(annotationId: number, userId: number): Promise<number | null> {
const [row] = await this.db
.select({ note: annotations.note })
.from(annotations)
.where(and(eq(annotations.id, annotationId), eq(annotations.userId, userId)))
.limit(1);
if (!row || row.note === null) return null;
return row.note.length;
}
async getMaxNoteLength(userId: number): Promise<number> {
const [result] = await this.db
.select({ value: sql<number>`coalesce(max(char_length(${annotations.note})), 0)::int` })
.from(annotations)
.where(and(eq(annotations.userId, userId), isNotNull(annotations.note)));
return Number(result?.value ?? 0);
}
async countDistinctColors(userId: number): Promise<number> {
const [{ value }] = await this.db
.select({ value: countDistinct(annotations.color) })
.from(annotations)
.where(eq(annotations.userId, userId));
return value;
}
// ── Devices / reading sources ──
async hasWebSession(userId: number): Promise<boolean> {
// Kobo analytics also writes reading sessions, so a genuine web-reader session must filter on source.
const [row] = await this.db
.select({ id: readingSessions.id })
.from(readingSessions)
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.source, 'web')))
.limit(1);
return !!row;
}
async hasKoreaderSync(userId: number): Promise<boolean> {
const [row] = await this.db
.select({ id: koreaderDeviceProgress.id })
.from(koreaderDeviceProgress)
.where(and(eq(koreaderDeviceProgress.userId, userId), eq(koreaderDeviceProgress.orphaned, false)))
.limit(1);
return !!row;
}
async hasKoboSync(userId: number): Promise<boolean> {
const [row] = await this.db.select({ id: koboReadingStates.id }).from(koboReadingStates).where(eq(koboReadingStates.userId, userId)).limit(1);
return !!row;
}
async hasAnyExternalDevice(userId: number): Promise<boolean> {
const [koreader, kobo] = await Promise.all([this.hasKoreaderSync(userId), this.hasKoboSync(userId)]);
return koreader || kobo;
}
async countDistinctSources(userId: number): Promise<number> {
const [web, koreader, kobo] = await Promise.all([this.hasWebSession(userId), this.hasKoreaderSync(userId), this.hasKoboSync(userId)]);
return (web ? 1 : 0) + (koreader ? 1 : 0) + (kobo ? 1 : 0);
}
async maxSourcesOnSingleBook(userId: number): Promise<number> {
const result = await this.db.execute<{ max_sources: number }>(sql`
SELECT COALESCE(MAX(src_count), 0)::int AS max_sources FROM (
SELECT book_id, COUNT(DISTINCT src) AS src_count FROM (
SELECT bf.book_id, 'web' AS src
FROM reading_sessions rs JOIN book_files bf ON bf.id = rs.book_file_id
WHERE rs.user_id = ${userId} AND rs.source = 'web'
UNION
SELECT bf.book_id, 'koreader' AS src
FROM koreader_device_progress kdp JOIN book_files bf ON bf.id = kdp.book_file_id
WHERE kdp.user_id = ${userId} AND kdp.orphaned = false AND kdp.book_file_id IS NOT NULL
UNION
SELECT krs.book_id, 'kobo' AS src
FROM kobo_reading_states krs WHERE krs.user_id = ${userId}
) sources
GROUP BY book_id
) per_book
`);
const rows = (result as unknown as { rows: Array<{ max_sources: number }> }).rows;
return Number(rows[0]?.max_sources ?? 0);
}
// ── Status ──
async hasAbandonedBook(userId: number): Promise<boolean> {
const [row] = await this.db
.select({ bookId: userBookStatus.bookId })
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'abandoned')))
.limit(1);
return !!row;
}
}
@@ -95,7 +95,7 @@ describe('AchievementService', () => {
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
expect(result.categories).toHaveLength(4);
expect(result.categories).toHaveLength(5);
expect(result.totalAvailable).toBe(1);
expect(result.totalEarned).toBe(0);
const readingCategory = result.categories.find((c) => c.key === 'reading');
@@ -578,7 +578,7 @@ describe('AchievementService', () => {
await service.onModuleInit();
const removeListenerSpy = vi.spyOn(events, 'removeListener');
service.onModuleDestroy();
expect(removeListenerSpy).toHaveBeenCalledTimes(5);
expect(removeListenerSpy).toHaveBeenCalledTimes(7);
});
});
});
@@ -14,6 +14,9 @@ import {
ACHIEVEMENT_EVENT_COLLECTION_CREATED,
ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED,
ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED,
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
ACHIEVEMENT_EVENT_BACKFILL,
} from './achievement-events.service';
import { EvaluatorRegistry } from './evaluators/evaluator-registry';
import { ACHIEVEMENT_SEED } from './seed/achievement-seed';
@@ -23,6 +26,7 @@ import type { AchievementRow, UserAchievementRow } from '../../db/schema';
export class AchievementService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AchievementService.name);
private readonly eventHandlers = new Map<string, (payload: Record<string, unknown>) => void>();
private seedPromise: Promise<void> | null = null;
constructor(
private readonly repo: AchievementRepository,
@@ -32,10 +36,19 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
await this.seedCatalogue();
await this.ensureCatalogueSeeded();
this.registerEventListeners();
}
// Memoized so the catalogue is seeded exactly once, and so a concurrent onModuleInit hook
// (the auto-backfill) can await it before awarding achievements whose keys are FK'd to the catalogue.
ensureCatalogueSeeded(): Promise<void> {
if (!this.seedPromise) {
this.seedPromise = this.seedCatalogue();
}
return this.seedPromise;
}
async getCatalogue(user: RequestUser): Promise<AchievementCatalogueResponse> {
const userId = user.id;
const [allAchievements, userAchievements] = await Promise.all([this.repo.findAllAchievements(), this.repo.findUserAchievements(userId)]);
@@ -47,7 +60,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
const progressMap = await this.computeProgress(userId, user.isSuperuser, allAchievements, earnedMap);
const categoryOrder: AchievementCategory[] = ['reading', 'library', 'exploration', 'dedication'];
const categoryOrder: AchievementCategory[] = ['reading', 'library', 'exploration', 'dedication', 'devices'];
const grouped = new Map<AchievementCategory, AchievementItem[]>();
for (const cat of categoryOrder) {
@@ -111,6 +124,8 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
try {
const [earnedKeys, isSuperuser] = await Promise.all([this.repo.findUserEarnedKeys(userId), this.repo.findUserIsSuperuser(userId)]);
const awards = await this.registry.evaluate({ userId, isSuperuser, eventName, payload }, earnedKeys);
// Backfill is a retroactive catch-up, so it awards silently instead of flooding the user with notifications.
const notify = eventName !== ACHIEVEMENT_EVENT_BACKFILL;
let awarded = 0;
for (const award of awards) {
@@ -119,12 +134,12 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
if (row) {
awarded++;
earnedKeys.add(award.key);
await this.sendNotification(userId, award.key);
if (notify) await this.sendNotification(userId, award.key);
}
}
if (awarded > 0) {
await this.evaluateMetaBadges(userId, isSuperuser, earnedKeys);
await this.evaluateMetaBadges(userId, isSuperuser, earnedKeys, notify);
}
if (awarded > 0) {
@@ -165,6 +180,8 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
ACHIEVEMENT_EVENT_COLLECTION_CREATED,
ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED,
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
];
for (const eventName of events) {
@@ -190,7 +207,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
});
}
private async evaluateMetaBadges(userId: number, isSuperuser: boolean, earnedKeys: Set<string>): Promise<void> {
private async evaluateMetaBadges(userId: number, isSuperuser: boolean, earnedKeys: Set<string>, notify: boolean): Promise<void> {
const metaAwards = await this.registry.evaluate(
{ userId, isSuperuser, eventName: ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED, payload: { userId } },
earnedKeys,
@@ -200,7 +217,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
const row = await this.repo.award(userId, award.key, award.context);
if (row) {
earnedKeys.add(award.key);
await this.sendNotification(userId, award.key);
if (notify) await this.sendNotification(userId, award.key);
}
}
}
@@ -297,6 +314,20 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
const currentYear = new Date().getFullYear();
return this.repo.countDistinctSeasonsWithReading(userId, currentYear);
}
case 'across_the_board':
return this.repo.countDistinctRatingValues(userId);
case 'power_hour':
return this.repo.getMaxSessionPages(userId);
case 'speed_reader':
return this.repo.getMaxPagesInADay(userId);
case 'wordsmith':
return this.repo.getMaxNoteLength(userId);
case 'box_of_crayons':
return this.repo.countDistinctColors(userId);
case 'full_orbit':
return this.repo.countDistinctSources(userId);
case 'two_worlds':
return this.repo.maxSourcesOnSingleBook(userId);
default:
return null;
}
@@ -322,6 +353,8 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy {
return this.repo.getCurrentStreak(userId);
case 'long_book':
return this.repo.getMaxFinishedBookPageCount(userId);
case 'critic':
return this.repo.countRatings(userId);
default:
return null;
}
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DevicesEvaluator } from './devices.evaluator';
import {
ACHIEVEMENT_EVENT_BACKFILL,
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
} from '../achievement-events.service';
function makeRepo(overrides: Record<string, unknown> = {}) {
return {
hasAnyExternalDevice: vi.fn().mockResolvedValue(false),
countDistinctSources: vi.fn().mockResolvedValue(0),
maxSourcesOnSingleBook: vi.fn().mockResolvedValue(0),
...overrides,
};
}
describe('DevicesEvaluator', () => {
let evaluator: DevicesEvaluator;
let repo: ReturnType<typeof makeRepo>;
const progressCtx = {
userId: 1,
isSuperuser: false,
eventName: ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
payload: { userId: 1, bookId: 5, progress: 50, source: 'kobo' } as never,
};
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
beforeEach(() => {
repo = makeRepo();
evaluator = new DevicesEvaluator(repo as never);
});
it('supports progress-changed, reading-session, and backfill events', () => {
expect(evaluator.supports(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED)).toBe(true);
expect(evaluator.supports(ACHIEVEMENT_EVENT_READING_SESSION_SAVED)).toBe(true);
expect(evaluator.supports(ACHIEVEMENT_EVENT_BACKFILL)).toBe(true);
expect(evaluator.supports('other')).toBe(false);
});
describe('synced_up', () => {
it('awards when an external device has synced', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(true);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'synced_up' }));
});
it('does not award without an external device', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(false);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards.find((a) => a.key === 'synced_up')).toBeUndefined();
});
});
describe('full_orbit', () => {
it('awards when all 3 sources are present', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(true);
repo.countDistinctSources.mockResolvedValue(3);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'full_orbit' }));
});
it('does not award with only 2 sources', async () => {
repo.countDistinctSources.mockResolvedValue(2);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards.find((a) => a.key === 'full_orbit')).toBeUndefined();
});
});
describe('two_worlds', () => {
it('awards when a single book has 2 sources', async () => {
repo.maxSourcesOnSingleBook.mockResolvedValue(2);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'two_worlds' }));
});
it('does not award when no book has 2 sources', async () => {
repo.maxSourcesOnSingleBook.mockResolvedValue(1);
const awards = await evaluator.evaluate(progressCtx, new Set());
expect(awards.find((a) => a.key === 'two_worlds')).toBeUndefined();
});
});
describe('reading-session gating', () => {
it('short-circuits a web session when the user has no external device', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(false);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toHaveLength(0);
expect(repo.countDistinctSources).not.toHaveBeenCalled();
expect(repo.maxSourcesOnSingleBook).not.toHaveBeenCalled();
});
});
it('does nothing when all device badges are already earned', async () => {
const awards = await evaluator.evaluate(progressCtx, new Set(['synced_up', 'full_orbit', 'two_worlds']));
expect(awards).toHaveLength(0);
expect(repo.hasAnyExternalDevice).not.toHaveBeenCalled();
});
describe('backfill', () => {
it('awards all device badges from DB scans', async () => {
repo.hasAnyExternalDevice.mockResolvedValue(true);
repo.countDistinctSources.mockResolvedValue(3);
repo.maxSourcesOnSingleBook.mockResolvedValue(2);
const keys = (await evaluator.evaluate(backfillCtx, new Set())).map((a) => a.key);
expect(keys).toContain('synced_up');
expect(keys).toContain('full_orbit');
expect(keys).toContain('two_worlds');
});
});
});
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { AchievementRepository } from '../achievement.repository';
import {
ACHIEVEMENT_EVENT_BACKFILL,
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
} from '../achievement-events.service';
import type { AchievementAward, EvaluationContext, IAchievementEvaluator } from './evaluator.interface';
const FULL_ORBIT_SOURCES = 3;
const TWO_WORLDS_SOURCES = 2;
@Injectable()
export class DevicesEvaluator implements IAchievementEvaluator {
constructor(private readonly repo: AchievementRepository) {}
supports(eventName: string): boolean {
return (
eventName === ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED ||
eventName === ACHIEVEMENT_EVENT_READING_SESSION_SAVED ||
eventName === ACHIEVEMENT_EVENT_BACKFILL
);
}
async evaluate(ctx: EvaluationContext, earnedKeys: Set<string>): Promise<AchievementAward[]> {
const awards: AchievementAward[] = [];
if (earnedKeys.has('synced_up') && earnedKeys.has('full_orbit') && earnedKeys.has('two_worlds')) {
return awards;
}
// A web reading session alone can only complete a device badge if an external device already exists,
// so web-only users short-circuit here instead of running the heavier source queries every session.
if (ctx.eventName === ACHIEVEMENT_EVENT_READING_SESSION_SAVED) {
const hasDevice = await this.repo.hasAnyExternalDevice(ctx.userId);
if (!hasDevice) return awards;
}
await this.evaluateSyncedUp(ctx.userId, earnedKeys, awards);
await this.evaluateFullOrbit(ctx.userId, earnedKeys, awards);
await this.evaluateTwoWorlds(ctx.userId, earnedKeys, awards);
return awards;
}
private async evaluateSyncedUp(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('synced_up')) return;
const hasDevice = await this.repo.hasAnyExternalDevice(userId);
if (hasDevice) {
awards.push({ key: 'synced_up', context: null });
}
}
private async evaluateFullOrbit(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('full_orbit')) return;
const sources = await this.repo.countDistinctSources(userId);
if (sources >= FULL_ORBIT_SOURCES) {
awards.push({ key: 'full_orbit', context: { sources } });
}
}
private async evaluateTwoWorlds(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('two_worlds')) return;
const maxSources = await this.repo.maxSourcesOnSingleBook(userId);
if (maxSources >= TWO_WORLDS_SOURCES) {
awards.push({ key: 'two_worlds', context: { maxSources } });
}
}
}
@@ -17,6 +17,9 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
countAnnotationsWithNotes: vi.fn().mockResolvedValue(0),
countAnnotationsOnDay: vi.fn().mockResolvedValue(0),
hasAnyDayWithAnnotationCount: vi.fn().mockResolvedValue(false),
getAnnotationNoteLength: vi.fn().mockResolvedValue(null),
getMaxNoteLength: vi.fn().mockResolvedValue(0),
countDistinctColors: vi.fn().mockResolvedValue(0),
...overrides,
};
}
@@ -227,4 +230,71 @@ describe('LibraryEvaluator', () => {
expect(awards.find((a) => a.key === 'deep_dive_session')).toBeUndefined();
});
});
describe('wordsmith', () => {
it('awards wordsmith for a note longer than 280 chars', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(300);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'wordsmith' }));
});
it('does not award wordsmith for a note of exactly 280 chars', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(280);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
new Set(),
);
expect(awards.find((a) => a.key === 'wordsmith')).toBeUndefined();
});
it('does not award wordsmith when the annotation has no note', async () => {
repo.getAnnotationNoteLength.mockResolvedValue(null);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
new Set(),
);
expect(awards.find((a) => a.key === 'wordsmith')).toBeUndefined();
});
it('awards wordsmith via backfill', async () => {
repo.getMaxNoteLength.mockResolvedValue(500);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'wordsmith' }));
});
});
describe('box_of_crayons', () => {
it('awards box_of_crayons when all 5 colors are used', async () => {
repo.countDistinctColors.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'box_of_crayons' }));
});
it('does not award box_of_crayons with only 4 colors', async () => {
repo.countDistinctColors.mockResolvedValue(4);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_ANNOTATION_CREATED, payload: { userId: 1, bookId: 1, annotationId: 9 } },
new Set(),
);
expect(awards.find((a) => a.key === 'box_of_crayons')).toBeUndefined();
});
it('awards box_of_crayons via backfill', async () => {
repo.countDistinctColors.mockResolvedValue(5);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'box_of_crayons' }));
});
});
});
@@ -28,6 +28,8 @@ const ANNOTATOR_TIERS = [
const NOTE_KEEPER_THRESHOLD = 25;
const DEEP_DIVE_SESSION_THRESHOLD = 10;
const WORDSMITH_MIN_LENGTH = 280;
const BOX_OF_CRAYONS_COLORS = 5;
@Injectable()
export class LibraryEvaluator implements IAchievementEvaluator {
@@ -51,6 +53,8 @@ export class LibraryEvaluator implements IAchievementEvaluator {
await this.evaluateAnnotatorTiers(ctx.userId, earnedKeys, awards);
await this.evaluateNoteKeeper(ctx.userId, earnedKeys, awards);
await this.evaluateDeepDiveSession(ctx.userId, payload, earnedKeys, awards);
await this.evaluateWordsmith(ctx.userId, payload, earnedKeys, awards);
await this.evaluateBoxOfCrayons(ctx.userId, earnedKeys, awards);
}
if (ctx.eventName === ACHIEVEMENT_EVENT_COLLECTION_CREATED) {
@@ -77,6 +81,8 @@ export class LibraryEvaluator implements IAchievementEvaluator {
await this.evaluateMultiFormat(ctx.userId, ctx.isSuperuser, earnedKeys, awards);
await this.evaluateNoteKeeper(ctx.userId, earnedKeys, awards);
await this.evaluateDeepDiveSessionBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateWordsmithBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateBoxOfCrayons(ctx.userId, earnedKeys, awards);
}
return awards;
@@ -158,4 +164,33 @@ export class LibraryEvaluator implements IAchievementEvaluator {
awards.push({ key: 'deep_dive_session', context: {} });
}
}
private async evaluateWordsmith(
userId: number,
payload: AnnotationCreatedPayload,
earnedKeys: Set<string>,
awards: AchievementAward[],
): Promise<void> {
if (earnedKeys.has('wordsmith')) return;
const length = await this.repo.getAnnotationNoteLength(payload.annotationId, userId);
if (length !== null && length > WORDSMITH_MIN_LENGTH) {
awards.push({ key: 'wordsmith', context: { length, bookId: payload.bookId } });
}
}
private async evaluateWordsmithBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('wordsmith')) return;
const maxLength = await this.repo.getMaxNoteLength(userId);
if (maxLength > WORDSMITH_MIN_LENGTH) {
awards.push({ key: 'wordsmith', context: { length: maxLength } });
}
}
private async evaluateBoxOfCrayons(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('box_of_crayons')) return;
const colors = await this.repo.countDistinctColors(userId);
if (colors >= BOX_OF_CRAYONS_COLORS) {
awards.push({ key: 'box_of_crayons', context: { colors } });
}
}
}
@@ -20,6 +20,7 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
countBooksFinishedInYear: vi.fn().mockResolvedValue(0),
countDistinctEarnedCategories: vi.fn().mockResolvedValue(0),
hasFinishedBookInSeries: vi.fn().mockResolvedValue(false),
hasAbandonedBook: vi.fn().mockResolvedValue(false),
...overrides,
};
}
@@ -270,4 +271,77 @@ describe('MilestonesEvaluator', () => {
expect(awards).toContainEqual(expect.objectContaining({ key: 'new_year_reader' }));
});
});
describe('old_friend', () => {
it('awards old_friend when a re-read is finished (previousStatus rereading)', async () => {
const awards = await evaluator.evaluate(
{
userId: 1,
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', previousStatus: 'rereading' } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'old_friend' }));
});
it('does not award old_friend for a first-time finish', async () => {
const awards = await evaluator.evaluate(
{
userId: 1,
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', previousStatus: 'reading' } as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'old_friend')).toBeUndefined();
});
it('does not award old_friend when already earned', async () => {
const awards = await evaluator.evaluate(
{
userId: 1,
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'read', previousStatus: 'rereading' } as never,
},
new Set(['old_friend']),
);
expect(awards.find((a) => a.key === 'old_friend')).toBeUndefined();
});
});
describe('dnf_self_aware', () => {
it('awards dnf_self_aware when a book is abandoned', async () => {
const awards = await evaluator.evaluate(
{
userId: 1,
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'abandoned', previousStatus: 'reading' } as never,
},
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'dnf_self_aware' }));
});
it('does not award dnf_self_aware for a non-abandoned status', async () => {
const awards = await evaluator.evaluate(
{
userId: 1,
eventName: ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
payload: { userId: 1, bookId: 5, newStatus: 'reading', previousStatus: 'unread' } as never,
},
new Set(),
);
expect(awards.find((a) => a.key === 'dnf_self_aware')).toBeUndefined();
});
it('awards dnf_self_aware via backfill when an abandoned book exists', async () => {
repo.hasAbandonedBook.mockResolvedValue(true);
const awards = await evaluator.evaluate(
{ userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'dnf_self_aware' }));
});
});
});
@@ -36,6 +36,10 @@ export class MilestonesEvaluator implements IAchievementEvaluator {
const payload = ctx.payload as unknown as BookStatusChangedPayload;
if (payload.newStatus === 'read') {
await this.evaluateFirstSeries(ctx.userId, payload.bookId, earnedKeys, awards);
this.evaluateOldFriend(payload, earnedKeys, awards);
}
if (payload.newStatus === 'abandoned') {
this.evaluateDnfSelfAware(payload, earnedKeys, awards);
}
}
@@ -56,6 +60,7 @@ export class MilestonesEvaluator implements IAchievementEvaluator {
await this.evaluateBookmarkedBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateNewYearReaderBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateFirstSeriesBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateDnfSelfAwareBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateCategorySweeper(ctx.userId, earnedKeys, awards);
}
@@ -115,4 +120,26 @@ export class MilestonesEvaluator implements IAchievementEvaluator {
awards.push({ key: 'first_series', context: {} });
}
}
// Awarded when a book that was being re-read is finished again. Re-read history is not stored,
// so this is live-only (no backfill branch) and only accrues going forward.
private evaluateOldFriend(payload: BookStatusChangedPayload, earnedKeys: Set<string>, awards: AchievementAward[]): void {
if (earnedKeys.has('old_friend')) return;
if (payload.previousStatus === 'rereading') {
awards.push({ key: 'old_friend', context: { bookId: payload.bookId } });
}
}
private evaluateDnfSelfAware(payload: BookStatusChangedPayload, earnedKeys: Set<string>, awards: AchievementAward[]): void {
if (earnedKeys.has('dnf_self_aware')) return;
awards.push({ key: 'dnf_self_aware', context: { bookId: payload.bookId } });
}
private async evaluateDnfSelfAwareBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('dnf_self_aware')) return;
const hasAbandoned = await this.repo.hasAbandonedBook(userId);
if (hasAbandoned) {
awards.push({ key: 'dnf_self_aware', context: null });
}
}
}
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RatingEvaluator } from './rating.evaluator';
import { ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement-events.service';
function makeRepo(overrides: Record<string, unknown> = {}) {
return {
countRatings: vi.fn().mockResolvedValue(0),
countRatingsAtMost: vi.fn().mockResolvedValue(0),
countDistinctRatingValues: vi.fn().mockResolvedValue(0),
existsRatingValue: vi.fn().mockResolvedValue(false),
...overrides,
};
}
function makeRatingPayload(overrides: Partial<Record<string, unknown>> = {}) {
return { userId: 1, bookIds: [100], rating: 4, ...overrides };
}
describe('RatingEvaluator', () => {
let evaluator: RatingEvaluator;
let repo: ReturnType<typeof makeRepo>;
const backfillCtx = { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BACKFILL, payload: { userId: 1 } as never };
beforeEach(() => {
repo = makeRepo();
evaluator = new RatingEvaluator(repo as never);
});
function ratingCtx(payload = makeRatingPayload()) {
return { userId: 1, isSuperuser: false, eventName: ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, payload: payload as never };
}
it('supports rating-changed and backfill events', () => {
expect(evaluator.supports(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED)).toBe(true);
expect(evaluator.supports(ACHIEVEMENT_EVENT_BACKFILL)).toBe(true);
expect(evaluator.supports('other')).toBe(false);
});
describe('first_verdict', () => {
it('awards first_verdict on first rating', async () => {
repo.countRatings.mockResolvedValue(1);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'first_verdict' }));
});
it('does nothing when the rating was removed (null)', async () => {
repo.countRatings.mockResolvedValue(5);
const awards = await evaluator.evaluate(ratingCtx(makeRatingPayload({ rating: null })), new Set());
expect(awards).toHaveLength(0);
});
});
describe('critic tiers', () => {
it('awards critic_1 at 10 ratings', async () => {
repo.countRatings.mockResolvedValue(10);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'critic_1' }));
});
it('awards multiple critic tiers at once', async () => {
repo.countRatings.mockResolvedValue(220);
const keys = (await evaluator.evaluate(ratingCtx(), new Set())).map((a) => a.key);
expect(keys).toContain('critic_1');
expect(keys).toContain('critic_2');
expect(keys).toContain('critic_3');
expect(keys).not.toContain('critic_4');
});
it('skips already earned tiers', async () => {
repo.countRatings.mockResolvedValue(60);
const keys = (await evaluator.evaluate(ratingCtx(), new Set(['critic_1']))).map((a) => a.key);
expect(keys).not.toContain('critic_1');
expect(keys).toContain('critic_2');
});
});
describe('standing_ovation', () => {
it('awards on a 5-star rating', async () => {
const awards = await evaluator.evaluate(ratingCtx(makeRatingPayload({ rating: 5 })), new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'standing_ovation' }));
});
it('does not award below 5 stars', async () => {
const awards = await evaluator.evaluate(ratingCtx(makeRatingPayload({ rating: 4 })), new Set());
expect(awards.find((a) => a.key === 'standing_ovation')).toBeUndefined();
});
it('awards via backfill when a 5-star rating exists', async () => {
repo.existsRatingValue.mockResolvedValue(true);
const awards = await evaluator.evaluate(backfillCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'standing_ovation' }));
expect(repo.existsRatingValue).toHaveBeenCalledWith(1, 5);
});
});
describe('across_the_board', () => {
it('awards when all five star values have been used', async () => {
repo.countDistinctRatingValues.mockResolvedValue(5);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'across_the_board' }));
});
it('does not award with only 4 distinct values', async () => {
repo.countDistinctRatingValues.mockResolvedValue(4);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards.find((a) => a.key === 'across_the_board')).toBeUndefined();
});
});
describe('tough_crowd', () => {
it('awards when 10 ratings are 2 stars or lower', async () => {
repo.countRatingsAtMost.mockResolvedValue(10);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'tough_crowd' }));
expect(repo.countRatingsAtMost).toHaveBeenCalledWith(1, 2);
});
it('does not award below 10 low ratings', async () => {
repo.countRatingsAtMost.mockResolvedValue(9);
const awards = await evaluator.evaluate(ratingCtx(), new Set());
expect(awards.find((a) => a.key === 'tough_crowd')).toBeUndefined();
});
});
describe('backfill', () => {
it('awards rating badges from DB scans', async () => {
repo.countRatings.mockResolvedValue(10);
repo.countDistinctRatingValues.mockResolvedValue(5);
repo.countRatingsAtMost.mockResolvedValue(10);
const keys = (await evaluator.evaluate(backfillCtx, new Set())).map((a) => a.key);
expect(keys).toContain('first_verdict');
expect(keys).toContain('critic_1');
expect(keys).toContain('across_the_board');
expect(keys).toContain('tough_crowd');
});
});
});
@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { AchievementRepository } from '../achievement.repository';
import { ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, type BookRatingChangedPayload } from '../achievement-events.service';
import type { AchievementAward, EvaluationContext, IAchievementEvaluator } from './evaluator.interface';
const CRITIC_TIERS = [
{ key: 'critic_1', threshold: 10 },
{ key: 'critic_2', threshold: 50 },
{ key: 'critic_3', threshold: 200 },
{ key: 'critic_4', threshold: 500 },
];
const ACROSS_THE_BOARD_THRESHOLD = 5;
const TOUGH_CROWD_MAX_RATING = 2;
const TOUGH_CROWD_THRESHOLD = 10;
const STANDING_OVATION_RATING = 5;
@Injectable()
export class RatingEvaluator implements IAchievementEvaluator {
constructor(private readonly repo: AchievementRepository) {}
supports(eventName: string): boolean {
return eventName === ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED || eventName === ACHIEVEMENT_EVENT_BACKFILL;
}
async evaluate(ctx: EvaluationContext, earnedKeys: Set<string>): Promise<AchievementAward[]> {
const awards: AchievementAward[] = [];
if (ctx.eventName === ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED) {
const payload = ctx.payload as unknown as BookRatingChangedPayload;
if (payload.rating === null) return awards;
await this.evaluateFirstVerdict(ctx.userId, earnedKeys, awards);
await this.evaluateCriticTiers(ctx.userId, earnedKeys, awards);
this.evaluateStandingOvation(payload, earnedKeys, awards);
await this.evaluateAcrossTheBoard(ctx.userId, earnedKeys, awards);
await this.evaluateToughCrowd(ctx.userId, earnedKeys, awards);
}
if (ctx.eventName === ACHIEVEMENT_EVENT_BACKFILL) {
await this.evaluateFirstVerdict(ctx.userId, earnedKeys, awards);
await this.evaluateCriticTiers(ctx.userId, earnedKeys, awards);
await this.evaluateStandingOvationBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateAcrossTheBoard(ctx.userId, earnedKeys, awards);
await this.evaluateToughCrowd(ctx.userId, earnedKeys, awards);
}
return awards;
}
private async evaluateFirstVerdict(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('first_verdict')) return;
const count = await this.repo.countRatings(userId);
if (count >= 1) {
awards.push({ key: 'first_verdict', context: { count } });
}
}
private async evaluateCriticTiers(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
const unearnedTiers = CRITIC_TIERS.filter((t) => !earnedKeys.has(t.key));
if (unearnedTiers.length === 0) return;
const count = await this.repo.countRatings(userId);
for (const tier of unearnedTiers) {
if (count >= tier.threshold) {
awards.push({ key: tier.key, context: { count } });
}
}
}
private evaluateStandingOvation(payload: BookRatingChangedPayload, earnedKeys: Set<string>, awards: AchievementAward[]): void {
if (earnedKeys.has('standing_ovation')) return;
if (payload.rating === STANDING_OVATION_RATING) {
awards.push({ key: 'standing_ovation', context: null });
}
}
private async evaluateStandingOvationBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('standing_ovation')) return;
const has = await this.repo.existsRatingValue(userId, STANDING_OVATION_RATING);
if (has) {
awards.push({ key: 'standing_ovation', context: null });
}
}
private async evaluateAcrossTheBoard(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('across_the_board')) return;
const distinct = await this.repo.countDistinctRatingValues(userId);
if (distinct >= ACROSS_THE_BOARD_THRESHOLD) {
awards.push({ key: 'across_the_board', context: { distinct } });
}
}
private async evaluateToughCrowd(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('tough_crowd')) return;
const count = await this.repo.countRatingsAtMost(userId, TOUGH_CROWD_MAX_RATING);
if (count >= TOUGH_CROWD_THRESHOLD) {
awards.push({ key: 'tough_crowd', context: { count } });
}
}
}
@@ -26,6 +26,10 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
hasMonthWithBooksFinished: vi.fn().mockResolvedValue(false),
hasSessionWithProgressDeltaAtLeast: vi.fn().mockResolvedValue(false),
countBooksFinishedInMonth: vi.fn().mockResolvedValue(0),
getPageCountByBookFile: vi.fn().mockResolvedValue(null),
getMaxSessionPages: vi.fn().mockResolvedValue(0),
getPagesOnDay: vi.fn().mockResolvedValue(0),
getMaxPagesInADay: vi.fn().mockResolvedValue(0),
...overrides,
};
}
@@ -644,4 +648,74 @@ describe('ReadingEvaluator', () => {
expect(repo.hasMonthWithBooksFinished).not.toHaveBeenCalled();
});
});
describe('power_hour', () => {
it('awards power_hour for 75+ pages in a session', async () => {
repo.getPageCountByBookFile.mockResolvedValue(300);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload({ progressDelta: 30 }) as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'power_hour' }));
});
it('does not award power_hour below 75 pages', async () => {
repo.getPageCountByBookFile.mockResolvedValue(300);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload({ progressDelta: 10 }) as never },
new Set(),
);
expect(awards.find((a) => a.key === 'power_hour')).toBeUndefined();
});
it('does not award power_hour when progressDelta is null', async () => {
repo.getPageCountByBookFile.mockResolvedValue(1000);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload({ progressDelta: null }) as never },
new Set(),
);
expect(awards.find((a) => a.key === 'power_hour')).toBeUndefined();
});
it('does not award power_hour when page count is unknown', async () => {
repo.getPageCountByBookFile.mockResolvedValue(null);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload({ progressDelta: 50 }) as never },
new Set(),
);
expect(awards.find((a) => a.key === 'power_hour')).toBeUndefined();
});
it('awards power_hour via backfill', async () => {
repo.getMaxSessionPages.mockResolvedValue(80);
const awards = await evaluator.evaluate(backfillCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'power_hour' }));
});
});
describe('speed_reader', () => {
it('awards speed_reader for 100+ pages in a day', async () => {
repo.getPagesOnDay.mockResolvedValue(120);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
new Set(),
);
expect(awards).toContainEqual(expect.objectContaining({ key: 'speed_reader' }));
});
it('does not award speed_reader below 100 pages', async () => {
repo.getPagesOnDay.mockResolvedValue(99);
const awards = await evaluator.evaluate(
{ userId: 1, eventName: ACHIEVEMENT_EVENT_READING_SESSION_SAVED, payload: makeSessionPayload() as never },
new Set(),
);
expect(awards.find((a) => a.key === 'speed_reader')).toBeUndefined();
});
it('awards speed_reader via backfill', async () => {
repo.getMaxPagesInADay.mockResolvedValue(150);
const awards = await evaluator.evaluate(backfillCtx, new Set());
expect(awards).toContainEqual(expect.objectContaining({ key: 'speed_reader' }));
});
});
});
@@ -47,6 +47,8 @@ const ALL_NIGHTER_START_HOUR = 1;
const ALL_NIGHTER_END_HOUR = 4;
const EARLY_BIRD_START_HOUR = 5;
const EARLY_BIRD_END_HOUR = 7;
const POWER_HOUR_PAGES = 75;
const SPEED_READER_PAGES = 100;
@Injectable()
export class ReadingEvaluator implements IAchievementEvaluator {
@@ -84,6 +86,8 @@ export class ReadingEvaluator implements IAchievementEvaluator {
this.evaluateAllNighter(payload, earnedKeys, awards);
this.evaluateEarlyBird(payload, earnedKeys, awards);
this.evaluateOneSitting(payload, earnedKeys, awards);
await this.evaluatePowerHour(payload, earnedKeys, awards);
await this.evaluateSpeedReader(ctx.userId, payload, earnedKeys, awards);
}
if (ctx.eventName === ACHIEVEMENT_EVENT_BACKFILL) {
@@ -99,6 +103,8 @@ export class ReadingEvaluator implements IAchievementEvaluator {
await this.evaluateSlowBurnBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateMonthlyReader2Backfill(ctx.userId, earnedKeys, awards);
await this.evaluateOneSittingBackfill(ctx.userId, earnedKeys, awards);
await this.evaluatePowerHourBackfill(ctx.userId, earnedKeys, awards);
await this.evaluateSpeedReaderBackfill(ctx.userId, earnedKeys, awards);
}
return awards;
@@ -318,4 +324,44 @@ export class ReadingEvaluator implements IAchievementEvaluator {
awards.push({ key: 'one_sitting', context: {} });
}
}
private async evaluatePowerHour(payload: ReadingSessionSavedPayload, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('power_hour')) return;
if (payload.progressDelta === null || payload.progressDelta <= 0) return;
const pageCount = await this.repo.getPageCountByBookFile(payload.bookFileId);
if (pageCount === null || pageCount <= 0) return;
const pages = (Math.min(Math.max(payload.progressDelta, 0), 100) / 100) * pageCount;
if (pages >= POWER_HOUR_PAGES) {
awards.push({ key: 'power_hour', context: { pages: Math.floor(pages) } });
}
}
private async evaluateSpeedReader(
userId: number,
payload: ReadingSessionSavedPayload,
earnedKeys: Set<string>,
awards: AchievementAward[],
): Promise<void> {
if (earnedKeys.has('speed_reader')) return;
const pages = await this.repo.getPagesOnDay(userId, payload.startedAt);
if (pages >= SPEED_READER_PAGES) {
awards.push({ key: 'speed_reader', context: { pages } });
}
}
private async evaluatePowerHourBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('power_hour')) return;
const maxPages = await this.repo.getMaxSessionPages(userId);
if (maxPages >= POWER_HOUR_PAGES) {
awards.push({ key: 'power_hour', context: { pages: maxPages } });
}
}
private async evaluateSpeedReaderBackfill(userId: number, earnedKeys: Set<string>, awards: AchievementAward[]): Promise<void> {
if (earnedKeys.has('speed_reader')) return;
const maxDayPages = await this.repo.getMaxPagesInADay(userId);
if (maxDayPages >= SPEED_READER_PAGES) {
awards.push({ key: 'speed_reader', context: { pages: maxDayPages } });
}
}
}
@@ -948,4 +948,235 @@ export const ACHIEVEMENT_SEED: NewAchievement[] = [
hidden: false,
sortOrder: 16,
},
// ── Reading: Velocity ──
{
key: 'power_hour',
groupKey: null,
tier: null,
category: 'reading',
name: 'Power Hour',
description: 'Read 75 or more pages in a single reading session',
iconName: 'gauge',
rarity: 'rare',
threshold: 75,
hidden: false,
sortOrder: 25,
},
{
key: 'speed_reader',
groupKey: null,
tier: null,
category: 'reading',
name: 'Speed Reader',
description: 'Read 100 or more pages in a single day',
iconName: 'rabbit',
rarity: 'epic',
threshold: 100,
hidden: false,
sortOrder: 26,
},
// ── Reading: Re-read & DNF ──
{
key: 'old_friend',
groupKey: null,
tier: null,
category: 'reading',
name: 'Old Friend',
description: 'Finish a book you were re-reading',
iconName: 'book-heart',
rarity: 'rare',
threshold: null,
hidden: false,
sortOrder: 27,
},
{
key: 'dnf_self_aware',
groupKey: null,
tier: null,
category: 'reading',
name: "It's Not You, It's Me",
description: 'Mark a book as abandoned',
iconName: 'book-x',
rarity: 'common',
threshold: null,
hidden: true,
sortOrder: 28,
},
// ── Library: Ratings ──
{
key: 'first_verdict',
groupKey: null,
tier: null,
category: 'library',
name: 'First Verdict',
description: 'Rate your first book',
iconName: 'gavel',
rarity: 'common',
threshold: null,
hidden: false,
sortOrder: 14,
},
{
key: 'critic_1',
groupKey: 'critic',
tier: 1,
category: 'library',
name: 'Fledgling Critic',
description: 'Rate 10 books',
iconName: 'star',
rarity: 'common',
threshold: 10,
hidden: false,
sortOrder: 15,
},
{
key: 'critic_2',
groupKey: 'critic',
tier: 2,
category: 'library',
name: 'Seasoned Critic',
description: 'Rate 50 books',
iconName: 'star',
rarity: 'rare',
threshold: 50,
hidden: false,
sortOrder: 16,
},
{
key: 'critic_3',
groupKey: 'critic',
tier: 3,
category: 'library',
name: 'Esteemed Critic',
description: 'Rate 200 books',
iconName: 'star',
rarity: 'epic',
threshold: 200,
hidden: false,
sortOrder: 17,
},
{
key: 'critic_4',
groupKey: 'critic',
tier: 4,
category: 'library',
name: 'Definitive Critic',
description: 'Rate 500 books',
iconName: 'star',
rarity: 'legendary',
threshold: 500,
hidden: false,
sortOrder: 18,
},
{
key: 'standing_ovation',
groupKey: null,
tier: null,
category: 'library',
name: 'Standing Ovation',
description: 'Give a book a 5-star rating',
iconName: 'medal',
rarity: 'common',
threshold: null,
hidden: false,
sortOrder: 19,
},
{
key: 'across_the_board',
groupKey: null,
tier: null,
category: 'library',
name: 'Across the Board',
description: 'Use all five star ratings at least once',
iconName: 'star-half',
rarity: 'rare',
threshold: 5,
hidden: false,
sortOrder: 20,
},
{
key: 'tough_crowd',
groupKey: null,
tier: null,
category: 'library',
name: 'Tough Crowd',
description: 'Give 10 books a rating of 2 stars or lower',
iconName: 'thumbs-down',
rarity: 'rare',
threshold: null,
hidden: true,
sortOrder: 21,
},
// ── Library: Annotation depth ──
{
key: 'wordsmith',
groupKey: null,
tier: null,
category: 'library',
name: 'Wordsmith',
description: 'Write a note longer than 280 characters',
iconName: 'pen-tool',
rarity: 'rare',
threshold: 280,
hidden: false,
sortOrder: 22,
},
{
key: 'box_of_crayons',
groupKey: null,
tier: null,
category: 'library',
name: 'Box of Crayons',
description: 'Use all 5 highlight colors',
iconName: 'palette',
rarity: 'rare',
threshold: 5,
hidden: false,
sortOrder: 23,
},
// ── Devices: Cross-device sync ──
{
key: 'synced_up',
groupKey: null,
tier: null,
category: 'devices',
name: 'Synced Up',
description: 'Sync reading progress from a Kobo or KOReader device for the first time',
iconName: 'smartphone',
rarity: 'common',
threshold: null,
hidden: false,
sortOrder: 1,
},
{
key: 'full_orbit',
groupKey: null,
tier: null,
category: 'devices',
name: 'Full Orbit',
description: 'Log reading from the web reader, a Kobo, and a KOReader device',
iconName: 'orbit',
rarity: 'legendary',
threshold: 3,
hidden: false,
sortOrder: 2,
},
{
key: 'two_worlds',
groupKey: null,
tier: null,
category: 'devices',
name: 'Two Worlds',
description: 'Read the same book from two or more different sources',
iconName: 'arrow-left-right',
rarity: 'rare',
threshold: 2,
hidden: false,
sortOrder: 3,
},
];
@@ -71,6 +71,13 @@ describe('AppSettingsService', () => {
});
});
describe('setValue', () => {
it('upserts the key/value pair', async () => {
await service.setValue('achievements_backfill_signature', 'abc123');
expect(repo.upsert).toHaveBeenCalledWith('achievements_backfill_signature', 'abc123');
});
});
describe('update', () => {
it('returns updated setting', async () => {
const setting = { key: 'allow_registration', value: 'true' };
@@ -51,6 +51,10 @@ export class AppSettingsService {
return row?.value ?? null;
}
async setValue(key: string, value: string): Promise<void> {
await this.repo.upsert(key, value);
}
async update(key: string, value: string) {
const setting = await this.repo.updateByKey(key, value);
if (!setting) throw new NotFoundException(`Setting '${key}' not found`);
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { CommonModule } from '../../common/common.module';
import { AchievementModule } from '../achievement/achievement.module';
import { BookModule } from '../book/book.module';
import { ReadingSessionModule } from '../reading-session/reading-session.module';
import { UserModule } from '../user/user.module';
@@ -25,7 +26,7 @@ import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver
import { KoboAnalyticsService } from './services/kobo-analytics.service';
@Module({
imports: [CommonModule, BookModule, UserModule, UserBookStatusModule, ReadingSessionModule],
imports: [CommonModule, AchievementModule, BookModule, UserModule, UserBookStatusModule, ReadingSessionModule],
controllers: [KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController],
providers: [
KoboTokenGuard,
@@ -66,6 +66,7 @@ describe('KoboAnalyticsService', () => {
endProgress: 4,
},
user,
'kobo',
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(
2,
@@ -76,6 +77,7 @@ describe('KoboAnalyticsService', () => {
endProgress: 6,
}),
user,
'kobo',
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(
3,
@@ -85,6 +87,7 @@ describe('KoboAnalyticsService', () => {
durationSeconds: 4,
}),
user,
'kobo',
);
});
@@ -139,6 +142,7 @@ describe('KoboAnalyticsService', () => {
endProgress: 12,
},
user,
'kobo',
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(
2,
@@ -152,6 +156,7 @@ describe('KoboAnalyticsService', () => {
endProgress: 10,
},
user,
'kobo',
);
});
@@ -191,8 +196,20 @@ describe('KoboAnalyticsService', () => {
device,
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(1, 100, expect.objectContaining({ sessionId: 'leave-one', progressDelta: 2 }), user);
expect(readingSessionService.save).toHaveBeenNthCalledWith(2, 100, expect.objectContaining({ sessionId: 'leave-two', progressDelta: 5 }), user);
expect(readingSessionService.save).toHaveBeenNthCalledWith(
1,
100,
expect.objectContaining({ sessionId: 'leave-one', progressDelta: 2 }),
user,
'kobo',
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(
2,
100,
expect.objectContaining({ sessionId: 'leave-two', progressDelta: 5 }),
user,
'kobo',
);
});
it('falls back to null progressDelta when the paired open progress is invalid', async () => {
@@ -222,6 +239,7 @@ describe('KoboAnalyticsService', () => {
100,
expect.objectContaining({ sessionId: 'leave-after-bad-progress', progressDelta: null, endProgress: 20 }),
user,
'kobo',
);
});
@@ -250,6 +268,7 @@ describe('KoboAnalyticsService', () => {
durationSeconds: 20,
}),
user,
'kobo',
);
});
@@ -277,8 +296,8 @@ describe('KoboAnalyticsService', () => {
device,
);
expect(readingSessionService.save).toHaveBeenNthCalledWith(1, 100, expect.objectContaining({ endProgress: null }), user);
expect(readingSessionService.save).toHaveBeenNthCalledWith(2, 100, expect.objectContaining({ endProgress: null }), user);
expect(readingSessionService.save).toHaveBeenNthCalledWith(1, 100, expect.objectContaining({ endProgress: null }), user, 'kobo');
expect(readingSessionService.save).toHaveBeenNthCalledWith(2, 100, expect.objectContaining({ endProgress: null }), user, 'kobo');
});
it('still processes later events when an earlier save throws', async () => {
@@ -358,7 +377,7 @@ describe('KoboAnalyticsService', () => {
expect(bookIdentityService.resolveBookIdByEntitlementId).toHaveBeenCalledWith(7, entitlementId);
expect(resolver.resolveBookFileId).toHaveBeenCalledWith(7, 9);
expect(readingSessionService.save).toHaveBeenCalledWith(100, expect.objectContaining({ sessionId: 'uuid-volume', endProgress: 8 }), user);
expect(readingSessionService.save).toHaveBeenCalledWith(100, expect.objectContaining({ sessionId: 'uuid-volume', endProgress: 8 }), user, 'kobo');
});
it('skips LeaveContent without volumeid or SecondsRead', async () => {
@@ -182,6 +182,7 @@ export class KoboAnalyticsService {
endProgress: parseKoboProgress(ev.Attributes?.progress),
},
user,
'kobo',
);
}
@@ -1,4 +1,5 @@
import { KoboReadingStateService } from './kobo-reading-state.service';
import { ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../../achievement/achievement-events.service';
function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
@@ -37,9 +38,16 @@ describe('KoboReadingStateService', () => {
const bookAccessService = { assertBookAccessible: vi.fn() };
const userBookStatusService = { autoUpdate: vi.fn() };
const bookIdentityService = { ensureForBook: vi.fn() };
const achievementEvents = { emit: vi.fn() };
function makeService(db: ReturnType<typeof makeDb>) {
return new KoboReadingStateService(db as never, bookAccessService as never, userBookStatusService as never, bookIdentityService as never);
return new KoboReadingStateService(
db as never,
bookAccessService as never,
userBookStatusService as never,
bookIdentityService as never,
achievementEvents as never,
);
}
beforeEach(() => {
@@ -210,6 +218,10 @@ describe('KoboReadingStateService', () => {
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(1, 5, 42.5, 1, 99);
expect(db.select).not.toHaveBeenCalled();
expect(db.execute).not.toHaveBeenCalled();
expect(achievementEvents.emit).toHaveBeenCalledWith(
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
expect.objectContaining({ userId: 1, bookId: 5, progress: 42.5, source: 'kobo' }),
);
});
it('does not replace newer internal CFI progress with stale Kobo percent', async () => {
@@ -5,6 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
import { UserBookStatusService } from '../../user-book-status/user-book-status.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../../achievement/achievement-events.service';
import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
@@ -28,6 +29,7 @@ export class KoboReadingStateService {
private readonly bookAccessService: KoboBookAccessService,
private readonly userBookStatusService: UserBookStatusService,
private readonly bookIdentityService: KoboBookIdentityService,
private readonly achievementEvents: AchievementEventsService,
) {}
async upsertState(
@@ -122,6 +124,12 @@ export class KoboReadingStateService {
await this.markSnapshotBookUnsynced(userId, bookId);
}
void this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold);
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId,
bookId,
progress: percent,
source: 'kobo',
});
}
return this.getRawState(userId, bookId);
@@ -108,6 +108,7 @@ describe('ReadingSessionRepository', () => {
60,
null,
42.5,
'kobo',
);
expect(result).toEqual({ kind: 'saved' });
@@ -119,6 +120,7 @@ describe('ReadingSessionRepository', () => {
durationSeconds: 60,
progressDelta: null,
endProgress: 42.5,
source: 'kobo',
}),
);
expect(sessionConflict).toHaveBeenCalledWith({ target: [readingSessions.userId, readingSessions.sessionId] });
@@ -6,6 +6,7 @@ import type { BookReadingSession, BookReadingSessionListResponse, BookReadingSes
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, books, readingSessions, userReadingDailyStats } from '../../db/schema';
import type { ReadingSessionSource } from '../../db/schema/reader';
type Db = NodePgDatabase<typeof schema>;
@@ -31,6 +32,7 @@ export class ReadingSessionRepository {
durationSeconds: number,
progressDelta: number | null,
endProgress: number | null,
source: ReadingSessionSource,
): Promise<SaveReadingSessionResult> {
if (durationSeconds < MIN_READING_SESSION_SECONDS) {
return { kind: 'skipped', reason: 'duration_below_minimum' };
@@ -52,7 +54,7 @@ export class ReadingSessionRepository {
return this.db.transaction(async (tx): Promise<SaveReadingSessionResult> => {
const inserted = await tx
.insert(readingSessions)
.values({ userId, bookFileId, sessionId, startedAt, endedAt, durationSeconds, progressDelta, endProgress })
.values({ userId, bookFileId, sessionId, startedAt, endedAt, durationSeconds, progressDelta, endProgress, source })
.onConflictDoNothing({ target: [readingSessions.userId, readingSessions.sessionId] })
.returning({ id: readingSessions.id });
@@ -28,7 +28,8 @@ function makeUser(overrides?: Partial<RequestUser>): RequestUser {
}
const mockRepo = {
saveSession: vi.fn<(...args: [number, number, string, Date, Date, number, number | null, number | null]) => Promise<SaveReadingSessionResult>>(),
saveSession:
vi.fn<(...args: [number, number, string, Date, Date, number, number | null, number | null, string]) => Promise<SaveReadingSessionResult>>(),
};
const mockBookService = {
@@ -75,6 +76,7 @@ describe('ReadingSessionService', () => {
120,
2.5,
10,
'web',
);
});
@@ -101,6 +103,35 @@ describe('ReadingSessionService', () => {
30,
null,
null,
'web',
);
});
it('threads an explicit source through to the repository', async () => {
await service.save(
42,
{
sessionId: 'session-kobo',
startedAt: '2026-04-15T10:00:00.000Z',
endedAt: '2026-04-15T10:02:00.000Z',
durationSeconds: 120,
progressDelta: 5,
endProgress: 20,
},
makeUser({ id: 7 }),
'kobo',
);
expect(mockRepo.saveSession).toHaveBeenCalledWith(
7,
42,
'session-kobo',
new Date('2026-04-15T10:00:00.000Z'),
new Date('2026-04-15T10:02:00.000Z'),
120,
5,
20,
'kobo',
);
});
@@ -5,6 +5,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { BookService } from '../book/book.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_READING_SESSION_SAVED } from '../achievement/achievement-events.service';
import type { ReadingSessionSource } from '../../db/schema/reader';
import type { ListBookReadingSessionsDto } from './dto/list-book-reading-sessions.dto';
import type { SaveReadingSessionDto } from './dto/save-reading-session.dto';
import { ReadingSessionRepository } from './reading-session.repository';
@@ -19,7 +20,7 @@ export class ReadingSessionService {
private readonly achievementEvents: AchievementEventsService,
) {}
async save(fileId: number, dto: SaveReadingSessionDto, user: RequestUser): Promise<void> {
async save(fileId: number, dto: SaveReadingSessionDto, user: RequestUser, source: ReadingSessionSource = 'web'): Promise<void> {
const event = 'reading_session.save';
const startedAtMs = Date.now();
this.logger.log(
@@ -53,6 +54,7 @@ export class ReadingSessionService {
durationSeconds,
dto.progressDelta ?? null,
dto.endProgress ?? null,
source,
);
this.logger.log(