mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(kobo): sync native reading sessions from analytics events (#245)
* feat(kobo): integrate analytics service into kobodevicecontroller - added analytics service to the kobodevicecontroller to handle analytics events. - updated the analyticsevent method to process incoming events and handle errors gracefully. - enhanced unit tests to cover new analytics functionality, including successful ingestion and error handling scenarios. - updated module imports to include the new analytics service and resolver. * test(architecture): add kobo analytics resolver service to architecture boundaries tests * refactor(kobo): enhance analytics handling and improve tests - Updated KoboDeviceController to include analyticsService in constructor and added test for handling non-Error rejections. - Enhanced KoboAnalyticsResolverService tests to cover scenarios where book rows do not exist and when primary file IDs are set without corresponding EPUB files. - Modified KoboAnalyticsService to integrate bookIdentityService for resolving volume IDs and added tests for various edge cases in event ingestion, including handling invalid timestamps and logging warnings for non-Error exceptions. These changes improve the robustness of analytics processing and ensure comprehensive test coverage for new functionalities. * fix(kobo): enhance leavecontent event validation and update tests - Improved validation logic in KoboAnalyticsService for LeaveContent events to ensure volume ID is a non-empty string. --------- Signed-off-by: Logan Selley <37816781+Logan-Selley@users.noreply.github.com> Co-authored-by: Logan-Selley <logan@selleytech.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,7 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/catalog/catalog.service.ts',
|
||||
'src/modules/cover/cover.service.ts',
|
||||
'src/modules/entity-manager/duplicate-compute.service.ts',
|
||||
'src/modules/kobo/services/kobo-analytics-resolver.service.ts',
|
||||
'src/modules/kobo/services/kobo-book-access.service.ts',
|
||||
'src/modules/kobo/services/kobo-book-identity.service.ts',
|
||||
'src/modules/kobo/services/kobo-device.service.ts',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface KoboAnalyticsEvent {
|
||||
Id: string;
|
||||
EventType: string;
|
||||
Timestamp: string;
|
||||
Metrics?: {
|
||||
SecondsRead?: number;
|
||||
IdleTime?: number;
|
||||
PagesTurned?: number;
|
||||
stars?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
Attributes?: {
|
||||
volumeid?: string;
|
||||
progress?: string;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KoboAnalyticsBody {
|
||||
Events?: KoboAnalyticsEvent[];
|
||||
PlatformId?: string;
|
||||
SerialNumber?: string;
|
||||
AffiliateName?: string;
|
||||
ApplicationVersion?: string;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ describe('KoboDeviceController', () => {
|
||||
const proxyService = {
|
||||
forward: vi.fn(),
|
||||
};
|
||||
const analyticsService = {
|
||||
ingest: vi.fn(),
|
||||
};
|
||||
const bookIdentityService = {
|
||||
resolveBookIdByEntitlementId: vi.fn(),
|
||||
resolveBookIdByCoverImageId: vi.fn(),
|
||||
@@ -28,6 +31,7 @@ describe('KoboDeviceController', () => {
|
||||
thumbnailService as never,
|
||||
downloadService as never,
|
||||
proxyService as never,
|
||||
analyticsService as never,
|
||||
bookIdentityService as never,
|
||||
);
|
||||
|
||||
@@ -123,14 +127,36 @@ describe('KoboDeviceController', () => {
|
||||
expect(thumbnailService.serveThumbnail).toHaveBeenCalledWith(11, 420, undefined, reply);
|
||||
});
|
||||
|
||||
it('returns static payload endpoints and analytics keys', () => {
|
||||
it('returns static payload endpoints and analytics keys', async () => {
|
||||
expect(controller.affiliate()).toEqual({});
|
||||
expect(controller.remainingBookSeries()).toEqual({ TotalResultCount: 0, SearchResults: [] });
|
||||
expect(controller.productNextRead()).toEqual([]);
|
||||
expect(controller.analyticsEvent()).toEqual({});
|
||||
analyticsService.ingest.mockResolvedValue(undefined);
|
||||
await expect(controller.analyticsEvent({ Events: [] }, { id: 3 } as never, { deviceToken: 't' } as never)).resolves.toEqual({});
|
||||
expect(analyticsService.ingest).toHaveBeenCalledWith({ Events: [] }, { id: 3 }, { deviceToken: 't' });
|
||||
expect(controller.getTests()).toEqual({ Result: 'Success', TestKey: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns {} when analytics ingest throws', async () => {
|
||||
analyticsService.ingest.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
controller.analyticsEvent(
|
||||
{ Events: [{ Id: '1', EventType: 'LeaveContent', Timestamp: '2026-06-01T00:00:00Z' }] },
|
||||
{ id: 1 } as never,
|
||||
{
|
||||
deviceToken: 't',
|
||||
} as never,
|
||||
),
|
||||
).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when analytics ingest throws a non-Error value', async () => {
|
||||
analyticsService.ingest.mockRejectedValue('db down');
|
||||
|
||||
await expect(controller.analyticsEvent({ Events: [] }, { id: 1 } as never, { deviceToken: 't' } as never)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('forwards unmatched routes through proxy service', async () => {
|
||||
const req = { method: 'PUT', url: '/api/v1/kobo/token/v1/unknown' };
|
||||
const reply = makeReply();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { All, Controller, Get, Headers, HttpCode, HttpStatus, Logger, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { All, Body, Controller, Get, Headers, HttpCode, HttpStatus, Logger, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@@ -8,9 +8,11 @@ import type { KoboDeviceContext } from './guards/kobo-token.guard';
|
||||
import { KoboTokenGuard } from './guards/kobo-token.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { KoboAnalyticsService } from './services/kobo-analytics.service';
|
||||
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
|
||||
import { KoboDownloadService } from './services/kobo-download.service';
|
||||
import { KoboProxyService } from './services/kobo-proxy.service';
|
||||
import type { KoboAnalyticsBody } from './kobo-analytics.types';
|
||||
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
||||
|
||||
@Controller('kobo/:deviceToken')
|
||||
@@ -23,6 +25,7 @@ export class KoboDeviceController {
|
||||
private readonly thumbnailService: KoboThumbnailService,
|
||||
private readonly downloadService: KoboDownloadService,
|
||||
private readonly proxyService: KoboProxyService,
|
||||
private readonly analyticsService: KoboAnalyticsService,
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
) {}
|
||||
|
||||
@@ -101,7 +104,13 @@ export class KoboDeviceController {
|
||||
|
||||
@Post('v1/analytics/event')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
analyticsEvent() {
|
||||
async analyticsEvent(@Body() body: KoboAnalyticsBody, @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext) {
|
||||
try {
|
||||
await this.analyticsService.ingest(body, user, device);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'unknown error';
|
||||
this.logger.warn(`[kobo.analytics] ingest failed userId=${user.id}: ${message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,17 @@ import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||
import { KoboSettingsService } from './services/kobo-settings.service';
|
||||
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 { ReadingSessionModule } from '../reading-session/reading-session.module';
|
||||
|
||||
describe('KoboModule', () => {
|
||||
it('registers expected controllers and providers', () => {
|
||||
const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, KoboModule);
|
||||
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, KoboModule) as unknown[];
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, KoboModule) as unknown[];
|
||||
|
||||
expect(imports).toContain(ReadingSessionModule);
|
||||
expect(controllers).toEqual([KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController]);
|
||||
expect(providers).toEqual([
|
||||
KoboTokenGuard,
|
||||
@@ -39,6 +44,8 @@ describe('KoboModule', () => {
|
||||
KoboThumbnailService,
|
||||
KoboDownloadService,
|
||||
KoboProxyService,
|
||||
KoboAnalyticsResolverService,
|
||||
KoboAnalyticsService,
|
||||
]);
|
||||
expect(Reflect.getMetadata(MODULE_METADATA.EXPORTS, KoboModule)).toEqual([KepubConversionService, KoboSettingsService]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../common/common.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';
|
||||
import { KoboAuthController } from './kobo-auth.controller';
|
||||
@@ -19,9 +20,11 @@ import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||
import { KoboSettingsService } from './services/kobo-settings.service';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [CommonModule, UserModule, UserBookStatusModule],
|
||||
imports: [CommonModule, UserModule, UserBookStatusModule, ReadingSessionModule],
|
||||
controllers: [KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController],
|
||||
providers: [
|
||||
KoboTokenGuard,
|
||||
@@ -36,6 +39,8 @@ import { KoboThumbnailService } from './services/kobo-thumbnail.service';
|
||||
KoboThumbnailService,
|
||||
KoboDownloadService,
|
||||
KoboProxyService,
|
||||
KoboAnalyticsResolverService,
|
||||
KoboAnalyticsService,
|
||||
],
|
||||
exports: [KepubConversionService, KoboSettingsService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { KoboAnalyticsResolverService } from './kobo-analytics-resolver.service';
|
||||
|
||||
function makeSelectChain(rows: unknown[]) {
|
||||
const limit = vi.fn().mockResolvedValue(rows);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const innerJoin = vi.fn().mockReturnValue({ where });
|
||||
const from = vi.fn().mockReturnValue({ innerJoin, where });
|
||||
return { from, where, limit };
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
query: {
|
||||
books: { findFirst: vi.fn() },
|
||||
bookFiles: { findFirst: vi.fn() },
|
||||
},
|
||||
select: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KoboAnalyticsResolverService', () => {
|
||||
const bookAccessService = { assertBookAccessible: vi.fn() };
|
||||
|
||||
function makeService(db: ReturnType<typeof makeDb>) {
|
||||
return new KoboAnalyticsResolverService(db as never, bookAccessService as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('skips when the user cannot access the book', async () => {
|
||||
bookAccessService.assertBookAccessible.mockRejectedValue(new Error('forbidden'));
|
||||
const db = makeDb();
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'book_not_accessible',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves via snapshot file hash when exactly one epub matches', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 50 });
|
||||
const snapChain = makeSelectChain([{ fileHash: 'abc123' }]);
|
||||
const hashChain = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 42 }]),
|
||||
}),
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the current primary when multiple files share the snapshot hash', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 51 });
|
||||
const snapChain = makeSelectChain([{ fileHash: 'abc123' }]);
|
||||
const hashChain = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 50 }, { id: 51 }]),
|
||||
}),
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 51,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the lowest file id when multiple hash matches and primary is not among them', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 99 });
|
||||
const snapChain = makeSelectChain([{ fileHash: 'abc123' }]);
|
||||
const hashChain = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 55 }, { id: 44 }]),
|
||||
}),
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 44,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the primary epub when snapshot hash is missing', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 50 });
|
||||
const snapChain = makeSelectChain([]);
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue({ id: 50 });
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to primary when hash matches no files', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 50 });
|
||||
const snapChain = makeSelectChain([{ fileHash: 'stale' }]);
|
||||
const hashChain = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
};
|
||||
db.select.mockReturnValueOnce(snapChain).mockReturnValueOnce(hashChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue({ id: 50 });
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'resolved',
|
||||
bookFileId: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when there is no epub primary file', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: null });
|
||||
const snapChain = makeSelectChain([]);
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'no_epub_file',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when the book row does not exist', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'book_not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when primary file id is set but no epub file exists', async () => {
|
||||
const db = makeDb();
|
||||
db.query.books.findFirst.mockResolvedValue({ id: 9, primaryFileId: 50 });
|
||||
const snapChain = makeSelectChain([]);
|
||||
db.select.mockReturnValueOnce(snapChain);
|
||||
db.query.bookFiles.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService(db).resolveBookFileId(1, 9)).resolves.toEqual({
|
||||
kind: 'skipped',
|
||||
reason: 'no_epub_file',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { KoboBookAccessService } from './kobo-book-access.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export type KoboAnalyticsResolveResult =
|
||||
| { kind: 'resolved'; bookFileId: number }
|
||||
| { kind: 'skipped'; reason: 'book_not_found' | 'book_not_accessible' | 'no_epub_file' };
|
||||
|
||||
@Injectable()
|
||||
export class KoboAnalyticsResolverService {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly bookAccessService: KoboBookAccessService,
|
||||
) {}
|
||||
|
||||
async resolveBookFileId(userId: number, bookId: number): Promise<KoboAnalyticsResolveResult> {
|
||||
try {
|
||||
await this.bookAccessService.assertBookAccessible(userId, bookId);
|
||||
} catch {
|
||||
return { kind: 'skipped', reason: 'book_not_accessible' };
|
||||
}
|
||||
|
||||
const book = await this.db.query.books.findFirst({
|
||||
where: eq(schema.books.id, bookId),
|
||||
columns: { id: true, primaryFileId: true },
|
||||
});
|
||||
if (!book) return { kind: 'skipped', reason: 'book_not_found' };
|
||||
|
||||
const snap = await this.db
|
||||
.select({ fileHash: schema.koboSnapshotBooks.fileHash })
|
||||
.from(schema.koboSnapshotBooks)
|
||||
.innerJoin(schema.koboLibrarySnapshots, eq(schema.koboLibrarySnapshots.id, schema.koboSnapshotBooks.snapshotId))
|
||||
.where(and(eq(schema.koboLibrarySnapshots.userId, userId), eq(schema.koboSnapshotBooks.bookId, bookId)))
|
||||
.orderBy(desc(schema.koboLibrarySnapshots.id))
|
||||
.limit(1);
|
||||
|
||||
const snapHash = snap[0]?.fileHash ?? null;
|
||||
if (snapHash) {
|
||||
const byHash = await this.db
|
||||
.select({ id: schema.bookFiles.id })
|
||||
.from(schema.bookFiles)
|
||||
.where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.fileHash, snapHash), eq(schema.bookFiles.format, 'epub')));
|
||||
|
||||
if (byHash.length === 1) {
|
||||
return { kind: 'resolved', bookFileId: byHash[0].id };
|
||||
}
|
||||
if (byHash.length > 1) {
|
||||
const primary = byHash.find((f) => f.id === book.primaryFileId);
|
||||
return { kind: 'resolved', bookFileId: primary?.id ?? Math.min(...byHash.map((f) => f.id)) };
|
||||
}
|
||||
}
|
||||
|
||||
if (book.primaryFileId == null) return { kind: 'skipped', reason: 'no_epub_file' };
|
||||
|
||||
const primary = await this.db.query.bookFiles.findFirst({
|
||||
where: and(eq(schema.bookFiles.id, book.primaryFileId), eq(schema.bookFiles.format, 'epub')),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!primary) return { kind: 'skipped', reason: 'no_epub_file' };
|
||||
|
||||
return { kind: 'resolved', bookFileId: primary.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { KoboAnalyticsEvent } from '../kobo-analytics.types';
|
||||
import { KoboAnalyticsService } from './kobo-analytics.service';
|
||||
|
||||
describe('KoboAnalyticsService', () => {
|
||||
const bookIdentityService = { resolveBookIdByEntitlementId: vi.fn() };
|
||||
const resolver = { resolveBookFileId: vi.fn() };
|
||||
const readingSessionService = { save: 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);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockImplementation((_userId: number, id: string) =>
|
||||
/^\d+$/.test(id) ? Promise.resolve(Number(id)) : Promise.resolve(null),
|
||||
);
|
||||
resolver.resolveBookFileId.mockResolvedValue({ kind: 'resolved', bookFileId: 100 });
|
||||
readingSessionService.save.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('maps LeaveContent to reading sessions (duration minimum enforced in ReadingSessionService.save)', async () => {
|
||||
const events: KoboAnalyticsEvent[] = [
|
||||
{
|
||||
Id: '9aa9fefd-21e7-4974-8123-6a7fa58bd771',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:37:36Z',
|
||||
Metrics: { SecondsRead: 10, IdleTime: 4 },
|
||||
Attributes: { volumeid: '1', progress: '4' },
|
||||
},
|
||||
{
|
||||
Id: 'ca3fcb0b-3e16-440a-985e-b754cdf904d8',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:38:13Z',
|
||||
Metrics: { SecondsRead: 34, IdleTime: 6 },
|
||||
Attributes: { volumeid: '1', progress: '6' },
|
||||
},
|
||||
{
|
||||
Id: '21c140de-5341-4d6f-af0d-412b769d0135',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:38:58Z',
|
||||
Metrics: { SecondsRead: 4, IdleTime: 4 },
|
||||
Attributes: { volumeid: '1', progress: '6' },
|
||||
},
|
||||
];
|
||||
|
||||
await makeService().ingest({ Events: events }, user, device);
|
||||
|
||||
expect(resolver.resolveBookFileId).toHaveBeenCalledTimes(3);
|
||||
expect(readingSessionService.save).toHaveBeenCalledTimes(3);
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
100,
|
||||
{
|
||||
sessionId: '9aa9fefd-21e7-4974-8123-6a7fa58bd771',
|
||||
startedAt: '2026-06-01T00:37:26.000Z',
|
||||
endedAt: '2026-06-01T00:37:36.000Z',
|
||||
durationSeconds: 10,
|
||||
progressDelta: null,
|
||||
endProgress: 4,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
100,
|
||||
expect.objectContaining({
|
||||
sessionId: 'ca3fcb0b-3e16-440a-985e-b754cdf904d8',
|
||||
durationSeconds: 34,
|
||||
endProgress: 6,
|
||||
}),
|
||||
user,
|
||||
);
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
100,
|
||||
expect.objectContaining({
|
||||
sessionId: '21c140de-5341-4d6f-af0d-412b769d0135',
|
||||
durationSeconds: 4,
|
||||
}),
|
||||
user,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats empty or invalid progress as null', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'empty-progress',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:20Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '1', progress: '' },
|
||||
},
|
||||
{
|
||||
Id: 'bad-progress',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:30Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '1', progress: 'garbage' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(1, 100, expect.objectContaining({ endProgress: null }), user);
|
||||
expect(readingSessionService.save).toHaveBeenNthCalledWith(2, 100, expect.objectContaining({ endProgress: null }), user);
|
||||
});
|
||||
|
||||
it('still processes later events when an earlier save throws', async () => {
|
||||
readingSessionService.save.mockRejectedValueOnce(new Error('access denied')).mockResolvedValueOnce(undefined);
|
||||
|
||||
const events: KoboAnalyticsEvent[] = [
|
||||
{
|
||||
Id: 'first',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:10Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '1', progress: '5' },
|
||||
},
|
||||
{
|
||||
Id: 'second',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:20Z',
|
||||
Metrics: { SecondsRead: 15 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
];
|
||||
|
||||
await makeService().ingest({ Events: events }, user, device);
|
||||
|
||||
expect(readingSessionService.save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores volumeid values that do not resolve to a book', async () => {
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(null);
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'partial',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:10Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '12abc' },
|
||||
},
|
||||
{
|
||||
Id: 'decimal',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:20Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '1.5' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(resolver.resolveBookFileId).not.toHaveBeenCalled();
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves UUID entitlement volumeid values via book identity', async () => {
|
||||
const entitlementId = '11111111-1111-4111-8111-111111111111';
|
||||
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(9);
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'uuid-volume',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:10Z',
|
||||
Metrics: { SecondsRead: 20 },
|
||||
Attributes: { volumeid: entitlementId, progress: '8' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('skips LeaveContent without volumeid or SecondsRead', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{ Id: 'a', EventType: 'LeaveContent', Timestamp: '2026-06-01T00:00:00Z', Metrics: {} },
|
||||
{ Id: 'b', EventType: 'LeaveContent', Timestamp: '2026-06-01T00:00:00Z', Attributes: { volumeid: '1' } },
|
||||
{
|
||||
Id: 'c',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:00Z',
|
||||
Metrics: { SecondsRead: 10 },
|
||||
Attributes: { volumeid: 1 as never },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
expect(bookIdentityService.resolveBookIdByEntitlementId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-session analytics event types', async () => {
|
||||
await makeService().ingest(
|
||||
{ Events: [{ Id: 'x', EventType: 'OpenContent', Timestamp: '2026-06-01T00:00:00Z', Attributes: { volumeid: '1' } }] },
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(resolver.resolveBookFileId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips ingest when Events is not an array', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await makeService().ingest({ Events: 'not-an-array' } as never, user, device);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid Events envelope'));
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call save when resolver skips', async () => {
|
||||
resolver.resolveBookFileId.mockResolvedValue({ kind: 'skipped', reason: 'no_epub_file' });
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 's',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:00Z',
|
||||
Metrics: { SecondsRead: 20 },
|
||||
Attributes: { volumeid: '3' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores LeaveContent with an invalid timestamp', async () => {
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'bad-ts',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: 'not-a-date',
|
||||
Metrics: { SecondsRead: 20 },
|
||||
Attributes: { volumeid: '1', progress: '5' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats null or missing Events as an empty batch', async () => {
|
||||
await makeService().ingest(null, user, device);
|
||||
await makeService().ingest({}, user, device);
|
||||
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a warning when a non-Error is thrown during save', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
readingSessionService.save.mockRejectedValueOnce('boom');
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [
|
||||
{
|
||||
Id: 'err',
|
||||
EventType: 'LeaveContent',
|
||||
Timestamp: '2026-06-01T00:00:10Z',
|
||||
Metrics: { SecondsRead: 12 },
|
||||
Attributes: { volumeid: '1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown error'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('summarizes events with missing event types', async () => {
|
||||
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
|
||||
await makeService().ingest(
|
||||
{
|
||||
Events: [{ Id: 'x', EventType: undefined as never, Timestamp: '2026-06-01T00:00:00Z' }],
|
||||
},
|
||||
user,
|
||||
device,
|
||||
);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('types=(missing):1'));
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back when analytics payload cannot be stringified', async () => {
|
||||
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
await makeService().ingest(circular as never, user, device);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
expect(readingSessionService.save).not.toHaveBeenCalled();
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { ReadingSessionService } from '../../reading-session/reading-session.service';
|
||||
import type { KoboDeviceContext } from '../guards/kobo-token.guard';
|
||||
import type { KoboAnalyticsBody, KoboAnalyticsEvent } from '../kobo-analytics.types';
|
||||
import { KoboBookIdentityService } from './kobo-book-identity.service';
|
||||
import { KoboAnalyticsResolverService } from './kobo-analytics-resolver.service';
|
||||
|
||||
const DEBUG_PAYLOAD_MAX_LENGTH = 4000;
|
||||
|
||||
function parseKoboEndProgress(raw: string | undefined): number | null {
|
||||
if (raw == null) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return null;
|
||||
const value = Number.parseFloat(trimmed);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatAnalyticsPayload(value: unknown, maxLength = DEBUG_PAYLOAD_MAX_LENGTH): string {
|
||||
try {
|
||||
return sanitizeLogValue(JSON.stringify(value), maxLength);
|
||||
} catch {
|
||||
return sanitizeLogValue(String(value), maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeEventTypes(events: KoboAnalyticsEvent[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const ev of events) {
|
||||
const type = typeof ev.EventType === 'string' ? ev.EventType : '(missing)';
|
||||
counts.set(type, (counts.get(type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].map(([type, count]) => `${type}:${count}`).join(', ');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KoboAnalyticsService {
|
||||
private readonly logger = new Logger(KoboAnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
private readonly resolver: KoboAnalyticsResolverService,
|
||||
private readonly readingSessionService: ReadingSessionService,
|
||||
) {}
|
||||
|
||||
async ingest(body: KoboAnalyticsBody | null | undefined, user: RequestUser, device: KoboDeviceContext): Promise<void> {
|
||||
const events = this.normalizeEvents(body, user.id);
|
||||
this.logBatch(body, events, user.id, device.deviceId);
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.EventType !== 'LeaveContent') continue;
|
||||
try {
|
||||
await this.handleLeaveContent(ev, user);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'unknown error';
|
||||
this.logger.warn(
|
||||
`[kobo.analytics.session] [fail] userId=${user.id} eventId=${ev.Id} error="${message}" event="${formatAnalyticsPayload(ev, 800)}" - leave content ingest failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeEvents(body: KoboAnalyticsBody | null | undefined, userId: number): KoboAnalyticsEvent[] {
|
||||
if (body?.Events == null) return [];
|
||||
if (!Array.isArray(body.Events)) {
|
||||
this.logger.warn(`[kobo.analytics] invalid Events envelope userId=${userId} payload="${formatAnalyticsPayload(body)}" - expected an array`);
|
||||
return [];
|
||||
}
|
||||
return body.Events;
|
||||
}
|
||||
|
||||
private logBatch(body: KoboAnalyticsBody | null | undefined, events: KoboAnalyticsEvent[], userId: number, deviceId: number): void {
|
||||
const typeSummary = events.length > 0 ? summarizeEventTypes(events) : 'none';
|
||||
this.logger.debug(
|
||||
`[kobo.analytics] batch userId=${userId} deviceId=${deviceId} eventCount=${events.length} types=${typeSummary} payload="${formatAnalyticsPayload(body)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleLeaveContent(ev: KoboAnalyticsEvent, user: RequestUser): Promise<void> {
|
||||
const volumeid = ev.Attributes?.volumeid;
|
||||
const seconds = ev.Metrics?.SecondsRead;
|
||||
if (typeof volumeid !== 'string' || volumeid.length === 0 || typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) {
|
||||
this.logger.debug(
|
||||
`[kobo.analytics.session] [ignore] malformed LeaveContent userId=${user.id} eventId=${ev.Id} event="${formatAnalyticsPayload(ev, 800)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedVolumeId = volumeid.trim();
|
||||
const bookId = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, trimmedVolumeId);
|
||||
if (bookId === null) {
|
||||
this.logger.debug(
|
||||
`[kobo.analytics.session] [ignore] invalid volumeid userId=${user.id} eventId=${ev.Id} volumeid="${sanitizeLogValue(volumeid)}" event="${formatAnalyticsPayload(ev, 800)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSeconds = Math.floor(seconds);
|
||||
|
||||
const resolved = await this.resolver.resolveBookFileId(user.id, bookId);
|
||||
if (resolved.kind !== 'resolved') {
|
||||
this.logger.log(`[kobo.analytics.session] [skip] bookId=${bookId} userId=${user.id} reason=${resolved.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date(ev.Timestamp);
|
||||
if (Number.isNaN(endedAt.getTime())) {
|
||||
this.logger.debug(
|
||||
`[kobo.analytics.session] [ignore] invalid timestamp userId=${user.id} eventId=${ev.Id} event="${formatAnalyticsPayload(ev, 800)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(endedAt.getTime() - durationSeconds * 1000);
|
||||
|
||||
await this.readingSessionService.save(
|
||||
resolved.bookFileId,
|
||||
{
|
||||
sessionId: ev.Id,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds,
|
||||
progressDelta: null,
|
||||
endProgress: parseKoboEndProgress(ev.Attributes?.progress),
|
||||
},
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@ describe('ReadingSessionModule', () => {
|
||||
expect(Reflect.getMetadata(MODULE_METADATA.IMPORTS, ReadingSessionModule)).toEqual([BookModule, AchievementModule]);
|
||||
expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, ReadingSessionModule)).toEqual([ReadingSessionController, BookReadingSessionController]);
|
||||
expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ReadingSessionModule)).toEqual([ReadingSessionService, ReadingSessionRepository]);
|
||||
expect(Reflect.getMetadata(MODULE_METADATA.EXPORTS, ReadingSessionModule)).toEqual([ReadingSessionService]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,5 +11,6 @@ import { ReadingSessionService } from './reading-session.service';
|
||||
imports: [BookModule, AchievementModule],
|
||||
controllers: [ReadingSessionController, BookReadingSessionController],
|
||||
providers: [ReadingSessionService, ReadingSessionRepository],
|
||||
exports: [ReadingSessionService],
|
||||
})
|
||||
export class ReadingSessionModule {}
|
||||
|
||||
Reference in New Issue
Block a user