mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user