mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(email): harden security and reliability with stronger coverage
This commit is contained in:
@@ -47,8 +47,6 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts',
|
||||
'src/modules/catalog/catalog.service.ts',
|
||||
'src/modules/cover/cover.service.ts',
|
||||
'src/modules/email/email-template-context.service.ts',
|
||||
'src/modules/embedding/book-embedder.service.ts',
|
||||
'src/modules/file-write/file-write-settings.service.ts',
|
||||
'src/modules/kobo/services/kobo-device.service.ts',
|
||||
'src/modules/kobo/services/kobo-download.service.ts',
|
||||
|
||||
@@ -109,6 +109,7 @@ describe('Email DTO validation', () => {
|
||||
expect(valid.size).toBe(50);
|
||||
|
||||
expect((await errorsFor(QuerySendLogDto, { page: -1, size: 0 })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(QuerySendLogDto, { size: 101 })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('requires at least one book and integer collections for send requests', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsPositive, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsPositive, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QuerySendLogDto {
|
||||
@@ -12,5 +12,6 @@ export class QuerySendLogDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Max(100)
|
||||
size?: number = 20;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
|
||||
describe('EmailBookAccessService', () => {
|
||||
const user: RequestUser = {
|
||||
id: 9,
|
||||
username: 'reader9',
|
||||
name: 'Reader Nine',
|
||||
email: 'reader9@example.com',
|
||||
active: true,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'manual',
|
||||
isSuperuser: false,
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
let service: EmailBookAccessService;
|
||||
let bookReadService: { findLibraryIdsByBookIds: ReturnType<typeof vi.fn> };
|
||||
let libraryService: { verifyUserAccess: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
bookReadService = {
|
||||
findLibraryIdsByBookIds: vi.fn(),
|
||||
};
|
||||
libraryService = {
|
||||
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new EmailBookAccessService(bookReadService as unknown as BookReadService, libraryService as unknown as LibraryService);
|
||||
});
|
||||
|
||||
it('verifies access for each unique book id', async () => {
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([
|
||||
{ id: 1, libraryId: 10 },
|
||||
{ id: 2, libraryId: 11 },
|
||||
]);
|
||||
|
||||
await service.assertUserCanAccessBooks([1, 2, 1], user);
|
||||
|
||||
expect(bookReadService.findLibraryIdsByBookIds).toHaveBeenCalledWith([1, 2]);
|
||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(2);
|
||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 10, user.isSuperuser);
|
||||
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 11, user.isSuperuser);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when any requested book is missing', async () => {
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 10 }]);
|
||||
|
||||
await expect(service.assertUserCanAccessBooks([1, 2], user)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('propagates ForbiddenException from library access checks', async () => {
|
||||
bookReadService.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 10 }]);
|
||||
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access to this library'));
|
||||
|
||||
await expect(service.assertUserCanAccessBook(1, user)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
|
||||
@Injectable()
|
||||
export class EmailBookAccessService {
|
||||
constructor(
|
||||
private readonly bookReadService: BookReadService,
|
||||
private readonly libraryService: LibraryService,
|
||||
) {}
|
||||
|
||||
async assertUserCanAccessBook(bookId: number, user: RequestUser): Promise<void> {
|
||||
await this.assertUserCanAccessBooks([bookId], user);
|
||||
}
|
||||
|
||||
async assertUserCanAccessBooks(bookIds: number[], user: RequestUser): Promise<void> {
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
if (uniqueBookIds.length === 0) return;
|
||||
|
||||
const rows = await this.bookReadService.findLibraryIdsByBookIds(uniqueBookIds);
|
||||
const libraryByBookId = new Map(rows.map((row) => [row.id, row.libraryId]));
|
||||
const missingBookId = uniqueBookIds.find((bookId) => !libraryByBookId.has(bookId));
|
||||
if (missingBookId !== undefined) {
|
||||
throw new NotFoundException(`Book ${missingBookId} not found`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
uniqueBookIds.map(async (bookId) => {
|
||||
const libraryId = libraryByBookId.get(bookId);
|
||||
if (libraryId === undefined) throw new NotFoundException(`Book ${bookId} not found`);
|
||||
await this.libraryService.verifyUserAccess(user.id, libraryId, user.isSuperuser);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, tags } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class EmailBookReadRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findBookById(bookId: number): Promise<typeof books.$inferSelect | null> {
|
||||
const [book] = await this.db.select().from(books).where(eq(books.id, bookId)).limit(1);
|
||||
return book ?? null;
|
||||
}
|
||||
|
||||
async findBookPrimaryFileId(bookId: number): Promise<number | null> {
|
||||
const [book] = await this.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, bookId)).limit(1);
|
||||
return book?.primaryFileId ?? null;
|
||||
}
|
||||
|
||||
async findFileForBook(bookId: number, fileId: number): Promise<typeof bookFiles.$inferSelect | null> {
|
||||
const [file] = await this.db
|
||||
.select()
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)))
|
||||
.limit(1);
|
||||
return file ?? null;
|
||||
}
|
||||
|
||||
findFilesByBookId(bookId: number): Promise<(typeof bookFiles.$inferSelect)[]> {
|
||||
return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
|
||||
}
|
||||
|
||||
async findMetadataByBookId(bookId: number): Promise<typeof bookMetadata.$inferSelect | null> {
|
||||
const [metadata] = await this.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1);
|
||||
return metadata ?? null;
|
||||
}
|
||||
|
||||
findAuthorNamesByBookId(bookId: number): Promise<{ name: string | null }[]> {
|
||||
return this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId))
|
||||
.orderBy(bookAuthors.displayOrder);
|
||||
}
|
||||
|
||||
findTagNamesByBookId(bookId: number): Promise<{ name: string }[]> {
|
||||
return this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId));
|
||||
}
|
||||
|
||||
async findFileById(fileId: number): Promise<typeof bookFiles.$inferSelect | null> {
|
||||
const [file] = await this.db.select().from(bookFiles).where(eq(bookFiles.id, fileId)).limit(1);
|
||||
return file ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,64 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { DB } from '../../db';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
|
||||
describe('EmailFileSelector', () => {
|
||||
let selector: EmailFileSelector;
|
||||
let db: any;
|
||||
let repo: {
|
||||
findFileForBook: ReturnType<typeof vi.fn>;
|
||||
findFilesByBookId: ReturnType<typeof vi.fn>;
|
||||
findBookPrimaryFileId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const mockFile = { id: 100, bookId: 1, format: 'EPUB', role: 'primary' };
|
||||
const mockFile2 = { id: 101, bookId: 1, format: 'PDF', role: 'secondary' };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(),
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
findFileForBook: vi.fn(),
|
||||
findFilesByBookId: vi.fn(),
|
||||
findBookPrimaryFileId: vi.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [EmailFileSelector, { provide: DB, useValue: db }],
|
||||
}).compile();
|
||||
|
||||
selector = module.get<EmailFileSelector>(EmailFileSelector);
|
||||
selector = new EmailFileSelector(repo as unknown as EmailBookReadRepository);
|
||||
});
|
||||
|
||||
it('should select by fileId if provided', async () => {
|
||||
db.where.mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([mockFile]),
|
||||
});
|
||||
|
||||
repo.findFileForBook.mockResolvedValue(mockFile);
|
||||
const result = await selector.select(1, 100, null);
|
||||
expect(result).toEqual(mockFile);
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
expect(repo.findFileForBook).toHaveBeenCalledWith(1, 100);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if specific fileId not found', async () => {
|
||||
db.where.mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
repo.findFileForBook.mockResolvedValue(null);
|
||||
await expect(selector.select(1, 999, null)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should select preferred format if available', async () => {
|
||||
db.where.mockResolvedValueOnce([mockFile, mockFile2]);
|
||||
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
const result = await selector.select(1, null, 'pdf');
|
||||
expect(result).toEqual(mockFile2);
|
||||
});
|
||||
|
||||
it('should fallback to primary if preferred format not found', async () => {
|
||||
db.where.mockResolvedValueOnce([mockFile, mockFile2]).mockReturnValueOnce({ limit: vi.fn().mockResolvedValue([{ primaryFileId: 100 }]) });
|
||||
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(100);
|
||||
const result = await selector.select(1, null, 'mobi');
|
||||
expect(result).toEqual(mockFile);
|
||||
});
|
||||
|
||||
it('should throw if no files found for book', async () => {
|
||||
db.where.mockResolvedValue([]);
|
||||
it('should fallback to first file when primary file is missing', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(404);
|
||||
|
||||
const result = await selector.select(1, null, 'mobi');
|
||||
|
||||
expect(result).toEqual(mockFile);
|
||||
});
|
||||
|
||||
it('should throw if no files found for book', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([]);
|
||||
await expect(selector.select(1, null, null)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookFiles, books } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
|
||||
@Injectable()
|
||||
export class EmailFileSelector {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
constructor(private readonly bookReadRepository: EmailBookReadRepository) {}
|
||||
|
||||
async select(
|
||||
bookId: number,
|
||||
fileId: number | null | undefined,
|
||||
preferredFormat: string | null | undefined,
|
||||
): Promise<typeof bookFiles.$inferSelect> {
|
||||
if (fileId) {
|
||||
const [file] = await this.db
|
||||
.select()
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)))
|
||||
.limit(1);
|
||||
async select(bookId: number, fileId: number | null | undefined, preferredFormat: string | null | undefined): Promise<BookFile> {
|
||||
if (fileId !== null && fileId !== undefined) {
|
||||
const file = await this.bookReadRepository.findFileForBook(bookId, fileId);
|
||||
if (!file) throw new NotFoundException('Book file not found');
|
||||
return file;
|
||||
}
|
||||
|
||||
const allFiles = await this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
|
||||
const allFiles = await this.bookReadRepository.findFilesByBookId(bookId);
|
||||
if (allFiles.length === 0) throw new NotFoundException('No files found for this book');
|
||||
|
||||
if (preferredFormat) {
|
||||
@@ -35,8 +22,8 @@ export class EmailFileSelector {
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
const [book] = await this.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, bookId)).limit(1);
|
||||
const primary = allFiles.find((f) => f.id === book?.primaryFileId);
|
||||
const primaryFileId = await this.bookReadRepository.findBookPrimaryFileId(bookId);
|
||||
const primary = allFiles.find((f) => f.id === primaryFileId);
|
||||
return primary ?? allFiles[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ResolvedSmtp {
|
||||
@Injectable()
|
||||
export class EmailProviderResolver {
|
||||
private readonly logger = new Logger(EmailProviderResolver.name);
|
||||
private readonly event = 'email.provider.resolve';
|
||||
|
||||
constructor(
|
||||
private readonly providerService: EmailProviderService,
|
||||
@@ -23,14 +24,14 @@ export class EmailProviderResolver {
|
||||
async resolve(user: RequestUser, requestedProviderId?: number | null): Promise<ResolvedSmtp> {
|
||||
if (requestedProviderId !== null && requestedProviderId !== undefined) {
|
||||
const provider = await this.providerService.getProviderWithDecryptedPassword(requestedProviderId, user);
|
||||
this.logger.debug(`[email.provider] [resolved] id=${provider.id} host=${provider.host} (requested)`);
|
||||
this.logger.debug(`[${this.event}] [end] userId=${user.id} providerId=${provider.id} source=requested - provider resolved`);
|
||||
return this.fromProvider(provider);
|
||||
}
|
||||
|
||||
const prefs = await this.preferencesService.getForUser(user.id);
|
||||
if (prefs?.defaultProviderId !== null && prefs?.defaultProviderId !== undefined) {
|
||||
const provider = await this.providerService.getProviderWithDecryptedPassword(prefs.defaultProviderId, user);
|
||||
this.logger.debug(`[email.provider] [resolved] id=${provider.id} host=${provider.host} (default)`);
|
||||
this.logger.debug(`[${this.event}] [end] userId=${user.id} providerId=${provider.id} source=default - provider resolved`);
|
||||
return this.fromProvider(provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })),
|
||||
or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })),
|
||||
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
|
||||
}));
|
||||
|
||||
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, eq, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { emailProviders } from '../../db/schema';
|
||||
import { EmailProviderRepository } from './email-provider.repository';
|
||||
@@ -137,29 +136,6 @@ describe('EmailProviderRepository', () => {
|
||||
expect(updateBuilder.returning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setShared allows only system providers or already shared entries', () => {
|
||||
const { db, updateBuilder } = makeDb();
|
||||
const repo = new EmailProviderRepository(db as never);
|
||||
|
||||
void repo.setShared(6, true);
|
||||
|
||||
expect(isNull).toHaveBeenCalledWith(emailProviders.userId);
|
||||
expect(or).toHaveBeenCalledWith({ op: 'isNull', value: emailProviders.userId }, { op: 'eq', left: emailProviders.isShared, right: true });
|
||||
expect(updateBuilder.where).toHaveBeenCalledWith({
|
||||
op: 'and',
|
||||
clauses: [
|
||||
{ op: 'eq', left: emailProviders.id, right: 6 },
|
||||
{
|
||||
op: 'or',
|
||||
clauses: [
|
||||
{ op: 'isNull', value: emailProviders.userId },
|
||||
{ op: 'eq', left: emailProviders.isShared, right: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('setSharedByOwner restricts by owner id', () => {
|
||||
const { db, updateBuilder } = makeDb();
|
||||
const repo = new EmailProviderRepository(db as never);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { and, eq, ne, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -51,14 +51,6 @@ export class EmailProviderRepository {
|
||||
.returning();
|
||||
}
|
||||
|
||||
setShared(id: number, isShared: boolean) {
|
||||
return this.db
|
||||
.update(emailProviders)
|
||||
.set({ isShared, updatedAt: sql`now()` })
|
||||
.where(and(eq(emailProviders.id, id), or(isNull(emailProviders.userId), eq(emailProviders.isShared, true))))
|
||||
.returning();
|
||||
}
|
||||
|
||||
setSharedByOwner(id: number, userId: number, isShared: boolean) {
|
||||
return this.db
|
||||
.update(emailProviders)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { emailProviders } from '../../db/schema';
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { EmailProviderRepository } from './email-provider.repository';
|
||||
@@ -47,11 +48,19 @@ export class EmailProviderService {
|
||||
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) {
|
||||
await this.getOwned(id, user);
|
||||
|
||||
const patch: Record<string, unknown> = { ...dto };
|
||||
const patch: Partial<typeof emailProviders.$inferInsert> = {};
|
||||
if (dto.name !== undefined) patch.name = dto.name;
|
||||
if (dto.host !== undefined) patch.host = dto.host;
|
||||
if (dto.port !== undefined) patch.port = dto.port;
|
||||
if (dto.username !== undefined) patch.username = dto.username;
|
||||
if (dto.fromName !== undefined) patch.fromName = dto.fromName;
|
||||
if (dto.fromAddress !== undefined) patch.fromAddress = dto.fromAddress;
|
||||
if (dto.auth !== undefined) patch.auth = dto.auth;
|
||||
if (dto.ssl !== undefined) patch.ssl = dto.ssl;
|
||||
if (dto.startTls !== undefined) patch.startTls = dto.startTls;
|
||||
if (dto.password !== undefined) {
|
||||
patch.passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
||||
}
|
||||
delete patch.password;
|
||||
|
||||
const [updated] = await this.repo.update(id, user.id, patch);
|
||||
if (!updated) throw new NotFoundException('Provider not found');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
inArray: vi.fn((left: unknown, values: unknown[]) => ({ op: 'inArray', left, values })),
|
||||
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
|
||||
}));
|
||||
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } from '../../db/schema';
|
||||
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
||||
@@ -92,6 +93,20 @@ describe('EmailRecipientGroupRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('findMembersForGroupIds batches membership lookup by group ids', () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
const repo = new EmailRecipientGroupRepository(db as never);
|
||||
|
||||
void repo.findMembersForGroupIds([1, 2]);
|
||||
|
||||
expect(inArray).toHaveBeenCalledWith(emailRecipientGroupMembers.groupId, [1, 2]);
|
||||
expect(selectBuilder.where).toHaveBeenCalledWith({
|
||||
op: 'inArray',
|
||||
left: emailRecipientGroupMembers.groupId,
|
||||
values: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it('insert/update/delete enforce owner scoping and return rows', () => {
|
||||
const { db, insertBuilder, updateBuilder, deleteBuilder } = makeDb();
|
||||
const repo = new EmailRecipientGroupRepository(db as never);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -7,6 +7,9 @@ import * as schema from '../../db/schema';
|
||||
import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type EmailRecipientRow = typeof emailRecipients.$inferSelect;
|
||||
type EmailGroupMemberRow = { recipient: EmailRecipientRow };
|
||||
type EmailGroupMemberByGroupRow = { groupId: number; recipient: EmailRecipientRow };
|
||||
|
||||
@Injectable()
|
||||
export class EmailRecipientGroupRepository {
|
||||
@@ -20,7 +23,7 @@ export class EmailRecipientGroupRepository {
|
||||
return this.db.select().from(emailRecipientGroups).where(eq(emailRecipientGroups.id, id)).limit(1);
|
||||
}
|
||||
|
||||
findMembers(groupId: number) {
|
||||
findMembers(groupId: number): Promise<EmailGroupMemberRow[]> {
|
||||
return this.db
|
||||
.select({ recipient: emailRecipients })
|
||||
.from(emailRecipientGroupMembers)
|
||||
@@ -28,6 +31,15 @@ export class EmailRecipientGroupRepository {
|
||||
.where(eq(emailRecipientGroupMembers.groupId, groupId));
|
||||
}
|
||||
|
||||
findMembersForGroupIds(groupIds: number[]): Promise<EmailGroupMemberByGroupRow[]> {
|
||||
if (groupIds.length === 0) return Promise.resolve<EmailGroupMemberByGroupRow[]>([]);
|
||||
return this.db
|
||||
.select({ groupId: emailRecipientGroupMembers.groupId, recipient: emailRecipients })
|
||||
.from(emailRecipientGroupMembers)
|
||||
.innerJoin(emailRecipients, eq(emailRecipients.id, emailRecipientGroupMembers.recipientId))
|
||||
.where(inArray(emailRecipientGroupMembers.groupId, groupIds));
|
||||
}
|
||||
|
||||
insert(values: typeof emailRecipientGroups.$inferInsert) {
|
||||
return this.db.insert(emailRecipientGroups).values(values).returning();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('EmailRecipientGroupService', () => {
|
||||
findAllForUser: vi.fn().mockResolvedValue([mockGroup]),
|
||||
findById: vi.fn().mockResolvedValue([mockGroup]),
|
||||
findMembers: vi.fn().mockResolvedValue([mockMember]),
|
||||
findMembersForGroupIds: vi.fn().mockResolvedValue([{ groupId: 10, recipient: mockMember.recipient }]),
|
||||
insert: vi.fn().mockResolvedValue([mockGroup]),
|
||||
update: vi.fn().mockResolvedValue([mockGroup]),
|
||||
delete: vi.fn(),
|
||||
@@ -65,6 +66,7 @@ describe('EmailRecipientGroupService', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].members).toHaveLength(1);
|
||||
expect(result[0].members[0].name).toBe('R1');
|
||||
expect(repo.findMembersForGroupIds).toHaveBeenCalledWith([10]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,12 +16,23 @@ export class EmailRecipientGroupService {
|
||||
|
||||
async findAll(user: RequestUser) {
|
||||
const groups = await this.repo.findAllForUser(user.id);
|
||||
return Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const memberRows = await this.repo.findMembers(group.id);
|
||||
return { ...group, members: memberRows.map((r) => r.recipient) };
|
||||
}),
|
||||
);
|
||||
if (groups.length === 0) return [];
|
||||
|
||||
const memberRows = await this.repo.findMembersForGroupIds(groups.map((group) => group.id));
|
||||
const membersByGroupId = new Map<number, Array<(typeof memberRows)[number]>>();
|
||||
for (const memberRow of memberRows) {
|
||||
const rows = membersByGroupId.get(memberRow.groupId);
|
||||
if (rows) {
|
||||
rows.push(memberRow);
|
||||
} else {
|
||||
membersByGroupId.set(memberRow.groupId, [memberRow]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
members: (membersByGroupId.get(group.id) ?? []).map((row) => row.recipient),
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
inArray: vi.fn((left: unknown, values: unknown[]) => ({ op: 'inArray', left, values })),
|
||||
}));
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import { emailRecipients } from '../../db/schema';
|
||||
import { EmailRecipientRepository } from './email-recipient.repository';
|
||||
@@ -71,6 +72,20 @@ describe('EmailRecipientRepository', () => {
|
||||
expect(selectBuilder.limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('findByIds queries all requested ids in one statement', () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
const repo = new EmailRecipientRepository(db as never);
|
||||
|
||||
void repo.findByIds([3, 7, 9]);
|
||||
|
||||
expect(inArray).toHaveBeenCalledWith(emailRecipients.id, [3, 7, 9]);
|
||||
expect(selectBuilder.where).toHaveBeenCalledWith({
|
||||
op: 'inArray',
|
||||
left: emailRecipients.id,
|
||||
values: [3, 7, 9],
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts provided values and returns inserted rows', async () => {
|
||||
const { db, insertBuilder } = makeDb();
|
||||
const inserted = [{ id: 7 }];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -7,24 +7,30 @@ import * as schema from '../../db/schema';
|
||||
import { emailRecipients } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type EmailRecipientRow = typeof emailRecipients.$inferSelect;
|
||||
|
||||
@Injectable()
|
||||
export class EmailRecipientRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
findAllForUser(userId: number) {
|
||||
findAllForUser(userId: number): Promise<EmailRecipientRow[]> {
|
||||
return this.db.select().from(emailRecipients).where(eq(emailRecipients.userId, userId)).orderBy(emailRecipients.name);
|
||||
}
|
||||
|
||||
findById(id: number) {
|
||||
findById(id: number): Promise<EmailRecipientRow[]> {
|
||||
return this.db.select().from(emailRecipients).where(eq(emailRecipients.id, id)).limit(1);
|
||||
}
|
||||
|
||||
insert(values: typeof emailRecipients.$inferInsert) {
|
||||
findByIds(ids: number[]): Promise<EmailRecipientRow[]> {
|
||||
if (ids.length === 0) return Promise.resolve<EmailRecipientRow[]>([]);
|
||||
return this.db.select().from(emailRecipients).where(inArray(emailRecipients.id, ids));
|
||||
}
|
||||
|
||||
insert(values: typeof emailRecipients.$inferInsert): Promise<EmailRecipientRow[]> {
|
||||
return this.db.insert(emailRecipients).values(values).returning();
|
||||
}
|
||||
|
||||
update(id: number, userId: number, values: Partial<typeof emailRecipients.$inferInsert>) {
|
||||
update(id: number, userId: number, values: Partial<typeof emailRecipients.$inferInsert>): Promise<EmailRecipientRow[]> {
|
||||
return this.db
|
||||
.update(emailRecipients)
|
||||
.set(values)
|
||||
@@ -39,7 +45,7 @@ export class EmailRecipientRepository {
|
||||
.where(and(eq(emailRecipients.userId, userId), eq(emailRecipients.isDefault, true)));
|
||||
}
|
||||
|
||||
setDefault(id: number, userId: number) {
|
||||
setDefault(id: number, userId: number): Promise<EmailRecipientRow[]> {
|
||||
return this.db
|
||||
.update(emailRecipients)
|
||||
.set({ isDefault: true })
|
||||
@@ -47,7 +53,7 @@ export class EmailRecipientRepository {
|
||||
.returning();
|
||||
}
|
||||
|
||||
delete(id: number, userId: number) {
|
||||
delete(id: number, userId: number): Promise<EmailRecipientRow[]> {
|
||||
return this.db
|
||||
.delete(emailRecipients)
|
||||
.where(and(eq(emailRecipients.id, id), eq(emailRecipients.userId, userId)))
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('EmailRecipientService', () => {
|
||||
useValue: {
|
||||
findAllForUser: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
findById: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
findByIds: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
insert: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
update: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
delete: vi.fn(),
|
||||
@@ -95,8 +96,19 @@ describe('EmailRecipientService', () => {
|
||||
await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should get by id', async () => {
|
||||
const result = await service.getById(10);
|
||||
it('should get owned recipients by ids', async () => {
|
||||
const result = await service.getOwnedByIds([10], mockUser);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(repo.findByIds).toHaveBeenCalledWith([10]);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when one of requested recipients does not exist', async () => {
|
||||
(repo.findByIds as vi.Mock).mockResolvedValue([]);
|
||||
await expect(service.getOwnedByIds([10], mockUser)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should get owned recipient by id', async () => {
|
||||
const result = await service.getOwnedById(10, mockUser);
|
||||
expect(result.id).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,16 +50,24 @@ export class EmailRecipientService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<EmailRecipient> {
|
||||
const [recipient] = await this.repo.findById(id);
|
||||
if (!recipient) throw new NotFoundException('Recipient not found');
|
||||
async getOwnedById(id: number, user: RequestUser): Promise<EmailRecipient> {
|
||||
const [recipient] = await this.getOwnedByIds([id], user);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
async getOwnedById(id: number, user: RequestUser): Promise<EmailRecipient> {
|
||||
const [recipient] = await this.repo.findById(id);
|
||||
if (!recipient) throw new NotFoundException('Recipient not found');
|
||||
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot modify this recipient');
|
||||
return recipient;
|
||||
async getOwnedByIds(ids: number[], user: RequestUser): Promise<EmailRecipient[]> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
|
||||
const recipients = await this.repo.findByIds(uniqueIds);
|
||||
const recipientById = new Map(recipients.map((recipient) => [recipient.id, recipient]));
|
||||
const missingId = uniqueIds.find((id) => !recipientById.has(id));
|
||||
if (missingId !== undefined) throw new NotFoundException('Recipient not found');
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.userId !== user.id) throw new ForbiddenException('Cannot modify this recipient');
|
||||
}
|
||||
|
||||
return uniqueIds.map((id) => recipientById.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
lte: vi.fn((left: unknown, right: unknown) => ({ op: 'lte', left, right })),
|
||||
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
|
||||
}));
|
||||
|
||||
import { and, eq, lte, sql } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { emailSendLog } from '../../db/schema';
|
||||
import { EmailSendLogRepository } from './email-send-log.repository';
|
||||
@@ -76,25 +75,17 @@ describe('EmailSendLogRepository', () => {
|
||||
expect(sql).toHaveBeenCalledWith(expect.any(Array), emailSendLog.createdAt);
|
||||
});
|
||||
|
||||
it('findPendingRetries enforces pending status and retry time cutoff', () => {
|
||||
it('findPending scopes to pending status', () => {
|
||||
const { db, selectBuilder } = makeDb();
|
||||
const repo = new EmailSendLogRepository(db as never);
|
||||
const now = new Date('2026-03-31T12:00:00.000Z');
|
||||
|
||||
void repo.findPendingRetries(now);
|
||||
void repo.findPending();
|
||||
|
||||
expect(eq).toHaveBeenCalledWith(emailSendLog.status, 'pending');
|
||||
expect(lte).toHaveBeenCalledWith(emailSendLog.nextRetryAt, now);
|
||||
expect(and).toHaveBeenCalledWith(
|
||||
{ op: 'eq', left: emailSendLog.status, right: 'pending' },
|
||||
{ op: 'lte', left: emailSendLog.nextRetryAt, right: now },
|
||||
);
|
||||
expect(selectBuilder.where).toHaveBeenCalledWith({
|
||||
op: 'and',
|
||||
clauses: [
|
||||
{ op: 'eq', left: emailSendLog.status, right: 'pending' },
|
||||
{ op: 'lte', left: emailSendLog.nextRetryAt, right: now },
|
||||
],
|
||||
op: 'eq',
|
||||
left: emailSendLog.status,
|
||||
right: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, lte, sql } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { emailSendLog } from '../../db/schema';
|
||||
import { EMAIL_SEND_STATUS_FAILED, EMAIL_SEND_STATUS_PENDING, EMAIL_SEND_STATUS_SENT } from './email-send.constants';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -39,17 +40,14 @@ export class EmailSendLogRepository {
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
findPendingRetries(now: Date) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(emailSendLog)
|
||||
.where(and(eq(emailSendLog.status, 'pending'), lte(emailSendLog.nextRetryAt, now)));
|
||||
findPending() {
|
||||
return this.db.select().from(emailSendLog).where(eq(emailSendLog.status, EMAIL_SEND_STATUS_PENDING));
|
||||
}
|
||||
|
||||
markSent(id: number) {
|
||||
return this.db
|
||||
.update(emailSendLog)
|
||||
.set({ status: 'sent', sentAt: sql`now()`, errorMessage: null, updatedAt: sql`now()` })
|
||||
.set({ status: EMAIL_SEND_STATUS_SENT, sentAt: sql`now()`, errorMessage: null, updatedAt: sql`now()` })
|
||||
.where(eq(emailSendLog.id, id))
|
||||
.returning();
|
||||
}
|
||||
@@ -58,7 +56,7 @@ export class EmailSendLogRepository {
|
||||
return this.db
|
||||
.update(emailSendLog)
|
||||
.set({
|
||||
status: nextRetryAt ? 'pending' : 'failed',
|
||||
status: nextRetryAt ? EMAIL_SEND_STATUS_PENDING : EMAIL_SEND_STATUS_FAILED,
|
||||
errorMessage,
|
||||
nextRetryAt,
|
||||
attemptCount: sql`${emailSendLog.attemptCount} + 1`,
|
||||
@@ -72,7 +70,7 @@ export class EmailSendLogRepository {
|
||||
return this.db
|
||||
.update(emailSendLog)
|
||||
.set({
|
||||
status: 'failed',
|
||||
status: EMAIL_SEND_STATUS_FAILED,
|
||||
errorMessage: 'Server restarted before send completed. Use resend to retry.',
|
||||
nextRetryAt: null,
|
||||
updatedAt: sql`now()`,
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('EmailSendLogService', () => {
|
||||
findAll: vi.fn().mockResolvedValue([mockLog]),
|
||||
findById: vi.fn().mockResolvedValue([mockLog]),
|
||||
delete: vi.fn(),
|
||||
findPendingRetries: vi.fn().mockResolvedValue([]),
|
||||
findPending: vi.fn().mockResolvedValue([]),
|
||||
markAbandoned: vi.fn(),
|
||||
},
|
||||
},
|
||||
@@ -51,16 +51,16 @@ describe('EmailSendLogService', () => {
|
||||
});
|
||||
|
||||
describe('onApplicationBootstrap', () => {
|
||||
it('should mark stale pending entries as abandoned', async () => {
|
||||
(repo.findPendingRetries as vi.Mock).mockResolvedValue([{ id: 100 }, { id: 101 }]);
|
||||
it('should mark pending entries as abandoned', async () => {
|
||||
(repo.findPending as vi.Mock).mockResolvedValue([{ id: 100 }, { id: 101 }]);
|
||||
await service.onApplicationBootstrap();
|
||||
expect(repo.markAbandoned).toHaveBeenCalledTimes(2);
|
||||
expect(repo.markAbandoned).toHaveBeenCalledWith(100);
|
||||
expect(repo.markAbandoned).toHaveBeenCalledWith(101);
|
||||
});
|
||||
|
||||
it('should do nothing if no stale pending entries', async () => {
|
||||
(repo.findPendingRetries as vi.Mock).mockResolvedValue([]);
|
||||
it('should do nothing if no pending entries', async () => {
|
||||
(repo.findPending as vi.Mock).mockResolvedValue([]);
|
||||
await service.onApplicationBootstrap();
|
||||
expect(repo.markAbandoned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import { ForbiddenException, Injectable, Logger, NotFoundException, OnApplicatio
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { NewEmailSendLog } from '../../db/schema';
|
||||
import { EMAIL_SEND_STATUS_PENDING } from './email-send.constants';
|
||||
import { EmailSendLogRepository } from './email-send-log.repository';
|
||||
|
||||
export const MAX_SEND_ATTEMPTS = 3;
|
||||
export const SEND_RETRY_DELAYS_MS = [30_000, 120_000];
|
||||
const EMAIL_SEND_LOG_BOOTSTRAP_EVENT = 'email.send-log.bootstrap-cleanup';
|
||||
|
||||
@Injectable()
|
||||
export class EmailSendLogService implements OnApplicationBootstrap {
|
||||
@@ -14,11 +16,11 @@ export class EmailSendLogService implements OnApplicationBootstrap {
|
||||
constructor(private readonly repo: EmailSendLogRepository) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.abandonStalePending();
|
||||
await this.abandonPendingOnBootstrap();
|
||||
}
|
||||
|
||||
async create(values: Omit<NewEmailSendLog, 'status' | 'attemptCount'>) {
|
||||
const [entry] = await this.repo.insert({ ...values, status: 'pending', attemptCount: 0 });
|
||||
const [entry] = await this.repo.insert({ ...values, status: EMAIL_SEND_STATUS_PENDING, attemptCount: 0 });
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -62,10 +64,32 @@ export class EmailSendLogService implements OnApplicationBootstrap {
|
||||
return this.findOneOwned(id, user);
|
||||
}
|
||||
|
||||
private async abandonStalePending() {
|
||||
const stale = await this.repo.findPendingRetries(new Date());
|
||||
if (stale.length === 0) return;
|
||||
this.logger.warn(`Marking ${stale.length} stale pending send(s) as failed — use resend to retry`);
|
||||
await Promise.all(stale.map((entry) => this.repo.markAbandoned(entry.id)));
|
||||
private async abandonPendingOnBootstrap() {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [start] - pending send cleanup started`);
|
||||
try {
|
||||
const pending = await this.repo.findPending();
|
||||
if (pending.length === 0) {
|
||||
this.logger.log(
|
||||
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [end] durationMs=${Date.now() - startedAt} abandoned=0 - pending send cleanup completed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(pending.map((entry) => this.repo.markAbandoned(entry.id)));
|
||||
this.logger.warn(
|
||||
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [end] durationMs=${Date.now() - startedAt} abandoned=${pending.length} - pending send cleanup completed`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error && error.name ? error.name : 'Error';
|
||||
const errorMessage = (error instanceof Error ? error.message : String(error))
|
||||
.replace(/[\r\n"]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
this.logger.error(
|
||||
`[${EMAIL_SEND_LOG_BOOTSTRAP_EVENT}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - pending send cleanup failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import { EmailTemplateContextService } from './email-template-context.service';
|
||||
import { EmailPreferencesService } from './email-preferences.service';
|
||||
import { EmailSendLogService } from './email-send-log.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { SendBookDto } from './dto/send-book.dto';
|
||||
import * as fs from 'fs';
|
||||
import { KINDLE_CONVERT_SUBJECT } from './email-send.constants';
|
||||
|
||||
vi.mock('fs');
|
||||
|
||||
@@ -24,6 +26,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
let preferencesService: EmailPreferencesService;
|
||||
let sendLogService: EmailSendLogService;
|
||||
let transportService: EmailTransportService;
|
||||
let bookAccessService: EmailBookAccessService;
|
||||
|
||||
const mockUser: RequestUser = {
|
||||
id: 1,
|
||||
@@ -70,7 +73,10 @@ describe('EmailSendOrchestrator', () => {
|
||||
},
|
||||
{
|
||||
provide: EmailRecipientService,
|
||||
useValue: { getOwnedById: vi.fn().mockResolvedValue(mockRecipient) },
|
||||
useValue: {
|
||||
getOwnedById: vi.fn().mockResolvedValue(mockRecipient),
|
||||
getOwnedByIds: vi.fn().mockResolvedValue([mockRecipient]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EmailRecipientGroupService,
|
||||
@@ -101,6 +107,13 @@ describe('EmailSendOrchestrator', () => {
|
||||
provide: EmailTransportService,
|
||||
useValue: { buildTransporter: vi.fn().mockReturnValue({ sendMail: vi.fn().mockResolvedValue({}) }) },
|
||||
},
|
||||
{
|
||||
provide: EmailBookAccessService,
|
||||
useValue: {
|
||||
assertUserCanAccessBook: vi.fn().mockResolvedValue(undefined),
|
||||
assertUserCanAccessBooks: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -111,6 +124,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
preferencesService = module.get<EmailPreferencesService>(EmailPreferencesService);
|
||||
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
|
||||
transportService = module.get<EmailTransportService>(EmailTransportService);
|
||||
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
|
||||
|
||||
(fs.createReadStream as vi.Mock).mockReturnValue('mock-stream');
|
||||
});
|
||||
@@ -123,6 +137,8 @@ describe('EmailSendOrchestrator', () => {
|
||||
expect(result.queued).toBe(1);
|
||||
expect(providerResolver.resolve).toHaveBeenCalledWith(mockUser, 300);
|
||||
expect(sendLogService.create).toHaveBeenCalled();
|
||||
expect(bookAccessService.assertUserCanAccessBooks).toHaveBeenCalledWith([1], mockUser);
|
||||
expect(recipientService.getOwnedByIds).toHaveBeenCalledWith([10], mockUser);
|
||||
});
|
||||
|
||||
it('should expand groups and queue emails', async () => {
|
||||
@@ -131,13 +147,19 @@ describe('EmailSendOrchestrator', () => {
|
||||
|
||||
expect(result.queued).toBe(1);
|
||||
expect(groupService.expandOwnedGroupToRecipientIds).toHaveBeenCalledWith(5, mockUser);
|
||||
expect(recipientService.getOwnedById).toHaveBeenCalledWith(10, mockUser);
|
||||
expect(recipientService.getOwnedByIds).toHaveBeenCalledWith([10], mockUser);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if no recipients', async () => {
|
||||
const dto: SendBookDto = { bookIds: [1], recipientIds: [], groupIds: [] };
|
||||
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should fail if user cannot access requested books', async () => {
|
||||
(bookAccessService.assertUserCanAccessBooks as vi.Mock).mockRejectedValue(new Error('No access to this library'));
|
||||
const dto: SendBookDto = { bookIds: [1], recipientIds: [10] };
|
||||
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow('No access to this library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quickSend', () => {
|
||||
@@ -147,6 +169,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
const result = await orchestrator.quickSend(1, mockUser);
|
||||
|
||||
expect(result.queued).toBe(1);
|
||||
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
|
||||
expect(recipientService.getOwnedById).toHaveBeenCalledWith(10, mockUser);
|
||||
});
|
||||
|
||||
@@ -173,6 +196,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
const result = await orchestrator.resend(400, mockUser);
|
||||
|
||||
expect(result.queued).toBe(1);
|
||||
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
|
||||
expect(sendLogService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toEmail: 'resend@test.com',
|
||||
@@ -229,7 +253,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
|
||||
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: 'convert',
|
||||
subject: KINDLE_CONVERT_SUBJECT,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -243,7 +267,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
templateId: 200,
|
||||
toEmail: 'resend@test.com',
|
||||
toName: 'Resend',
|
||||
subject: 'convert',
|
||||
subject: KINDLE_CONVERT_SUBJECT,
|
||||
};
|
||||
(sendLogService.getForResend as vi.Mock).mockResolvedValue(existingLog);
|
||||
|
||||
@@ -251,7 +275,7 @@ describe('EmailSendOrchestrator', () => {
|
||||
|
||||
expect(sendLogService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subject: 'convert',
|
||||
subject: KINDLE_CONVERT_SUBJECT,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
import * as schema from '../../db/schema';
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { EmailPreferencesService } from './email-preferences.service';
|
||||
import { EmailProviderResolver } from './email-provider-resolver';
|
||||
import { EmailRecipientGroupService } from './email-recipient-group.service';
|
||||
import { EmailRecipientService } from './email-recipient.service';
|
||||
import { KINDLE_CONVERT_SUBJECT } from './email-send.constants';
|
||||
import { EmailSendLogService, SEND_RETRY_DELAYS_MS } from './email-send-log.service';
|
||||
import { EmailTemplateContextService } from './email-template-context.service';
|
||||
import { EmailTemplateService } from './email-template.service';
|
||||
@@ -25,11 +27,17 @@ interface SendTask {
|
||||
effectiveTemplateId: number | null;
|
||||
}
|
||||
|
||||
const EMAIL_SEND_EVENT = 'email.send';
|
||||
const EMAIL_RESEND_EVENT = 'email.resend';
|
||||
const EMAIL_QUICK_SEND_EVENT = 'email.quick-send';
|
||||
const EMAIL_DISPATCH_EVENT = 'email.dispatch';
|
||||
|
||||
@Injectable()
|
||||
export class EmailSendOrchestrator {
|
||||
private readonly logger = new Logger(EmailSendOrchestrator.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookAccessService: EmailBookAccessService,
|
||||
private readonly providerResolver: EmailProviderResolver,
|
||||
private readonly fileSelector: EmailFileSelector,
|
||||
private readonly recipientService: EmailRecipientService,
|
||||
@@ -42,133 +50,169 @@ export class EmailSendOrchestrator {
|
||||
) {}
|
||||
|
||||
async send(dto: SendBookDto, user: RequestUser): Promise<{ queued: number }> {
|
||||
const startedAt = Date.now();
|
||||
const requestedRecipientCount = (dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0);
|
||||
this.logger.log(
|
||||
`[email.send] [start] user=${user.id} books=${dto.bookIds.length} recipients=${(dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0)}`,
|
||||
`[${EMAIL_SEND_EVENT}] [start] userId=${user.id} bookCount=${dto.bookIds.length} requestedRecipientCount=${requestedRecipientCount} - send enqueue started`,
|
||||
);
|
||||
const tasks = await this.buildTasks(dto, user);
|
||||
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
|
||||
|
||||
const resolved = await this.providerResolver.resolve(user, dto.providerId);
|
||||
try {
|
||||
await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user);
|
||||
|
||||
let queued = 0;
|
||||
for (const task of tasks) {
|
||||
for (const bookId of dto.bookIds) {
|
||||
await this.enqueueOne({ ...task, bookId }, resolved, dto, user);
|
||||
queued++;
|
||||
const tasks = await this.buildTasks(dto, user);
|
||||
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
|
||||
|
||||
const resolved = await this.providerResolver.resolve(user, dto.providerId);
|
||||
|
||||
let queued = 0;
|
||||
for (const task of tasks) {
|
||||
for (const bookId of dto.bookIds) {
|
||||
await this.enqueueOne({ ...task, bookId }, resolved, dto.fileId ?? null, user);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[email.send] [queued] user=${user.id} total=${queued}`);
|
||||
return { queued };
|
||||
this.logger.log(`[${EMAIL_SEND_EVENT}] [end] userId=${user.id} durationMs=${Date.now() - startedAt} queued=${queued} - send enqueue completed`);
|
||||
return { queued };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[${EMAIL_SEND_EVENT}] [fail] userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - send enqueue failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resend(logEntryId: number, user: RequestUser): Promise<{ queued: number }> {
|
||||
this.logger.log(`[email.resend] [start] user=${user.id} logEntry=${logEntryId}`);
|
||||
const logEntry = await this.sendLogService.getForResend(logEntryId, user);
|
||||
if (!logEntry.bookId || !logEntry.toEmail) {
|
||||
throw new BadRequestException('Cannot resend: original book or recipient is missing');
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${EMAIL_RESEND_EVENT}] [start] userId=${user.id} logEntryId=${logEntryId} - resend enqueue started`);
|
||||
|
||||
try {
|
||||
const logEntry = await this.sendLogService.getForResend(logEntryId, user);
|
||||
if (logEntry.bookId === null || logEntry.bookId === undefined || !logEntry.toEmail) {
|
||||
throw new BadRequestException('Cannot resend: original book or recipient is missing');
|
||||
}
|
||||
|
||||
await this.bookAccessService.assertUserCanAccessBook(logEntry.bookId, user);
|
||||
|
||||
const resolved = await this.providerResolver.resolve(user, logEntry.providerId ?? null);
|
||||
const file = await this.fileSelector.select(logEntry.bookId, logEntry.bookFileId ?? null, null);
|
||||
const template = await this.templateService.resolveTemplate(logEntry.templateId ?? null, user);
|
||||
const context = await this.templateContextService.buildForBook(logEntry.bookId, file.id, user.name);
|
||||
const rendered = renderTemplate(template.subject, template.bodyText, context);
|
||||
const effectiveSubject = logEntry.subject === KINDLE_CONVERT_SUBJECT ? KINDLE_CONVERT_SUBJECT : rendered.subject;
|
||||
|
||||
const newLog = await this.sendLogService.create({
|
||||
userId: user.id,
|
||||
bookId: logEntry.bookId,
|
||||
bookFileId: file.id,
|
||||
providerId: resolved.providerId,
|
||||
templateId: template.id,
|
||||
toEmail: logEntry.toEmail,
|
||||
toName: logEntry.toName ?? '',
|
||||
subject: effectiveSubject,
|
||||
});
|
||||
|
||||
const task: SendTask = {
|
||||
bookId: logEntry.bookId,
|
||||
recipientId: 0,
|
||||
recipientEmail: logEntry.toEmail,
|
||||
recipientName: logEntry.toName ?? '',
|
||||
deviceType: logEntry.subject === KINDLE_CONVERT_SUBJECT ? 'kindle' : null,
|
||||
preferredFormat: null,
|
||||
effectiveTemplateId: logEntry.templateId ?? null,
|
||||
};
|
||||
|
||||
setImmediate(() => void this.dispatchSend(newLog.id, resolved.config, task, file, effectiveSubject, rendered.bodyText, 0));
|
||||
|
||||
this.logger.log(
|
||||
`[${EMAIL_RESEND_EVENT}] [end] userId=${user.id} logEntryId=${logEntryId} durationMs=${Date.now() - startedAt} queued=1 - resend enqueue completed`,
|
||||
);
|
||||
return { queued: 1 };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[${EMAIL_RESEND_EVENT}] [fail] userId=${user.id} logEntryId=${logEntryId} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - resend enqueue failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolved = await this.providerResolver.resolve(user, logEntry.providerId ?? null);
|
||||
const file = await this.fileSelector.select(logEntry.bookId, logEntry.bookFileId ?? null, null);
|
||||
const template = await this.templateService.resolveTemplate(logEntry.templateId ?? null, user);
|
||||
const context = await this.templateContextService.buildForBook(logEntry.bookId, file.id, user.name);
|
||||
const rendered = renderTemplate(template.subject, template.bodyText, context);
|
||||
const effectiveSubject = logEntry.subject === 'convert' ? 'convert' : rendered.subject;
|
||||
|
||||
const newLog = await this.sendLogService.create({
|
||||
userId: user.id,
|
||||
bookId: logEntry.bookId,
|
||||
bookFileId: file.id,
|
||||
providerId: resolved.providerId,
|
||||
templateId: template.id,
|
||||
toEmail: logEntry.toEmail,
|
||||
toName: logEntry.toName ?? '',
|
||||
subject: effectiveSubject,
|
||||
});
|
||||
|
||||
const task: SendTask = {
|
||||
bookId: logEntry.bookId,
|
||||
recipientId: 0,
|
||||
recipientEmail: logEntry.toEmail,
|
||||
recipientName: logEntry.toName ?? '',
|
||||
deviceType: logEntry.subject === 'convert' ? 'kindle' : null,
|
||||
preferredFormat: null,
|
||||
effectiveTemplateId: logEntry.templateId ?? null,
|
||||
};
|
||||
|
||||
setImmediate(() => void this.dispatchSend(newLog.id, resolved.config, task, file, effectiveSubject, rendered.bodyText, 0));
|
||||
|
||||
return { queued: 1 };
|
||||
}
|
||||
|
||||
async quickSend(bookId: number, user: RequestUser): Promise<{ queued: number }> {
|
||||
this.logger.log(`[email.quick-send] [start] user=${user.id} book=${bookId}`);
|
||||
const prefs = await this.preferencesService.getForUser(user.id);
|
||||
if (!prefs?.defaultRecipientId) {
|
||||
throw new BadRequestException('No default recipient configured. Set one in email settings.');
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${EMAIL_QUICK_SEND_EVENT}] [start] userId=${user.id} bookId=${bookId} - quick send enqueue started`);
|
||||
|
||||
try {
|
||||
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
|
||||
const prefs = await this.preferencesService.getForUser(user.id);
|
||||
if (!prefs?.defaultRecipientId) {
|
||||
throw new BadRequestException('No default recipient configured. Set one in email settings.');
|
||||
}
|
||||
|
||||
const recipient = await this.recipientService.getOwnedById(prefs.defaultRecipientId, user);
|
||||
const resolved = await this.providerResolver.resolve(user, null);
|
||||
|
||||
await this.enqueueOne(
|
||||
{
|
||||
bookId,
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
deviceType: recipient.deviceType,
|
||||
preferredFormat: recipient.preferredFormat,
|
||||
effectiveTemplateId: recipient.defaultTemplateId ?? prefs.defaultTemplateId ?? null,
|
||||
},
|
||||
resolved,
|
||||
null,
|
||||
user,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[${EMAIL_QUICK_SEND_EVENT}] [end] userId=${user.id} bookId=${bookId} durationMs=${Date.now() - startedAt} queued=1 - quick send enqueue completed`,
|
||||
);
|
||||
return { queued: 1 };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[${EMAIL_QUICK_SEND_EVENT}] [fail] userId=${user.id} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${this.getErrorClass(error)} error="${this.getErrorMessage(error)}" - quick send enqueue failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recipient = await this.recipientService.getOwnedById(prefs.defaultRecipientId, user);
|
||||
const resolved = await this.providerResolver.resolve(user, null);
|
||||
|
||||
await this.enqueueOne(
|
||||
{
|
||||
bookId,
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
deviceType: recipient.deviceType,
|
||||
preferredFormat: recipient.preferredFormat,
|
||||
effectiveTemplateId: recipient.defaultTemplateId ?? prefs.defaultTemplateId ?? null,
|
||||
},
|
||||
resolved,
|
||||
{ bookIds: [bookId] },
|
||||
user,
|
||||
);
|
||||
|
||||
return { queued: 1 };
|
||||
}
|
||||
|
||||
private async buildTasks(dto: SendBookDto, user: RequestUser): Promise<Omit<SendTask, 'bookId'>[]> {
|
||||
const tasks: Omit<SendTask, 'bookId'>[] = [];
|
||||
|
||||
const recipientIds = new Set<number>(dto.recipientIds ?? []);
|
||||
|
||||
if (dto.groupIds?.length) {
|
||||
for (const groupId of dto.groupIds) {
|
||||
const ids = await this.groupService.expandOwnedGroupToRecipientIds(groupId, user);
|
||||
ids.forEach((id) => recipientIds.add(id));
|
||||
const groupRecipients = await Promise.all(dto.groupIds.map((groupId) => this.groupService.expandOwnedGroupToRecipientIds(groupId, user)));
|
||||
for (const groupMemberIds of groupRecipients) {
|
||||
groupMemberIds.forEach((id) => recipientIds.add(id));
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of recipientIds) {
|
||||
const recipient = await this.recipientService.getOwnedById(id, user);
|
||||
tasks.push({
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
deviceType: recipient.deviceType,
|
||||
preferredFormat: recipient.preferredFormat,
|
||||
effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null,
|
||||
});
|
||||
}
|
||||
const dedupedRecipientIds = [...recipientIds];
|
||||
if (dedupedRecipientIds.length === 0) return [];
|
||||
|
||||
return tasks;
|
||||
const recipients = await this.recipientService.getOwnedByIds(dedupedRecipientIds, user);
|
||||
return recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
deviceType: recipient.deviceType,
|
||||
preferredFormat: recipient.preferredFormat,
|
||||
effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private async enqueueOne(
|
||||
task: SendTask,
|
||||
resolved: Awaited<ReturnType<EmailProviderResolver['resolve']>>,
|
||||
dto: Pick<SendBookDto, 'bookIds' | 'fileId' | 'templateId'>,
|
||||
fileId: number | null,
|
||||
user: RequestUser,
|
||||
) {
|
||||
const file = await this.fileSelector.select(task.bookId, dto.fileId ?? null, task.preferredFormat);
|
||||
const file = await this.fileSelector.select(task.bookId, fileId, task.preferredFormat);
|
||||
const template = await this.templateService.resolveTemplate(task.effectiveTemplateId, user);
|
||||
const context = await this.templateContextService.buildForBook(task.bookId, file.id, user.name);
|
||||
const rendered = renderTemplate(template.subject, template.bodyText, context);
|
||||
|
||||
const effectiveSubject = task.deviceType === 'kindle' ? 'convert' : rendered.subject;
|
||||
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : rendered.subject;
|
||||
|
||||
const logEntry = await this.sendLogService.create({
|
||||
userId: user.id,
|
||||
@@ -188,14 +232,14 @@ export class EmailSendOrchestrator {
|
||||
logId: number,
|
||||
smtpConfig: Awaited<ReturnType<EmailProviderResolver['resolve']>>['config'],
|
||||
task: SendTask,
|
||||
file: typeof schema.bookFiles.$inferSelect,
|
||||
file: BookFile,
|
||||
subject: string,
|
||||
bodyText: string,
|
||||
attemptCount: number,
|
||||
) {
|
||||
try {
|
||||
const transporter = this.transportService.buildTransporter(smtpConfig);
|
||||
const effectiveSubject = task.deviceType === 'kindle' ? 'convert' : subject;
|
||||
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject;
|
||||
|
||||
await transporter.sendMail({
|
||||
to: task.recipientEmail,
|
||||
@@ -210,23 +254,28 @@ export class EmailSendOrchestrator {
|
||||
});
|
||||
|
||||
await this.sendLogService.markSent(logId);
|
||||
this.logger.log(`[email.dispatch] [success] log=${logId} to=${task.recipientEmail}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(`[email.dispatch] [retry] log=${logId} attempt=${attemptCount + 1} error="${errorMessage}"`);
|
||||
} catch (error) {
|
||||
const errorClass = this.getErrorClass(error);
|
||||
const errorMessage = this.getErrorMessage(error);
|
||||
const attempt = attemptCount + 1;
|
||||
|
||||
const { isFinal } = await this.sendLogService.markFailed(logId, errorMessage, attemptCount);
|
||||
|
||||
if (!isFinal) {
|
||||
const delayMs = SEND_RETRY_DELAYS_MS[attemptCount] ?? 0;
|
||||
this.logger.warn(
|
||||
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} attempt=${attempt} willRetry=true retryDelayMs=${delayMs} errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
||||
);
|
||||
setTimeout(() => void this.dispatchSend(logId, smtpConfig, task, file, subject, bodyText, attemptCount + 1), delayMs);
|
||||
} else {
|
||||
this.logger.error(`[email.dispatch] [fail] log=${logId} to=${task.recipientEmail} error="${errorMessage}"`);
|
||||
this.logger.error(
|
||||
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildAttachmentFilename(file: typeof schema.bookFiles.$inferSelect): string {
|
||||
private buildAttachmentFilename(file: BookFile): string {
|
||||
const ext = file.format ? `.${file.format.toLowerCase()}` : '';
|
||||
const base = file.relPath
|
||||
? (file.relPath
|
||||
@@ -236,4 +285,17 @@ export class EmailSendOrchestrator {
|
||||
: 'book';
|
||||
return `${base}${ext}`;
|
||||
}
|
||||
|
||||
private getErrorClass(error: unknown): string {
|
||||
if (error instanceof Error && error.name) return error.name;
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
private getErrorMessage(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
return raw
|
||||
.replace(/[\r\n"]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const KINDLE_CONVERT_SUBJECT = 'convert';
|
||||
|
||||
export const EMAIL_SEND_STATUS_PENDING = 'pending';
|
||||
export const EMAIL_SEND_STATUS_SENT = 'sent';
|
||||
export const EMAIL_SEND_STATUS_FAILED = 'failed';
|
||||
@@ -1,27 +1,33 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { EmailTemplateContextService } from './email-template-context.service';
|
||||
import { DB } from '../../db';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
|
||||
describe('EmailTemplateContextService', () => {
|
||||
let service: EmailTemplateContextService;
|
||||
let db: any;
|
||||
let repo: {
|
||||
findBookById: ReturnType<typeof vi.fn>;
|
||||
findMetadataByBookId: ReturnType<typeof vi.fn>;
|
||||
findAuthorNamesByBookId: ReturnType<typeof vi.fn>;
|
||||
findTagNamesByBookId: ReturnType<typeof vi.fn>;
|
||||
findFileById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
db = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
repo = {
|
||||
findBookById: vi.fn(),
|
||||
findMetadataByBookId: vi.fn(),
|
||||
findAuthorNamesByBookId: vi.fn(),
|
||||
findTagNamesByBookId: vi.fn(),
|
||||
findFileById: vi.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailTemplateContextService,
|
||||
{ provide: DB, useValue: db },
|
||||
{ provide: EmailBookReadRepository, useValue: repo },
|
||||
{ provide: ConfigService, useValue: { get: vi.fn().mockReturnValue('http://localhost') } },
|
||||
],
|
||||
}).compile();
|
||||
@@ -30,41 +36,16 @@ describe('EmailTemplateContextService', () => {
|
||||
});
|
||||
|
||||
it('should build context for a book', async () => {
|
||||
const mockBook = { id: 1 };
|
||||
const mockBook = { id: 1, primaryFileId: 100 };
|
||||
const mockMeta = { title: 'Book Title', subtitle: 'Sub', seriesName: 'S', seriesIndex: 1 };
|
||||
const mockAuthors = [{ name: 'A1' }, { name: 'A2' }];
|
||||
const mockTags = [{ name: 'T1' }];
|
||||
const mockFile = { format: 'EPUB', sizeBytes: 1024 * 1024 };
|
||||
|
||||
// Promise.all order: book, meta, authors, tags, file
|
||||
(db.limit as vi.Mock)
|
||||
.mockResolvedValueOnce([mockBook]) // book
|
||||
.mockResolvedValueOnce([mockMeta]) // meta
|
||||
.mockResolvedValueOnce([mockFile]); // file (because it's the last one with limit)
|
||||
|
||||
// Authors and tags use orderBy or innerJoin and don't have limit
|
||||
(db.orderBy as vi.Mock).mockResolvedValueOnce(mockAuthors);
|
||||
(db.where as vi.Mock).mockImplementation(function (this: any) {
|
||||
if (db.where.mock.calls.length === 4) {
|
||||
// tags call
|
||||
return Promise.resolve(mockTags);
|
||||
}
|
||||
return this;
|
||||
});
|
||||
|
||||
// Actually, it's cleaner to mock each stage
|
||||
db.limit = vi.fn().mockResolvedValueOnce([mockBook]).mockResolvedValueOnce([mockMeta]).mockResolvedValueOnce([mockFile]);
|
||||
|
||||
db.orderBy = vi.fn().mockResolvedValueOnce(mockAuthors);
|
||||
|
||||
// For tags, it's select().from().innerJoin().where() -> no limit, no orderBy
|
||||
// We need to make where() return mockTags for the 4th call
|
||||
let callCount = 0;
|
||||
db.where = vi.fn().mockImplementation(function (this: any) {
|
||||
callCount++;
|
||||
if (callCount === 4) return Promise.resolve(mockTags);
|
||||
return this;
|
||||
});
|
||||
const mockFile = { id: 100, bookId: 1, format: 'EPUB', sizeBytes: 1024 * 1024 };
|
||||
repo.findBookById.mockResolvedValue(mockBook);
|
||||
repo.findMetadataByBookId.mockResolvedValue(mockMeta);
|
||||
repo.findAuthorNamesByBookId.mockResolvedValue(mockAuthors);
|
||||
repo.findTagNamesByBookId.mockResolvedValue(mockTags);
|
||||
repo.findFileById.mockResolvedValue(mockFile);
|
||||
|
||||
const context = await service.buildForBook(1, 100, 'Sender');
|
||||
|
||||
@@ -76,10 +57,21 @@ describe('EmailTemplateContextService', () => {
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if book not found', async () => {
|
||||
db.limit = vi.fn().mockResolvedValueOnce([]); // book not found
|
||||
db.where = vi.fn().mockReturnThis();
|
||||
db.orderBy = vi.fn().mockReturnThis();
|
||||
repo.findBookById.mockResolvedValue(null);
|
||||
repo.findMetadataByBookId.mockResolvedValue(null);
|
||||
repo.findAuthorNamesByBookId.mockResolvedValue([]);
|
||||
repo.findTagNamesByBookId.mockResolvedValue([]);
|
||||
|
||||
await expect(service.buildForBook(1, null, 'Sender')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if file does not belong to the book', async () => {
|
||||
repo.findBookById.mockResolvedValue({ id: 1 });
|
||||
repo.findMetadataByBookId.mockResolvedValue(null);
|
||||
repo.findAuthorNamesByBookId.mockResolvedValue([]);
|
||||
repo.findTagNamesByBookId.mockResolvedValue([]);
|
||||
repo.findFileById.mockResolvedValue({ id: 200, bookId: 2, format: 'PDF' });
|
||||
|
||||
await expect(service.buildForBook(1, 200, 'Sender')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, tags } from '../../db/schema';
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import type { TemplateContext } from './email-template-renderer';
|
||||
import { formatFileSize } from './email-template-renderer';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
|
||||
@Injectable()
|
||||
export class EmailTemplateContextService {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly bookReadRepository: EmailBookReadRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async buildForBook(bookId: number, fileId: number | null, senderName: string): Promise<TemplateContext> {
|
||||
const [bookResult, metaResult, authorRows, tagRows, fileResult] = await Promise.all([
|
||||
this.db.select().from(books).where(eq(books.id, bookId)).limit(1),
|
||||
this.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1),
|
||||
this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId))
|
||||
.orderBy(bookAuthors.displayOrder),
|
||||
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
|
||||
fileId ? this.db.select().from(bookFiles).where(eq(bookFiles.id, fileId)).limit(1) : Promise.resolve([]),
|
||||
const [book, meta, authorRows, tagRows, file] = await Promise.all([
|
||||
this.bookReadRepository.findBookById(bookId),
|
||||
this.bookReadRepository.findMetadataByBookId(bookId),
|
||||
this.bookReadRepository.findAuthorNamesByBookId(bookId),
|
||||
this.bookReadRepository.findTagNamesByBookId(bookId),
|
||||
fileId !== null ? this.bookReadRepository.findFileById(fileId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const [book] = bookResult;
|
||||
if (!book) throw new NotFoundException('Book not found');
|
||||
if (fileId !== null && (!file || file.bookId !== bookId)) throw new NotFoundException('Book file not found');
|
||||
|
||||
const [meta] = metaResult;
|
||||
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0];
|
||||
const selectedFile: BookFile | null = fileId !== null ? file : null;
|
||||
|
||||
const appUrl = this.config.get<string>('app.appUrl') ?? '';
|
||||
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
|
||||
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
|
||||
|
||||
return {
|
||||
@@ -47,8 +36,8 @@ export class EmailTemplateContextService {
|
||||
author: authorRows.map((a) => a.name).join(', '),
|
||||
series: meta?.seriesName ?? '',
|
||||
seriesIndex: meta?.seriesIndex ?? null,
|
||||
format: file?.format?.toUpperCase() ?? '',
|
||||
fileSize: formatFileSize(file?.sizeBytes ?? null),
|
||||
format: selectedFile?.format?.toUpperCase() ?? '',
|
||||
fileSize: formatFileSize(selectedFile?.sizeBytes ?? null),
|
||||
pageCount: meta?.pageCount ?? null,
|
||||
publisher: meta?.publisher ?? '',
|
||||
publishedYear: meta?.publishedYear ?? null,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EmailTemplateService } from './email-template.service';
|
||||
import { EmailTemplateRepository } from './email-template.repository';
|
||||
import { EmailTemplateContextService } from './email-template-context.service';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
|
||||
describe('EmailTemplateService', () => {
|
||||
let service: EmailTemplateService;
|
||||
let repo: EmailTemplateRepository;
|
||||
let bookAccessService: EmailBookAccessService;
|
||||
|
||||
const mockUser: RequestUser = {
|
||||
id: 1,
|
||||
@@ -55,11 +57,16 @@ describe('EmailTemplateService', () => {
|
||||
provide: EmailTemplateContextService,
|
||||
useValue: { buildForBook: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: EmailBookAccessService,
|
||||
useValue: { assertUserCanAccessBook: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EmailTemplateService>(EmailTemplateService);
|
||||
repo = module.get<EmailTemplateRepository>(EmailTemplateRepository);
|
||||
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@@ -148,6 +155,13 @@ describe('EmailTemplateService', () => {
|
||||
|
||||
const result = await service.preview(10, 1, 100, mockUser);
|
||||
expect(result.subject).toBe('S');
|
||||
expect(bookAccessService.assertUserCanAccessBook).toHaveBeenCalledWith(1, mockUser);
|
||||
});
|
||||
|
||||
it('should fail preview when book access is denied', async () => {
|
||||
(bookAccessService.assertUserCanAccessBook as vi.Mock).mockRejectedValue(new ForbiddenException('No access to this library'));
|
||||
|
||||
await expect(service.preview(10, 1, 100, mockUser)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RequestUser } from '../../common/types/request-user';
|
||||
import { EmailTemplateRepository } from './email-template.repository';
|
||||
import { EmailTemplateContextService } from './email-template-context.service';
|
||||
import { renderTemplate } from './email-template-renderer';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
|
||||
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
|
||||
import type { EmailTemplate } from '../../db/schema';
|
||||
@@ -13,6 +14,7 @@ export class EmailTemplateService {
|
||||
constructor(
|
||||
private readonly repo: EmailTemplateRepository,
|
||||
private readonly contextService: EmailTemplateContextService,
|
||||
private readonly bookAccessService: EmailBookAccessService,
|
||||
) {}
|
||||
|
||||
async findAll(user: RequestUser) {
|
||||
@@ -73,6 +75,7 @@ export class EmailTemplateService {
|
||||
|
||||
async preview(id: number, bookId: number, fileId: number | null, user: RequestUser) {
|
||||
const template = await this.getAccessible(id, user);
|
||||
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
|
||||
const context = await this.contextService.buildForBook(bookId, fileId, user.name);
|
||||
return renderTemplate(template.subject, template.bodyText, context);
|
||||
}
|
||||
|
||||
@@ -83,4 +83,22 @@ describe('EmailTransportService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not disable TLS certificate verification by default', () => {
|
||||
(nodemailer.createTransport as vi.Mock).mockReturnValue({});
|
||||
|
||||
service.buildTransporter({
|
||||
host: 'smtp.test.com',
|
||||
port: 587,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
auth: true,
|
||||
ssl: false,
|
||||
startTls: true,
|
||||
});
|
||||
|
||||
const calls = (nodemailer.createTransport as vi.Mock).mock.calls;
|
||||
const callArg = calls[calls.length - 1][0] as Record<string, unknown>;
|
||||
expect(callArg).not.toHaveProperty('tls');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
export interface SmtpConfig {
|
||||
@@ -17,16 +18,18 @@ export class EmailTransportService {
|
||||
buildTransporter(config: SmtpConfig): Transporter {
|
||||
const secure = config.ssl || config.port === 465;
|
||||
const requireTls = !secure && (config.startTls || config.port === 587);
|
||||
const hasAuthCredentials = config.username !== undefined && config.username !== null && config.password !== undefined && config.password !== null;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
const username = config.username ?? undefined;
|
||||
const password = config.password ?? undefined;
|
||||
const auth = config.auth && username !== undefined && password !== undefined ? { user: username, pass: password } : undefined;
|
||||
const options: SMTPTransport.Options = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure,
|
||||
requireTLS: requireTls,
|
||||
...(config.auth && hasAuthCredentials ? { auth: { user: config.username, pass: config.password } } : {}),
|
||||
tls: { rejectUnauthorized: false },
|
||||
} as nodemailer.TransportOptions);
|
||||
...(auth ? { auth } : {}),
|
||||
};
|
||||
|
||||
return nodemailer.createTransport(options);
|
||||
}
|
||||
|
||||
async verifyTransporter(transporter: Transporter): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
import { EmailAdminLogController } from './email-admin-log.controller';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { EmailModule } from './email.module';
|
||||
@@ -27,6 +29,7 @@ import { EmailTemplateController } from './email-template.controller';
|
||||
import { EmailTemplateRepository } from './email-template.repository';
|
||||
import { EmailTemplateService } from './email-template.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { SystemMailService } from './system-mail.service';
|
||||
|
||||
describe('EmailModule metadata', () => {
|
||||
it('registers all email controllers', () => {
|
||||
@@ -66,13 +69,16 @@ describe('EmailModule metadata', () => {
|
||||
EmailPreferencesRepository,
|
||||
EmailPreferencesService,
|
||||
EmailProviderResolver,
|
||||
EmailBookReadRepository,
|
||||
EmailBookAccessService,
|
||||
EmailFileSelector,
|
||||
EmailSendLogRepository,
|
||||
EmailSendLogService,
|
||||
EmailSendOrchestrator,
|
||||
SystemMailService,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(exportsList).toEqual(expect.arrayContaining([EmailSendOrchestrator, EmailTransportService, EmailEncryptionService]));
|
||||
expect(exportsList).toEqual(expect.arrayContaining([EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { EmailAdminLogController } from './email-admin-log.controller';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { EmailPreferencesController } from './email-preferences.controller';
|
||||
@@ -29,7 +33,7 @@ import { EmailTransportService } from './email-transport.service';
|
||||
import { SystemMailService } from './system-mail.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [forwardRef(() => BookModule), forwardRef(() => LibraryModule)],
|
||||
controllers: [
|
||||
EmailProviderController,
|
||||
EmailRecipientController,
|
||||
@@ -41,6 +45,8 @@ import { SystemMailService } from './system-mail.service';
|
||||
EmailAdminLogController,
|
||||
],
|
||||
providers: [
|
||||
EmailBookReadRepository,
|
||||
EmailBookAccessService,
|
||||
EmailEncryptionService,
|
||||
EmailTransportService,
|
||||
EmailProviderRepository,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailProviderRepository } from './email-provider.repository';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { SystemMailService } from './system-mail.service';
|
||||
|
||||
describe('SystemMailService', () => {
|
||||
let service: SystemMailService;
|
||||
let repo: { findSystemProvider: ReturnType<typeof vi.fn> };
|
||||
let encryption: { decrypt: ReturnType<typeof vi.fn> };
|
||||
let transport: { buildTransporter: ReturnType<typeof vi.fn> };
|
||||
let config: { get: ReturnType<typeof vi.fn> };
|
||||
let transporter: { sendMail: ReturnType<typeof vi.fn> };
|
||||
|
||||
const provider = {
|
||||
id: 3,
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'smtp-user',
|
||||
passwordEnc: 'enc-password',
|
||||
auth: true,
|
||||
ssl: false,
|
||||
startTls: true,
|
||||
fromName: 'Project X',
|
||||
fromAddress: 'no-reply@example.com',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
transporter = {
|
||||
sendMail: vi.fn().mockResolvedValue({ messageId: 'abc123' }),
|
||||
};
|
||||
repo = {
|
||||
findSystemProvider: vi.fn().mockResolvedValue([provider]),
|
||||
};
|
||||
encryption = {
|
||||
decrypt: vi.fn().mockReturnValue('decrypted-password'),
|
||||
};
|
||||
transport = {
|
||||
buildTransporter: vi.fn().mockReturnValue(transporter),
|
||||
};
|
||||
config = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'app.appUrl') return 'http://localhost:3000/';
|
||||
if (key === 'app.nodeEnv') return 'test';
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SystemMailService,
|
||||
{ provide: EmailProviderRepository, useValue: repo },
|
||||
{ provide: EmailEncryptionService, useValue: encryption },
|
||||
{ provide: EmailTransportService, useValue: transport },
|
||||
{ provide: ConfigService, useValue: config },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SystemMailService>(SystemMailService);
|
||||
});
|
||||
|
||||
it('reports configured when a system provider exists', async () => {
|
||||
await expect(service.isConfigured()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('reports not configured when no system provider exists', async () => {
|
||||
repo.findSystemProvider.mockResolvedValue([]);
|
||||
await expect(service.isConfigured()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('sends a password reset email using the configured provider', async () => {
|
||||
await service.sendPasswordReset('user@example.com', 'User', 'raw-token');
|
||||
|
||||
expect(transport.buildTransporter).toHaveBeenCalledWith({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'smtp-user',
|
||||
password: 'decrypted-password',
|
||||
auth: true,
|
||||
ssl: false,
|
||||
startTls: true,
|
||||
});
|
||||
expect(transporter.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: 'Project X <no-reply@example.com>',
|
||||
to: 'user@example.com',
|
||||
subject: 'Reset your password',
|
||||
}),
|
||||
);
|
||||
expect(transporter.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('http://localhost:3000/reset-password?token=raw-token'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when no system provider is configured at send time', async () => {
|
||||
repo.findSystemProvider.mockResolvedValue([]);
|
||||
await expect(service.sendPasswordReset('user@example.com', 'User', 'raw-token')).rejects.toThrow(ServiceUnavailableException);
|
||||
expect(transport.buildTransporter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when SMTP delivery fails', async () => {
|
||||
transporter.sendMail.mockRejectedValue(new Error('smtp timeout'));
|
||||
await expect(service.sendPasswordReset('user@example.com', 'User', 'raw-token')).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
@@ -8,6 +8,7 @@ import { EmailTransportService } from './email-transport.service';
|
||||
@Injectable()
|
||||
export class SystemMailService {
|
||||
private readonly logger = new Logger(SystemMailService.name);
|
||||
private readonly event = 'system-mail.password-reset';
|
||||
|
||||
constructor(
|
||||
private readonly repo: EmailProviderRepository,
|
||||
@@ -22,17 +23,21 @@ export class SystemMailService {
|
||||
}
|
||||
|
||||
async sendPasswordReset(to: string, name: string, rawToken: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
|
||||
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
|
||||
this.logger.log(`[${this.event}] [start] toEmail=${to} - password reset send started`);
|
||||
|
||||
if (this.config.get('app.nodeEnv') !== 'production') {
|
||||
this.logger.log(`[DEV] Password reset URL for ${name}: ${resetUrl}`);
|
||||
this.logger.debug(`[${this.event}] [start] toEmail=${to} - password reset requested in non-production environment`);
|
||||
}
|
||||
|
||||
const [provider] = await this.repo.findSystemProvider();
|
||||
if (!provider) {
|
||||
this.logger.warn('System mail provider not found when sending password reset - provider may have been removed');
|
||||
return;
|
||||
this.logger.error(
|
||||
`[${this.event}] [fail] toEmail=${to} durationMs=${Date.now() - startedAt} errorClass=ServiceUnavailableException error="system mail provider not configured" - password reset send failed`,
|
||||
);
|
||||
throw new ServiceUnavailableException('System mail provider is not configured');
|
||||
}
|
||||
|
||||
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
||||
@@ -56,10 +61,14 @@ export class SystemMailService {
|
||||
text: this.buildResetText(name, resetUrl),
|
||||
html: this.buildResetHtml(name, resetUrl),
|
||||
});
|
||||
this.logger.log(`[system-mail.password-reset] [success] to=${to}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`[system-mail.password-reset] [fail] to=${to} error="${errorMessage}"`);
|
||||
this.logger.log(`[${this.event}] [end] toEmail=${to} durationMs=${Date.now() - startedAt} - password reset send completed`);
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error && error.name ? error.name : 'Error';
|
||||
const errorMessage = this.sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
this.logger.error(
|
||||
`[${this.event}] [fail] toEmail=${to} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - password reset send failed`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Password reset email delivery failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,4 +135,11 @@ export class SystemMailService {
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private sanitizeErrorMessage(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n"]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { BookEmbedderRepository } from './book-embedder.repository';
|
||||
|
||||
type SelectStep = {
|
||||
terminal: 'where' | 'limit';
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
function makeDb(steps: SelectStep[]) {
|
||||
const chains: Array<Record<string, vi.Mock>> = [];
|
||||
|
||||
const select = vi.fn().mockImplementation(() => {
|
||||
const step = steps.shift();
|
||||
if (!step) throw new Error('No mocked select step available');
|
||||
|
||||
const chain = {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
innerJoin: vi.fn(),
|
||||
};
|
||||
|
||||
chain.from.mockReturnValue(chain);
|
||||
chain.innerJoin.mockReturnValue(chain);
|
||||
chain.where.mockImplementation(() => {
|
||||
if (step.terminal === 'where') return Promise.resolve(step.result);
|
||||
return chain;
|
||||
});
|
||||
chain.limit.mockImplementation(() => Promise.resolve(step.result));
|
||||
|
||||
chains.push(chain);
|
||||
return chain;
|
||||
});
|
||||
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
|
||||
const update = vi.fn().mockReturnValue({ set: updateSet });
|
||||
|
||||
return {
|
||||
db: { select, update } as never,
|
||||
select,
|
||||
chains,
|
||||
update,
|
||||
updateSet,
|
||||
updateWhere,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookEmbedderRepository', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null when metadata row does not exist', async () => {
|
||||
const { db, select } = makeDb([{ terminal: 'limit', result: [] }]);
|
||||
const repository = new BookEmbedderRepository(db);
|
||||
|
||||
const result = await repository.findSourceData(42);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('combines metadata with normalized author, genre, and tag names', async () => {
|
||||
const { db, select } = makeDb([
|
||||
{
|
||||
terminal: 'limit',
|
||||
result: [
|
||||
{
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
terminal: 'where',
|
||||
result: [{ name: ' Andrzej Sapkowski ' }, { name: ' ' }, { name: null }],
|
||||
},
|
||||
{
|
||||
terminal: 'where',
|
||||
result: [{ name: 'Fantasy' }, { name: 'Epic Fantasy' }],
|
||||
},
|
||||
{
|
||||
terminal: 'where',
|
||||
result: [{ name: 'Sword & Sorcery' }, { name: '' }],
|
||||
},
|
||||
]);
|
||||
const repository = new BookEmbedderRepository(db);
|
||||
|
||||
const result = await repository.findSourceData(7);
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(4);
|
||||
expect(result).toEqual({
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
authors: ['Andrzej Sapkowski'],
|
||||
genres: ['Fantasy', 'Epic Fantasy'],
|
||||
tags: ['Sword & Sorcery'],
|
||||
});
|
||||
});
|
||||
|
||||
it('persists embedding vectors to book metadata', async () => {
|
||||
const { db, update, updateSet, updateWhere } = makeDb([]);
|
||||
const repository = new BookEmbedderRepository(db);
|
||||
const embedding = [0.1, 0.2, 0.3];
|
||||
|
||||
await repository.saveEmbedding(11, embedding);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(updateSet).toHaveBeenCalledWith({ embedding });
|
||||
expect(updateWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema';
|
||||
import { BookEmbeddingSourceData } from './book-embedding.types';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type NameRow = { name: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class BookEmbedderRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findSourceData(bookId: number): Promise<BookEmbeddingSourceData | null> {
|
||||
const [metadata] = await this.db
|
||||
.select({
|
||||
title: bookMetadata.title,
|
||||
seriesName: bookMetadata.seriesName,
|
||||
publisher: bookMetadata.publisher,
|
||||
description: bookMetadata.description,
|
||||
})
|
||||
.from(bookMetadata)
|
||||
.where(eq(bookMetadata.bookId, bookId))
|
||||
.limit(1);
|
||||
|
||||
if (!metadata) return null;
|
||||
|
||||
const [authorNames, genreNames, tagNames] = await Promise.all([
|
||||
this.fetchRelationNames(
|
||||
this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId)),
|
||||
),
|
||||
this.fetchRelationNames(
|
||||
this.db
|
||||
.select({ name: genres.name })
|
||||
.from(bookGenres)
|
||||
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
|
||||
.where(eq(bookGenres.bookId, bookId)),
|
||||
),
|
||||
this.fetchRelationNames(
|
||||
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
title: metadata.title,
|
||||
seriesName: metadata.seriesName,
|
||||
publisher: metadata.publisher,
|
||||
description: metadata.description,
|
||||
authors: authorNames,
|
||||
genres: genreNames,
|
||||
tags: tagNames,
|
||||
};
|
||||
}
|
||||
|
||||
async saveEmbedding(bookId: number, embedding: number[]): Promise<void> {
|
||||
await this.db.update(bookMetadata).set({ embedding }).where(eq(bookMetadata.bookId, bookId));
|
||||
}
|
||||
|
||||
private async fetchRelationNames(queryPromise: Promise<NameRow[]>): Promise<string[]> {
|
||||
const rows = await queryPromise;
|
||||
return rows.map((row) => (row.name ?? '').trim()).filter((name): name is string => name.length > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +1,104 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BookEmbeddingSourceData } from './book-embedding.types';
|
||||
import { BookEmbedderService } from './book-embedder.service';
|
||||
|
||||
function makeMetaChain(metaRow: unknown[] = []) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue(metaRow),
|
||||
function makeSourceData(overrides: Partial<BookEmbeddingSourceData> = {}): BookEmbeddingSourceData {
|
||||
return {
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
authors: ['Andrzej Sapkowski'],
|
||||
genres: ['Fantasy'],
|
||||
tags: ['Sword & Sorcery'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const embedderRepository = {
|
||||
findSourceData: vi.fn(),
|
||||
saveEmbedding: vi.fn(),
|
||||
};
|
||||
const vectorizer = {
|
||||
buildVector: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new BookEmbedderService(embedderRepository as never, vectorizer as never),
|
||||
embedderRepository,
|
||||
vectorizer,
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe('BookEmbedderService', () => {
|
||||
const logger = { debug: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null when book metadata is missing', async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue(makeMetaChain([])),
|
||||
};
|
||||
|
||||
const service = new BookEmbedderService(db as never);
|
||||
it('returns null when metadata row does not exist', async () => {
|
||||
const { service, embedderRepository, vectorizer } = makeService();
|
||||
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
embedderRepository.findSourceData.mockResolvedValue(null);
|
||||
|
||||
await expect(service.embedBook(42)).resolves.toBeNull();
|
||||
expect(db.select).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(embedderRepository.findSourceData).toHaveBeenCalledWith(42);
|
||||
expect(vectorizer.buildVector).not.toHaveBeenCalled();
|
||||
expect(embedderRepository.saveEmbedding).not.toHaveBeenCalled();
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [start] bookId=42'));
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('outcome=metadata_missing'));
|
||||
});
|
||||
|
||||
it('builds and persists deterministic embedding for metadata + relations', async () => {
|
||||
const metaChain = makeMetaChain([
|
||||
{
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
},
|
||||
]);
|
||||
it('builds and persists embedding when source data exists', async () => {
|
||||
const { service, embedderRepository, vectorizer } = makeService();
|
||||
const debugSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
const sourceData = makeSourceData();
|
||||
const embedding = [0.12, 0.34, 0.56];
|
||||
|
||||
const authorChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([{ name: 'Andrzej Sapkowski' }]),
|
||||
};
|
||||
const genreChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([{ name: 'Fantasy' }]),
|
||||
};
|
||||
const tagChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([{ name: 'Sword & Sorcery' }]),
|
||||
};
|
||||
embedderRepository.findSourceData.mockResolvedValue(sourceData);
|
||||
vectorizer.buildVector.mockReturnValue(embedding);
|
||||
embedderRepository.saveEmbedding.mockResolvedValue(undefined);
|
||||
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
|
||||
const update = vi.fn().mockReturnValue({ set: updateSet });
|
||||
await expect(service.embedBook(7)).resolves.toEqual(embedding);
|
||||
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(genreChain).mockReturnValueOnce(tagChain),
|
||||
update,
|
||||
};
|
||||
|
||||
const service = new BookEmbedderService(db as never);
|
||||
(service as unknown as { logger: typeof logger }).logger = logger as never;
|
||||
|
||||
const embedding = await service.embedBook(7);
|
||||
|
||||
expect(embedding).not.toBeNull();
|
||||
expect(embedding).toHaveLength(256);
|
||||
const norm = Math.sqrt((embedding ?? []).reduce((sum, v) => sum + v * v, 0));
|
||||
expect(norm).toBeCloseTo(1, 10);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(updateSet).toHaveBeenCalledWith({ embedding });
|
||||
expect(updateWhere).toHaveBeenCalledTimes(1);
|
||||
expect(logger.debug).toHaveBeenCalledWith('Embedded book 7');
|
||||
|
||||
const again = service.buildVector({
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
authors: ['Andrzej Sapkowski'],
|
||||
genres: ['Fantasy'],
|
||||
tags: ['Sword & Sorcery'],
|
||||
});
|
||||
expect(again).toEqual(embedding);
|
||||
expect(embedderRepository.findSourceData).toHaveBeenCalledWith(7);
|
||||
expect(vectorizer.buildVector).toHaveBeenCalledWith(sourceData);
|
||||
expect(embedderRepository.saveEmbedding).toHaveBeenCalledWith(7, embedding);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [end] bookId=7'));
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('authorCount=1 genreCount=1 tagCount=1'));
|
||||
});
|
||||
|
||||
it('filters stopwords/short tokens and keeps zero vector when no usable content', () => {
|
||||
const service = new BookEmbedderService({} as never);
|
||||
it('logs fail and rethrows when reading source data fails', async () => {
|
||||
const { service, embedderRepository, vectorizer } = makeService();
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
embedderRepository.findSourceData.mockRejectedValue(new Error('database "offline"\n'));
|
||||
|
||||
const vec = service.buildVector({
|
||||
title: 'a the of',
|
||||
description: 'it is as to',
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
});
|
||||
await expect(service.embedBook(9)).rejects.toThrow('database "offline"');
|
||||
|
||||
expect(vec).toHaveLength(256);
|
||||
expect(vec.every((v) => v === 0)).toBe(true);
|
||||
expect(vectorizer.buildVector).not.toHaveBeenCalled();
|
||||
expect(embedderRepository.saveEmbedding).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [fail] bookId=9'));
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('errorClass=Error');
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/error="database\s+offline\s*"/);
|
||||
expect(warnSpy.mock.calls[0][0]).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('limits description contribution to first 100 words', () => {
|
||||
const service = new BookEmbedderService({} as never);
|
||||
it('logs fail and rethrows when persistence fails', async () => {
|
||||
const { service, embedderRepository, vectorizer } = makeService();
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const sourceData = makeSourceData({ authors: ['Andrzej Sapkowski', 'A. Author'] });
|
||||
const embedding = [0.3, 0.7];
|
||||
|
||||
const words = Array.from({ length: 130 }, (_, i) => `token${i}`);
|
||||
const longDescription = words.join(' ');
|
||||
embedderRepository.findSourceData.mockResolvedValue(sourceData);
|
||||
vectorizer.buildVector.mockReturnValue(embedding);
|
||||
embedderRepository.saveEmbedding.mockRejectedValue(new Error('write failed'));
|
||||
|
||||
const with130 = service.buildVector({
|
||||
title: null,
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
description: longDescription,
|
||||
});
|
||||
await expect(service.embedBook(15)).rejects.toThrow('write failed');
|
||||
|
||||
const with100 = service.buildVector({
|
||||
title: null,
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
description: words.slice(0, 100).join(' '),
|
||||
});
|
||||
|
||||
expect(with130).toEqual(with100);
|
||||
expect(vectorizer.buildVector).toHaveBeenCalledWith(sourceData);
|
||||
expect(embedderRepository.saveEmbedding).toHaveBeenCalledWith(15, embedding);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.embedding] [fail] bookId=15'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,178 +1,54 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema';
|
||||
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
|
||||
import { BookEmbedderRepository } from './book-embedder.repository';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
const DIMS = 256;
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'or',
|
||||
'of',
|
||||
'in',
|
||||
'to',
|
||||
'for',
|
||||
'is',
|
||||
'it',
|
||||
'on',
|
||||
'at',
|
||||
'by',
|
||||
'with',
|
||||
'as',
|
||||
'be',
|
||||
'this',
|
||||
'that',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'has',
|
||||
'have',
|
||||
'had',
|
||||
'not',
|
||||
'but',
|
||||
'from',
|
||||
'can',
|
||||
'all',
|
||||
'so',
|
||||
'if',
|
||||
'no',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'may',
|
||||
'might',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'been',
|
||||
'being',
|
||||
'into',
|
||||
'out',
|
||||
'up',
|
||||
'one',
|
||||
'two',
|
||||
'more',
|
||||
'most',
|
||||
'also',
|
||||
'just',
|
||||
'about',
|
||||
'after',
|
||||
'its',
|
||||
]);
|
||||
const EMBEDDING_EVENT = 'book.embedding';
|
||||
|
||||
@Injectable()
|
||||
export class BookEmbedderService {
|
||||
private readonly logger = new Logger(BookEmbedderService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
constructor(
|
||||
private readonly embedderRepository: BookEmbedderRepository,
|
||||
private readonly vectorizer: BookEmbeddingVectorizerService,
|
||||
) {}
|
||||
|
||||
async embedBook(bookId: number): Promise<number[] | null> {
|
||||
const [meta] = await this.db
|
||||
.select({
|
||||
title: bookMetadata.title,
|
||||
seriesName: bookMetadata.seriesName,
|
||||
publisher: bookMetadata.publisher,
|
||||
description: bookMetadata.description,
|
||||
})
|
||||
.from(bookMetadata)
|
||||
.where(eq(bookMetadata.bookId, bookId))
|
||||
.limit(1);
|
||||
const startedAt = Date.now();
|
||||
this.logger.debug(`[${EMBEDDING_EVENT}] [start] bookId=${bookId} - embedding started`);
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
const [authorRows, genreRows, tagRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId)),
|
||||
this.db
|
||||
.select({ name: genres.name })
|
||||
.from(bookGenres)
|
||||
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
|
||||
.where(eq(bookGenres.bookId, bookId)),
|
||||
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
|
||||
]);
|
||||
|
||||
const embedding = this.buildVector({
|
||||
title: meta.title,
|
||||
seriesName: meta.seriesName,
|
||||
publisher: meta.publisher,
|
||||
description: meta.description,
|
||||
authors: authorRows.map((r) => r.name),
|
||||
genres: genreRows.map((r) => r.name),
|
||||
tags: tagRows.map((r) => r.name),
|
||||
});
|
||||
|
||||
await this.db.update(bookMetadata).set({ embedding }).where(eq(bookMetadata.bookId, bookId));
|
||||
|
||||
this.logger.debug(`Embedded book ${bookId}`);
|
||||
return embedding;
|
||||
}
|
||||
|
||||
buildVector(params: {
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
seriesName: string | null;
|
||||
publisher: string | null;
|
||||
description: string | null;
|
||||
}): number[] {
|
||||
const acc = new Float64Array(DIMS);
|
||||
|
||||
const addExact = (text: string, weight: number) => {
|
||||
const key = text.toLowerCase().trim();
|
||||
if (key.length > 0) acc[this.fnv32(key) % DIMS] += weight;
|
||||
};
|
||||
|
||||
const addTokens = (text: string, weight: number) => {
|
||||
for (const token of this.tokenize(text)) {
|
||||
acc[this.fnv32(token) % DIMS] += weight;
|
||||
try {
|
||||
const sourceData = await this.embedderRepository.findSourceData(bookId);
|
||||
if (!sourceData) {
|
||||
this.logger.debug(
|
||||
`[${EMBEDDING_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=metadata_missing - embedding completed`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (params.seriesName) addExact(params.seriesName, 3.0);
|
||||
for (const author of params.authors) addExact(author, 1.0);
|
||||
for (const genre of params.genres) addExact(genre, 4.0);
|
||||
for (const tag of params.tags) addExact(tag, 3.5);
|
||||
if (params.title) addTokens(params.title, 3.0);
|
||||
if (params.publisher) addExact(params.publisher, 2.0);
|
||||
if (params.description) {
|
||||
addTokens(params.description.split(/\s+/).slice(0, 100).join(' '), 1.0);
|
||||
const embedding = this.vectorizer.buildVector(sourceData);
|
||||
await this.embedderRepository.saveEmbedding(bookId, embedding);
|
||||
|
||||
this.logger.debug(
|
||||
`[${EMBEDDING_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} authorCount=${sourceData.authors.length} genreCount=${sourceData.genres.length} tagCount=${sourceData.tags.length} - embedding completed`,
|
||||
);
|
||||
return embedding;
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
this.logger.warn(
|
||||
`[${EMBEDDING_EVENT}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${this.sanitizeErrorMessage(
|
||||
error,
|
||||
)}" - embedding failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.l2normalize(Array.from(acc));
|
||||
}
|
||||
|
||||
private tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
|
||||
}
|
||||
|
||||
private fnv32(s: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash ^= s.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private l2normalize(vec: number[]): number[] {
|
||||
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
|
||||
if (norm === 0) return vec;
|
||||
return vec.map((v) => v / norm);
|
||||
private sanitizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message.replace(/[\r\n"]/g, ' ').slice(0, 200);
|
||||
return String(error)
|
||||
.replace(/[\r\n"]/g, ' ')
|
||||
.slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { BookEmbeddingVectorizerService, EMBEDDING_DIMENSIONS } from './book-embedding-vectorizer.service';
|
||||
import { BookEmbeddingSourceData } from './book-embedding.types';
|
||||
|
||||
function makeSourceData(overrides: Partial<BookEmbeddingSourceData> = {}): BookEmbeddingSourceData {
|
||||
return {
|
||||
title: 'The Last Wish',
|
||||
seriesName: 'The Witcher',
|
||||
publisher: 'Orbit',
|
||||
description: 'A monster hunter travels the world',
|
||||
authors: ['Andrzej Sapkowski'],
|
||||
genres: ['Fantasy'],
|
||||
tags: ['Sword & Sorcery'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookEmbeddingVectorizerService', () => {
|
||||
it('produces deterministic normalized vectors', () => {
|
||||
const vectorizer = new BookEmbeddingVectorizerService();
|
||||
const sourceData = makeSourceData();
|
||||
|
||||
const vectorA = vectorizer.buildVector(sourceData);
|
||||
const vectorB = vectorizer.buildVector(sourceData);
|
||||
|
||||
expect(vectorA).toEqual(vectorB);
|
||||
expect(vectorA).toHaveLength(EMBEDDING_DIMENSIONS);
|
||||
const norm = Math.sqrt(vectorA.reduce((sum, value) => sum + value * value, 0));
|
||||
expect(norm).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it('keeps a zero vector when all text is stopwords or too short', () => {
|
||||
const vectorizer = new BookEmbeddingVectorizerService();
|
||||
|
||||
const vector = vectorizer.buildVector(
|
||||
makeSourceData({
|
||||
title: 'a the of',
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
description: 'it is as to',
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vector).toHaveLength(EMBEDDING_DIMENSIONS);
|
||||
expect(vector.every((value) => value === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('limits description contribution to the first 100 tokens', () => {
|
||||
const vectorizer = new BookEmbeddingVectorizerService();
|
||||
const words = Array.from({ length: 130 }, (_, index) => `token${index}`);
|
||||
const longDescription = words.join(' ');
|
||||
|
||||
const vectorWith130 = vectorizer.buildVector(
|
||||
makeSourceData({
|
||||
title: null,
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
description: longDescription,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const vectorWith100 = vectorizer.buildVector(
|
||||
makeSourceData({
|
||||
title: null,
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
description: words.slice(0, 100).join(' '),
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vectorWith130).toEqual(vectorWith100);
|
||||
});
|
||||
|
||||
it('keeps accented and non-latin tokens in the vector', () => {
|
||||
const vectorizer = new BookEmbeddingVectorizerService();
|
||||
const vector = vectorizer.buildVector(
|
||||
makeSourceData({
|
||||
title: 'Cien años de soledad Преступление и наказание',
|
||||
seriesName: null,
|
||||
publisher: null,
|
||||
description: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vector.some((value) => value !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores blank exact-match fields', () => {
|
||||
const vectorizer = new BookEmbeddingVectorizerService();
|
||||
|
||||
const vector = vectorizer.buildVector(
|
||||
makeSourceData({
|
||||
title: null,
|
||||
seriesName: ' ',
|
||||
publisher: '',
|
||||
description: null,
|
||||
authors: [' '],
|
||||
genres: [''],
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vector.every((value) => value === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BookEmbeddingSourceData } from './book-embedding.types';
|
||||
|
||||
export const EMBEDDING_DIMENSIONS = 256;
|
||||
|
||||
const MAX_DESCRIPTION_TOKENS = 100;
|
||||
const MIN_TOKEN_LENGTH = 2;
|
||||
|
||||
const FEATURE_WEIGHTS = {
|
||||
seriesName: 3.0,
|
||||
author: 1.0,
|
||||
genre: 4.0,
|
||||
tag: 3.5,
|
||||
title: 3.0,
|
||||
publisher: 2.0,
|
||||
description: 1.0,
|
||||
} as const;
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'or',
|
||||
'of',
|
||||
'in',
|
||||
'to',
|
||||
'for',
|
||||
'is',
|
||||
'it',
|
||||
'on',
|
||||
'at',
|
||||
'by',
|
||||
'with',
|
||||
'as',
|
||||
'be',
|
||||
'this',
|
||||
'that',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'has',
|
||||
'have',
|
||||
'had',
|
||||
'not',
|
||||
'but',
|
||||
'from',
|
||||
'can',
|
||||
'all',
|
||||
'so',
|
||||
'if',
|
||||
'no',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'may',
|
||||
'might',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'been',
|
||||
'being',
|
||||
'into',
|
||||
'out',
|
||||
'up',
|
||||
'one',
|
||||
'two',
|
||||
'more',
|
||||
'most',
|
||||
'also',
|
||||
'just',
|
||||
'about',
|
||||
'after',
|
||||
'its',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class BookEmbeddingVectorizerService {
|
||||
buildVector(params: BookEmbeddingSourceData): number[] {
|
||||
const accumulator = new Float64Array(EMBEDDING_DIMENSIONS);
|
||||
|
||||
const addExact = (text: string, weight: number) => {
|
||||
const normalized = text.toLowerCase().trim();
|
||||
if (normalized.length === 0) return;
|
||||
accumulator[this.fnv32(normalized) % EMBEDDING_DIMENSIONS] += weight;
|
||||
};
|
||||
|
||||
const addTokens = (text: string, weight: number, maxTokens = Number.POSITIVE_INFINITY) => {
|
||||
for (const token of this.tokenize(text, maxTokens)) {
|
||||
accumulator[this.fnv32(token) % EMBEDDING_DIMENSIONS] += weight;
|
||||
}
|
||||
};
|
||||
|
||||
if (params.seriesName) addExact(params.seriesName, FEATURE_WEIGHTS.seriesName);
|
||||
for (const authorName of params.authors) addExact(authorName, FEATURE_WEIGHTS.author);
|
||||
for (const genreName of params.genres) addExact(genreName, FEATURE_WEIGHTS.genre);
|
||||
for (const tagName of params.tags) addExact(tagName, FEATURE_WEIGHTS.tag);
|
||||
if (params.title) addTokens(params.title, FEATURE_WEIGHTS.title);
|
||||
if (params.publisher) addExact(params.publisher, FEATURE_WEIGHTS.publisher);
|
||||
if (params.description) addTokens(params.description, FEATURE_WEIGHTS.description, MAX_DESCRIPTION_TOKENS);
|
||||
|
||||
return this.l2normalize(Array.from(accumulator));
|
||||
}
|
||||
|
||||
private tokenize(text: string, maxTokens: number): string[] {
|
||||
const tokens: string[] = [];
|
||||
|
||||
for (const match of text.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
|
||||
const token = match[0];
|
||||
if (token.length < MIN_TOKEN_LENGTH || STOPWORDS.has(token)) continue;
|
||||
|
||||
tokens.push(token);
|
||||
if (tokens.length >= maxTokens) break;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private fnv32(text: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private l2normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
if (norm === 0) return vector;
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface BookEmbeddingSourceData {
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
seriesName: string | null;
|
||||
publisher: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
|
||||
import { BookEmbedderRepository } from './book-embedder.repository';
|
||||
import { BookEmbedderService } from './book-embedder.service';
|
||||
import { EmbeddingModule } from './embedding.module';
|
||||
|
||||
describe('EmbeddingModule', () => {
|
||||
it('registers and exports BookEmbedderService', () => {
|
||||
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, EmbeddingModule);
|
||||
const exportsMeta = Reflect.getMetadata(MODULE_METADATA.EXPORTS, EmbeddingModule);
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: DB, useValue: {} }],
|
||||
exports: [DB],
|
||||
})
|
||||
class DbTestModule {}
|
||||
|
||||
expect(providers).toEqual(expect.arrayContaining([BookEmbedderService]));
|
||||
expect(exportsMeta).toEqual(expect.arrayContaining([BookEmbedderService]));
|
||||
describe('EmbeddingModule', () => {
|
||||
it('wires embedding providers in a Nest module', async () => {
|
||||
const testingModule = await Test.createTestingModule({
|
||||
imports: [DbTestModule, EmbeddingModule],
|
||||
}).compile();
|
||||
|
||||
expect(testingModule.get(BookEmbedderService)).toBeInstanceOf(BookEmbedderService);
|
||||
expect(testingModule.get(BookEmbedderRepository)).toBeInstanceOf(BookEmbedderRepository);
|
||||
expect(testingModule.get(BookEmbeddingVectorizerService)).toBeInstanceOf(BookEmbeddingVectorizerService);
|
||||
|
||||
await testingModule.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BookEmbeddingVectorizerService } from './book-embedding-vectorizer.service';
|
||||
import { BookEmbedderRepository } from './book-embedder.repository';
|
||||
import { BookEmbedderService } from './book-embedder.service';
|
||||
|
||||
@Module({
|
||||
providers: [BookEmbedderService],
|
||||
providers: [BookEmbedderService, BookEmbedderRepository, BookEmbeddingVectorizerService],
|
||||
exports: [BookEmbedderService],
|
||||
})
|
||||
export class EmbeddingModule {}
|
||||
|
||||
Reference in New Issue
Block a user