mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(kobo): sync star ratings from ratebook analytics events (#269)
* feat(kobo): handle ratebook events in koboanalyticsservice This commit adds the BookModule to the KoboModule imports, allowing for better integration with book-related functionalities. Additionally, the KoboAnalyticsService has been updated to handle 'RateBook' events, enabling the processing of user ratings through the new bookService. Tests have been added to ensure the correct handling of these events and their interactions with the reading session service. * test(kobo): fill ratebook testing gaps --------- Co-authored-by: Logan-Selley <logan@selleytech.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { KoboSyncService } from './services/kobo-sync.service';
|
||||
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
|
||||
import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver.service';
|
||||
import { KoboAnalyticsService } from './services/kobo-analytics.service';
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { ReadingSessionModule } from '../reading-session/reading-session.module';
|
||||
|
||||
describe('KoboModule', () => {
|
||||
@@ -29,6 +30,7 @@ describe('KoboModule', () => {
|
||||
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, KoboModule) as unknown[];
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, KoboModule) as unknown[];
|
||||
|
||||
expect(imports).toContain(BookModule);
|
||||
expect(imports).toContain(ReadingSessionModule);
|
||||
expect(controllers).toEqual([KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController]);
|
||||
expect(providers).toEqual([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { ReadingSessionModule } from '../reading-session/reading-session.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
|
||||
@@ -24,7 +25,7 @@ import { KoboAnalyticsResolverService } from './services/kobo-analytics-resolver
|
||||
import { KoboAnalyticsService } from './services/kobo-analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonModule, UserModule, UserBookStatusModule, ReadingSessionModule],
|
||||
imports: [CommonModule, BookModule, UserModule, UserBookStatusModule, ReadingSessionModule],
|
||||
controllers: [KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController],
|
||||
providers: [
|
||||
KoboTokenGuard,
|
||||
|
||||
@@ -7,11 +7,12 @@ describe('KoboAnalyticsService', () => {
|
||||
const bookIdentityService = { resolveBookIdByEntitlementId: vi.fn() };
|
||||
const resolver = { resolveBookFileId: vi.fn() };
|
||||
const readingSessionService = { save: vi.fn() };
|
||||
const bookService = { bulkSetRating: vi.fn() };
|
||||
const user = { id: 7 } as never;
|
||||
const device = { deviceId: 2, deviceToken: 'tok', userId: 7 } as never;
|
||||
|
||||
function makeService() {
|
||||
return new KoboAnalyticsService(bookIdentityService as never, resolver as never, readingSessionService as never);
|
||||
return new KoboAnalyticsService(bookIdentityService as never, resolver as never, readingSessionService as never, bookService as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -21,6 +22,7 @@ describe('KoboAnalyticsService', () => {
|
||||
);
|
||||
resolver.resolveBookFileId.mockResolvedValue({ kind: 'resolved', bookFileId: 100 });
|
||||
readingSessionService.save.mockResolvedValue(undefined);
|
||||
bookService.bulkSetRating.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('maps LeaveContent to reading sessions (duration minimum enforced in ReadingSessionService.save)', async () => {
|
||||
@@ -390,6 +392,183 @@ describe('KoboAnalyticsService', () => {
|
||||
);
|
||||
|
||||
expect(resolver.resolveBookFileId).not.toHaveBeenCalled();
|
||||
expect(bookService.bulkSetRating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps RateBook to user ratings via bulkSetRating', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: '74db9ad6-152a-4a97-91d1-129308e4b591',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 4 },
|
||||
Attributes: { volumeid: '1', title: 'Promise of Blood', progress: '7' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookIdentityService.resolveBookIdByEntitlementId).toHaveBeenCalledWith(7, '1');
|
||||
expect(bookService.bulkSetRating).toHaveBeenCalledWith([1], 4, user);
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves UUID entitlement volumeid values for RateBook', async () => {
|
||||
const entitlementId = '11111111-1111-4111-8111-111111111111';
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(9);
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'rate-uuid',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 5 },
|
||||
Attributes: { volumeid: entitlementId },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookService.bulkSetRating).toHaveBeenCalledWith([9], 5, user);
|
||||
});
|
||||
|
||||
it('ignores malformed RateBook events', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{ Id: 'no-stars', EventType: 'RateBook', Timestamp: '2026-06-01T00:40:50Z', Attributes: { volumeid: '1' } },
|
||||
{
|
||||
Id: 'no-volume',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 4 },
|
||||
Attributes: {},
|
||||
},
|
||||
{
|
||||
Id: 'too-high',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 6 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
{
|
||||
Id: 'fractional-stars',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 3.5 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookService.bulkSetRating).not.toHaveBeenCalled();
|
||||
expect(bookIdentityService.resolveBookIdByEntitlementId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores RateBook when volumeid does not resolve to a book', async () => {
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(null);
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'bad-volume',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 4 },
|
||||
Attributes: { volumeid: 'unknown-entitlement' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookIdentityService.resolveBookIdByEntitlementId).toHaveBeenCalledWith(7, 'unknown-entitlement');
|
||||
expect(bookService.bulkSetRating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the user rating when Kobo sends stars: 0', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'clear-rating',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 0 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookService.bulkSetRating).toHaveBeenCalledWith([1], null, user);
|
||||
});
|
||||
|
||||
it('processes RateBook and LeaveContent in the same batch', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'rate',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 3 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
{
|
||||
Id: 'leave',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:41:00Z',
|
||||
Metrics: { SecondsRead: 20 },
|
||||
Attributes: { volumeid: '1', progress: '8' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(bookService.bulkSetRating).toHaveBeenCalledWith([1], 3, user);
|
||||
expect(readingSessionService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs a warning when bulkSetRating throws', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
bookService.bulkSetRating.mockRejectedValueOnce(new Error('forbidden'));
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'rate-fail',
|
||||
EventType: 'RateBook',
|
||||
Timestamp: '2026-06-01T00:40:50Z',
|
||||
Metrics: { stars: 4 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[kobo.analytics.rating] [fail]'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('warns and skips ingest when Events is not an array', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { BookService } from '../../book/book.service';
|
||||
import { ReadingSessionService } from '../../reading-session/reading-session.service';
|
||||
import type { KoboDeviceContext } from '../guards/kobo-token.guard';
|
||||
import type { KoboAnalyticsBody, KoboAnalyticsEvent } from '../kobo-analytics.types';
|
||||
@@ -22,6 +23,18 @@ function parseKoboProgress(raw: unknown): number | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
type KoboStarsValue = { kind: 'rating'; value: number } | { kind: 'clear' } | { kind: 'invalid' };
|
||||
|
||||
function parseKoboStars(metrics: KoboAnalyticsEvent['Metrics'] | undefined): KoboStarsValue {
|
||||
if (metrics?.stars === undefined) return { kind: 'invalid' };
|
||||
|
||||
const stars = metrics.stars;
|
||||
if (typeof stars !== 'number' || !Number.isInteger(stars)) return { kind: 'invalid' };
|
||||
if (stars === 0) return { kind: 'clear' };
|
||||
if (stars >= 1 && stars <= 5) return { kind: 'rating', value: stars };
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
|
||||
function parseKoboDurationSeconds(metrics: KoboAnalyticsEvent['Metrics'] | undefined): number | null {
|
||||
const secondsRead = metrics?.SecondsRead;
|
||||
if (typeof secondsRead !== 'number' || !Number.isFinite(secondsRead) || secondsRead < 0) return null;
|
||||
@@ -58,6 +71,7 @@ export class KoboAnalyticsService {
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
private readonly resolver: KoboAnalyticsResolverService,
|
||||
private readonly readingSessionService: ReadingSessionService,
|
||||
private readonly bookService: BookService,
|
||||
) {}
|
||||
|
||||
async ingest(body: KoboAnalyticsBody | null | undefined, user: RequestUser, device: KoboDeviceContext): Promise<void> {
|
||||
@@ -66,13 +80,17 @@ export class KoboAnalyticsService {
|
||||
const leaveContentContexts = this.buildLeaveContentContexts(events);
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.EventType !== 'LeaveContent') continue;
|
||||
try {
|
||||
await this.handleLeaveContent(ev, user, leaveContentContexts.get(ev) ?? { progressDelta: null, startedAtMs: null });
|
||||
if (ev.EventType === 'LeaveContent') {
|
||||
await this.handleLeaveContent(ev, user, leaveContentContexts.get(ev) ?? { progressDelta: null, startedAtMs: null });
|
||||
} else if (ev.EventType === 'RateBook') {
|
||||
await this.handleRateBook(ev, user);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = sanitizeLogValue(err instanceof Error ? err.message : 'unknown error');
|
||||
const kind = ev.EventType === 'RateBook' ? 'rating' : 'session';
|
||||
this.logger.warn(
|
||||
`[kobo.analytics.session] [fail] userId=${user.id} eventId=${ev.Id} error="${message}" event="${formatAnalyticsPayload(ev, 800)}" - leave content ingest failed`,
|
||||
`[kobo.analytics.${kind}] [fail] userId=${user.id} eventId=${ev.Id} error="${message}" event="${formatAnalyticsPayload(ev, 800)}" - analytics ingest failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +112,28 @@ export class KoboAnalyticsService {
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRateBook(ev: KoboAnalyticsEvent, user: RequestUser): Promise<void> {
|
||||
const volumeid = this.extractVolumeId(ev);
|
||||
const stars = parseKoboStars(ev.Metrics);
|
||||
if (volumeid === null || stars.kind === 'invalid') {
|
||||
this.logger.debug(
|
||||
`[kobo.analytics.rating] [ignore] malformed RateBook userId=${user.id} eventId=${ev.Id} event="${formatAnalyticsPayload(ev, 800)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bookId = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, volumeid);
|
||||
if (bookId === null) {
|
||||
this.logger.debug(
|
||||
`[kobo.analytics.rating] [ignore] invalid volumeid userId=${user.id} eventId=${ev.Id} volumeid="${sanitizeLogValue(volumeid)}" event="${formatAnalyticsPayload(ev, 800)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rating = stars.kind === 'clear' ? null : stars.value;
|
||||
await this.bookService.bulkSetRating([bookId], rating, user);
|
||||
}
|
||||
|
||||
private async handleLeaveContent(ev: KoboAnalyticsEvent, user: RequestUser, context: LeaveContentContext): Promise<void> {
|
||||
const volumeid = this.extractVolumeId(ev);
|
||||
const durationSeconds = parseKoboDurationSeconds(ev.Metrics);
|
||||
|
||||
Reference in New Issue
Block a user