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:
Logan Selley
2026-06-06 11:12:41 -07:00
committed by GitHub
parent 642a8a5075
commit 81e0531ece
12 changed files with 779 additions and 5 deletions
@@ -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]);
});
+6 -1
View File
@@ -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 {}