feat(api): refactor to use repository pattern to manage database operations

- Added FOLDERS constant to centralize folder names for better type safety and consistency across the application.
- Refactored folder usage in various services and entities to utilize the new FOLDERS constant instead of magic strings.
- Updated E2E tests to reflect changes in folder references, ensuring accurate testing of library item and highlight functionalities.
This commit is contained in:
Timothy Atapagra 2025-10-18 00:55:19 -04:00
parent da6850a7f4
commit 82b4de5695
26 changed files with 1274 additions and 489 deletions

View file

@ -1,7 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Repository, DataSource } from 'typeorm'
import { Filter } from '../filter/entities/filter.entity'
import { FOLDERS } from '../constants/folders.constants'
import { seedLibraryItems } from '../database/seeds/library-items.seed'
import { ConfigService } from '@nestjs/config'
export interface ProvisionOptions {
client?: string
@ -15,8 +18,16 @@ export class DefaultUserResourcesService {
constructor(
@InjectRepository(Filter)
private readonly filterRepository: Repository<Filter>,
private readonly dataSource: DataSource,
private readonly configService: ConfigService,
) {}
/**
* Provision default resources for a new user
* Creates default filters and optionally seeds example library items
* @param userId - The user ID to provision resources for
* @param options - Provisioning options (client, username)
*/
async provisionForUser(
userId: string,
options: ProvisionOptions,
@ -26,6 +37,7 @@ export class DefaultUserResourcesService {
try {
await this.createDefaultFilters(userId)
await this.addPopularReads(userId, options.client)
await this.seedExampleLibraryItems(userId)
} catch (error) {
this.logger.error(
`Failed to provision resources for user ${userId}`,
@ -56,7 +68,7 @@ export class DefaultUserResourcesService {
category: 'Search',
defaultFilter: true,
visible: true,
folder: 'inbox',
folder: FOLDERS.INBOX,
}))
try {
@ -127,4 +139,33 @@ export class DefaultUserResourcesService {
// Don't throw - this shouldn't block user creation
}
}
/**
* Seed example library items for new users in non-production environments
* Helps new users understand the app with sample content
* @param userId - The user ID to seed items for
* @private
*/
private async seedExampleLibraryItems(userId: string): Promise<void> {
const nodeEnv = this.configService.get<string>('NODE_ENV')
const shouldSeed = nodeEnv !== 'production' && nodeEnv !== 'test'
if (!shouldSeed) {
this.logger.debug(
`Skipping library items seed for user ${userId} (env: ${nodeEnv})`,
)
return
}
try {
await seedLibraryItems(this.dataSource, userId)
this.logger.debug(`Seeded example library items for user ${userId}`)
} catch (error) {
this.logger.warn(
`Failed to seed library items for user ${userId}`,
error,
)
// Don't throw - this is optional and shouldn't block user creation
}
}
}

View file

@ -1,7 +1,11 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'
import {
Injectable,
UnauthorizedException,
NotFoundException,
BadRequestException,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { ConfigService } from '@nestjs/config'
import { DataSource } from 'typeorm'
import { StructuredLogger } from '../../logging/structured-logger.service'
import { UserService } from '../../user/user.service'
import { User, StatusType } from '../../user/entities/user.entity'
@ -13,7 +17,6 @@ import { NotificationClient } from '../interfaces/notification-client.interface'
import { AnalyticsService } from '../../analytics/analytics.service'
import { PubSubService } from '../../pubsub/pubsub.service'
import { IntercomService } from '../../integrations/intercom.service'
import { seedLibraryItems } from '../../database/seeds/library-items.seed'
import {
LoginSuccessResponse,
RegisterSuccessWithLoginResponse,
@ -34,7 +37,6 @@ export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
private userService: UserService,
private emailVerificationService: EmailVerificationService,
private defaultResources: DefaultUserResourcesService,
@ -47,10 +49,21 @@ export class AuthService {
this.logger.setContext({ operation: 'auth' })
}
/**
* Validate user credentials for authentication
* @param email - User's email address
* @param password - User's password (plaintext)
* @returns User entity if credentials are valid, null otherwise
*/
async validateUser(email: string, password: string): Promise<User | null> {
return this.userService.validateCredentials(email, password)
}
/**
* Validate a JWT token and retrieve the associated user
* @param token - JWT token (with or without 'Bearer ' prefix)
* @returns User entity if token is valid, null otherwise
*/
async validateToken(token: string): Promise<User | null> {
try {
// Remove 'Bearer ' prefix if present
@ -75,6 +88,13 @@ export class AuthService {
}
}
/**
* Generate JWT token and login response for a user
* Validates user status and tracks login analytics
* @param user - User entity to login
* @returns Login response with access token and user data
* @throws UnauthorizedException if user account is not active
*/
async login(user: User): Promise<LoginSuccessResponse> {
this.logger
.withContext({ userId: user.id, email: user.email })
@ -132,6 +152,13 @@ export class AuthService {
}
}
/**
* Register a new user account with complete user provisioning
* Creates user, profile, default resources, and triggers analytics/notifications
* Returns either immediate login or email verification response based on config
* @param registerDto - User registration data (email, password, name, etc.)
* @returns Login response or email verification response
*/
async register(
registerDto: RegisterDto,
): Promise<
@ -146,21 +173,11 @@ export class AuthService {
const result = await this.userService.registerUserComplete(registerDto)
// Provision default resources for the new user
// This includes default filters and example library items (in non-production envs)
await this.defaultResources.provisionForUser(result.user.id, {
username: result.profile.username,
})
// Seed example library items in development (but not in test)
const nodeEnv = this.configService.get<string>('NODE_ENV')
const shouldSeed = nodeEnv !== 'production' && nodeEnv !== 'test'
if (shouldSeed) {
try {
await seedLibraryItems(this.dataSource, result.user.id)
} catch (error) {
this.logger.warn('Failed to seed library items', { error })
}
}
// Analytics: Track user creation
this.analytics.trackUserCreated(
result.user.id,
@ -234,6 +251,11 @@ export class AuthService {
return this.login(result.user)
}
/**
* Generate a new access token for an authenticated user
* @param user - User entity to generate token for
* @returns New access token and expiration time
*/
async refreshToken(user: User) {
const role = user.role ?? 'user'
@ -253,10 +275,22 @@ export class AuthService {
}
}
/**
* Find a user by their ID
* @param id - User ID
* @returns User entity if found, null otherwise
*/
async findUserById(id: string): Promise<User | null> {
return this.userService.findById(id)
}
/**
* Confirm a user's email address using a verification token
* Activates pending user accounts and returns login response
* @param token - Email verification token
* @returns Login response with access token
* @throws NotFoundException if user not found
*/
async confirmEmail(token: string) {
const payload = await this.emailVerificationService.verifyToken(token, {
consume: true,
@ -264,7 +298,7 @@ export class AuthService {
const user = await this.userService.findById(payload.userId)
if (!user) {
throw new Error('USER_NOT_FOUND')
throw new NotFoundException('User not found')
}
if (user.status === StatusType.PENDING) {
@ -286,14 +320,23 @@ export class AuthService {
return this.login(user)
}
/**
* Resend email verification for a pending user account
* @param email - User's email address
* @returns Success response
* @throws NotFoundException if user not found
* @throws BadRequestException if user already verified
*/
async resendVerification(email: string) {
const user = await this.userService.findByEmail(email.trim().toLowerCase())
if (!user) {
throw new Error('USER_NOT_FOUND')
throw new NotFoundException('User not found')
}
if (user.status !== StatusType.PENDING) {
throw new Error('USER_ALREADY_VERIFIED')
throw new BadRequestException(
'Email already verified. Please login to continue.',
)
}
const verificationToken =

View file

@ -10,6 +10,7 @@ import { GroupMembership } from '../group/entities/group-membership.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { Label } from '../label/entities/label.entity'
import { EntityLabel } from '../label/entities/entity-label.entity'
import { HighlightEntity } from '../highlight/entities/highlight.entity'
export const testDatabaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
@ -29,6 +30,7 @@ export const testDatabaseConfig: TypeOrmModuleOptions = {
LibraryItemEntity,
Label,
EntityLabel,
HighlightEntity,
],
synchronize: false,
logging: false,

View file

@ -0,0 +1,41 @@
/**
* Folder constants for library items
* Use these constants instead of magic strings to ensure type safety and consistency
*/
export const FOLDERS = {
INBOX: 'inbox',
ARCHIVE: 'archive',
TRASH: 'trash',
ALL: 'all', // Virtual folder for viewing all items
} as const
// Type-safe folder name from const assertion
export type FolderName = (typeof FOLDERS)[keyof typeof FOLDERS]
// Array of valid folder names (excluding 'all' which is a virtual folder)
export const VALID_FOLDERS = [
FOLDERS.INBOX,
FOLDERS.ARCHIVE,
FOLDERS.TRASH,
] as const
// Array of all folder names including virtual folders
export const ALL_FOLDERS = [
FOLDERS.INBOX,
FOLDERS.ARCHIVE,
FOLDERS.TRASH,
FOLDERS.ALL,
] as const
// Helper function to check if a string is a valid folder
export function isValidFolder(folder: string): folder is FolderName {
return ALL_FOLDERS.includes(folder as FolderName)
}
// Helper function to check if a string is a valid physical folder (not virtual)
export function isPhysicalFolder(
folder: string,
): folder is (typeof VALID_FOLDERS)[number] {
return VALID_FOLDERS.includes(folder as (typeof VALID_FOLDERS)[number])
}

View file

@ -10,6 +10,7 @@ import { GroupMembership } from '../group/entities/group-membership.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { Label } from '../label/entities/label.entity'
import { EntityLabel } from '../label/entities/entity-label.entity'
import { HighlightEntity } from '../highlight/entities/highlight.entity'
@Module({
imports: [
@ -44,6 +45,7 @@ import { EntityLabel } from '../label/entities/entity-label.entity'
LibraryItemEntity,
Label,
EntityLabel,
HighlightEntity,
],
// Migration configuration

View file

@ -5,6 +5,7 @@ import {
LibraryItemState,
ContentReaderType,
} from '../../library/entities/library-item.entity'
import { FOLDERS } from '../../constants/folders.constants'
/**
* Seed example library items for a user (for testing)
@ -29,7 +30,7 @@ export async function seedLibraryItems(
'Learn how to build scalable, maintainable applications with NestJS framework',
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
wordCount: 2500,
siteName: 'NestJS Docs',
@ -47,7 +48,7 @@ export async function seedLibraryItems(
'Modern best practices for designing and implementing GraphQL APIs',
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
wordCount: 3200,
siteName: 'GraphQL.org',
@ -66,7 +67,7 @@ export async function seedLibraryItems(
'Deep dive into React Server Components and their impact on modern web applications',
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
wordCount: 4100,
siteName: 'React.dev',
@ -85,7 +86,7 @@ export async function seedLibraryItems(
'New features and improvements in TypeScript 5.8 release',
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
itemType: 'ARTICLE',
wordCount: 1800,
siteName: 'TypeScript Blog',
@ -104,7 +105,7 @@ export async function seedLibraryItems(
'Comprehensive guide to optimizing PostgreSQL database performance',
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
wordCount: 5400,
siteName: 'PostgreSQL Docs',

View file

@ -1,12 +1,12 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { HighlightEntity } from './entities/highlight.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { HighlightService } from './highlight.service'
import { HighlightResolver } from './highlight.resolver'
import { RepositoriesModule } from '../repositories/repositories.module'
@Module({
imports: [TypeOrmModule.forFeature([HighlightEntity, LibraryItemEntity])],
imports: [
RepositoriesModule, // Access to IHighlightRepository and ILibraryItemRepository
],
providers: [HighlightService, HighlightResolver],
exports: [HighlightService],
})

View file

@ -3,22 +3,22 @@ import {
NotFoundException,
BadRequestException,
Logger,
Inject,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { HighlightEntity, HighlightType } from './entities/highlight.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { CreateHighlightInput, UpdateHighlightInput } from './dto/highlight-inputs.type'
import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface'
import { IHighlightRepository } from '../repositories/interfaces/highlight-repository.interface'
@Injectable()
export class HighlightService {
private readonly logger = new Logger(HighlightService.name)
constructor(
@InjectRepository(HighlightEntity)
private readonly highlightRepository: Repository<HighlightEntity>,
@InjectRepository(LibraryItemEntity)
private readonly libraryItemRepository: Repository<LibraryItemEntity>,
@Inject('IHighlightRepository')
private readonly highlightRepository: IHighlightRepository,
@Inject('ILibraryItemRepository')
private readonly libraryItemRepository: ILibraryItemRepository,
) {}
/**
@ -29,9 +29,10 @@ export class HighlightService {
libraryItemId: string,
): Promise<HighlightEntity[]> {
// Verify the library item belongs to the user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: libraryItemId, userId },
})
const libraryItem = await this.libraryItemRepository.findById(
libraryItemId,
userId,
)
if (!libraryItem) {
throw new NotFoundException(
@ -39,27 +40,15 @@ export class HighlightService {
)
}
return this.highlightRepository.find({
where: {
libraryItemId,
userId,
},
order: {
highlightPositionPercent: 'ASC',
},
})
// Delegate to repository for data access
return this.highlightRepository.findByLibraryItem(libraryItemId, userId)
}
/**
* Get a single highlight by ID
*/
async findById(userId: string, id: string): Promise<HighlightEntity | null> {
return this.highlightRepository.findOne({
where: {
id,
userId,
},
})
return this.highlightRepository.findById(id, userId)
}
/**
@ -70,9 +59,10 @@ export class HighlightService {
input: CreateHighlightInput,
): Promise<HighlightEntity> {
// Verify the library item exists and belongs to the user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: input.libraryItemId, userId },
})
const libraryItem = await this.libraryItemRepository.findById(
input.libraryItemId,
userId,
)
if (!libraryItem) {
throw new NotFoundException(

View file

@ -1,13 +1,12 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { Label } from './entities/label.entity'
import { EntityLabel } from './entities/entity-label.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { LabelService } from './label.service'
import { LabelResolver } from './label.resolver'
import { RepositoriesModule } from '../repositories/repositories.module'
@Module({
imports: [TypeOrmModule.forFeature([Label, EntityLabel, LibraryItemEntity])],
imports: [
RepositoriesModule, // Access to ILabelRepository, IEntityLabelRepository, and ILibraryItemRepository
],
providers: [LabelService, LabelResolver],
exports: [LabelService],
})

View file

@ -3,42 +3,37 @@ import {
NotFoundException,
ConflictException,
BadRequestException,
Inject,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Label } from './entities/label.entity'
import { EntityLabel } from './entities/entity-label.entity'
import { CreateLabelInput, UpdateLabelInput } from './dto/label-inputs.type'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface'
import { ILabelRepository } from '../repositories/interfaces/label-repository.interface'
import { IEntityLabelRepository } from '../repositories/interfaces/entity-label-repository.interface'
@Injectable()
export class LabelService {
constructor(
@InjectRepository(Label)
private readonly labelRepository: Repository<Label>,
@InjectRepository(EntityLabel)
private readonly entityLabelRepository: Repository<EntityLabel>,
@InjectRepository(LibraryItemEntity)
private readonly libraryItemRepository: Repository<LibraryItemEntity>,
@Inject('ILabelRepository')
private readonly labelRepository: ILabelRepository,
@Inject('IEntityLabelRepository')
private readonly entityLabelRepository: IEntityLabelRepository,
@Inject('ILibraryItemRepository')
private readonly libraryItemRepository: ILibraryItemRepository,
) {}
/**
* Get all labels for a user
*/
async findAll(userId: string): Promise<Label[]> {
return this.labelRepository.find({
where: { userId },
order: { position: 'ASC' },
})
return this.labelRepository.findAll(userId)
}
/**
* Get a single label by ID
*/
async findOne(userId: string, labelId: string): Promise<Label> {
const label = await this.labelRepository.findOne({
where: { id: labelId, userId },
})
const label = await this.labelRepository.findById(labelId, userId)
if (!label) {
throw new NotFoundException(`Label with ID ${labelId} not found`)
@ -52,9 +47,7 @@ export class LabelService {
*/
async create(userId: string, input: CreateLabelInput): Promise<Label> {
// Check for duplicate label name
const existing = await this.labelRepository.findOne({
where: { userId, name: input.name },
})
const existing = await this.labelRepository.findByName(input.name, userId)
if (existing) {
throw new ConflictException(
@ -89,9 +82,7 @@ export class LabelService {
// If updating name, check for duplicates
if (input.name && input.name !== label.name) {
const existing = await this.labelRepository.findOne({
where: { userId, name: input.name },
})
const existing = await this.labelRepository.findByName(input.name, userId)
if (existing) {
throw new ConflictException(
@ -132,9 +123,10 @@ export class LabelService {
labelIds: string[],
): Promise<Label[]> {
// Verify library item exists and belongs to user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: libraryItemId, userId },
})
const libraryItem = await this.libraryItemRepository.findById(
libraryItemId,
userId,
)
if (!libraryItem) {
throw new NotFoundException(
@ -145,9 +137,7 @@ export class LabelService {
// Verify all labels belong to the user
let labels: Label[] = []
if (labelIds.length > 0) {
labels = await this.labelRepository.find({
where: labelIds.map((id) => ({ id, userId })),
})
labels = await this.labelRepository.findByIds(labelIds, userId)
if (labels.length !== labelIds.length) {
throw new NotFoundException(
@ -157,7 +147,7 @@ export class LabelService {
}
// Remove existing labels for this library item
await this.entityLabelRepository.delete({ libraryItemId })
await this.entityLabelRepository.deleteByLibraryItemId(libraryItemId)
// Add new labels
if (labelIds.length > 0) {
@ -181,10 +171,7 @@ export class LabelService {
return []
}
return this.labelRepository.find({
where: labelIds.map((id) => ({ id, userId })),
order: { position: 'ASC' },
})
return this.labelRepository.findByIds(labelIds, userId)
}
/**
@ -194,10 +181,8 @@ export class LabelService {
userId: string,
libraryItemId: string,
): Promise<Label[]> {
const entityLabels = await this.entityLabelRepository.find({
where: { libraryItemId },
relations: ['label'],
})
const entityLabels =
await this.entityLabelRepository.findByLibraryItemId(libraryItemId)
// Filter to only return labels owned by the user
const labels = entityLabels

View file

@ -18,6 +18,7 @@ import {
IsUrl,
} from 'class-validator'
import { LibraryItemState } from '../entities/library-item.entity'
import { FOLDERS, ALL_FOLDERS, VALID_FOLDERS } from '../../constants/folders.constants'
/**
* Sort field options for library items
@ -114,7 +115,7 @@ export class DeleteResult {
export class MoveToFolderInput {
@Field(() => String, { description: 'Target folder (inbox, archive, trash)' })
@IsString()
@IsIn(['inbox', 'archive', 'trash', 'all'])
@IsIn([...ALL_FOLDERS])
folder: string
}
@ -138,7 +139,7 @@ export class LibrarySearchInput {
})
@IsOptional()
@IsString()
@IsIn(['inbox', 'archive', 'trash', 'all'])
@IsIn([...ALL_FOLDERS])
folder?: string
@Field(() => LibraryItemState, {
@ -187,12 +188,12 @@ export class SaveUrlInput {
@Field(() => String, {
nullable: true,
defaultValue: 'inbox',
defaultValue: FOLDERS.INBOX,
description: 'Folder to save the URL to (inbox, archive)',
})
@IsOptional()
@IsString()
@IsIn(['inbox', 'archive'])
@IsIn([FOLDERS.INBOX, FOLDERS.ARCHIVE])
folder?: string
@Field(() => String, {

View file

@ -1,15 +1,14 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { LibraryResolver } from './library.resolver'
import { LibraryService } from './library.service'
import { LibraryController } from './library.controller'
import { LibraryItemEntity } from './entities/library-item.entity'
import { LabelModule } from '../label/label.module'
import { QueueModule } from '../queue/queue.module'
import { RepositoriesModule } from '../repositories/repositories.module'
@Module({
imports: [
TypeOrmModule.forFeature([LibraryItemEntity]),
RepositoriesModule, // Access to ILibraryItemRepository
LabelModule,
QueueModule,
],

View file

@ -4,9 +4,8 @@ import {
BadRequestException,
Logger,
ConflictException,
Inject,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository, DataSource } from 'typeorm'
import {
LibraryItemEntity,
LibraryItemState,
@ -14,112 +13,50 @@ import {
import {
ReadingProgressInput,
LibrarySearchInput,
LibrarySortField,
SortOrder,
SaveUrlInput,
} from './dto/library-inputs.type'
import { EventBusService } from '../queue/event-bus.service'
import { EVENT_NAMES } from '../queue/events.constants'
import { JOB_PRIORITY } from '../queue/queue.constants'
import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface'
import { FOLDERS, VALID_FOLDERS } from '../constants/folders.constants'
@Injectable()
export class LibraryService {
private readonly logger = new Logger(LibraryService.name)
constructor(
@InjectRepository(LibraryItemEntity)
private readonly libraryRepository: Repository<LibraryItemEntity>,
private readonly dataSource: DataSource,
@Inject('ILibraryItemRepository')
private readonly libraryRepository: ILibraryItemRepository,
private readonly eventBus: EventBusService,
) {}
/**
* List library items for a user with pagination and optional filtering
* @param userId - User ID to fetch items for
* @param first - Number of items to fetch (pagination limit)
* @param after - Cursor for pagination (optional)
* @param search - Search and filter criteria (optional)
* @returns Paginated list of library items with next cursor
*/
async listForUser(
userId: string,
first: number,
after?: string,
search?: LibrarySearchInput,
): Promise<{ items: LibraryItemEntity[]; nextCursor: string | null }> {
const limit = Math.min(Math.max(first, 1), 100)
const query = this.libraryRepository
.createQueryBuilder('item')
.where('item.userId = :userId', { userId })
// Apply folder filter
if (search?.folder && search.folder !== 'all') {
query.andWhere('item.folder = :folder', { folder: search.folder })
}
// Apply state filter
if (search?.state) {
query.andWhere('item.state = :state', { state: search.state })
}
// Apply full-text search
if (search?.query && search.query.trim()) {
const searchTerm = `%${search.query.trim()}%`
query.andWhere(
'(item.title ILIKE :searchTerm OR item.description ILIKE :searchTerm OR item.author ILIKE :searchTerm)',
{ searchTerm },
)
}
// Apply label filter
if (search?.labels && search.labels.length > 0) {
query.andWhere('item.labelNames && :labels', { labels: search.labels })
}
// Determine sort field and order
const sortBy = search?.sortBy || LibrarySortField.SAVED_AT
const sortOrder = search?.sortOrder || SortOrder.DESC
// Map sort field to column name
const sortColumn = `item.${sortBy}`
query.orderBy(sortColumn, sortOrder).take(limit + 1)
// Handle cursor-based pagination
if (after) {
// For cursor pagination, we need to use the sort field
const cursorDate = new Date(after)
if (!Number.isNaN(cursorDate.getTime())) {
const operator = sortOrder === SortOrder.DESC ? '<' : '>'
query.andWhere(`${sortColumn} ${operator} :cursor`, {
cursor: cursorDate,
})
}
}
const rows = await query.getMany()
const hasNext = rows.length > limit
const sliced = hasNext ? rows.slice(0, limit) : rows
// Generate next cursor based on sort field
let nextCursor: string | null = null
if (hasNext && sliced.length > 0) {
const lastItem = sliced[sliced.length - 1]
const cursorField = sortBy as keyof LibraryItemEntity
const cursorValue = lastItem[cursorField]
if (cursorValue instanceof Date) {
nextCursor = cursorValue.toISOString()
} else if (typeof cursorValue === 'string') {
nextCursor = cursorValue
} else {
nextCursor = lastItem.savedAt.toISOString()
}
}
return { items: sliced, nextCursor }
// Delegate to repository - all query logic is now in the repository layer
return this.libraryRepository.listForUser(userId, first, after, search)
}
/**
* Find a library item by ID for a specific user
* @param userId - User ID who owns the item
* @param id - Library item ID
* @returns Library item or null if not found
*/
async findById(userId: string, id: string): Promise<LibraryItemEntity | null> {
return this.libraryRepository.findOne({
where: {
id,
userId,
},
})
return this.libraryRepository.findById(id, userId)
}
/**
@ -142,10 +79,9 @@ export class LibraryService {
// Update state and folder based on archive status
item.state = archived ? LibraryItemState.ARCHIVED : LibraryItemState.SUCCEEDED
item.folder = archived ? 'archive' : 'inbox'
item.folder = archived ? FOLDERS.ARCHIVE : FOLDERS.INBOX
await this.libraryRepository.save(item)
return item
return await this.libraryRepository.save(item)
}
/**
@ -165,7 +101,7 @@ export class LibraryService {
}
// If already in trash, perform hard delete (mark as DELETED)
if (item.folder === 'trash') {
if (item.folder === FOLDERS.TRASH) {
item.state = LibraryItemState.DELETED
await this.libraryRepository.save(item)
return {
@ -176,7 +112,7 @@ export class LibraryService {
}
// Otherwise, soft delete by moving to trash
item.folder = 'trash'
item.folder = FOLDERS.TRASH
item.state = LibraryItemState.DELETED
await this.libraryRepository.save(item)
@ -265,10 +201,9 @@ export class LibraryService {
}
// Validate folder
const validFolders = ['inbox', 'archive', 'trash']
if (!validFolders.includes(folder)) {
if (!VALID_FOLDERS.includes(folder as any)) {
throw new BadRequestException(
`Invalid folder. Must be one of: ${validFolders.join(', ')}`,
`Invalid folder. Must be one of: ${VALID_FOLDERS.join(', ')}`,
)
}
@ -276,11 +211,11 @@ export class LibraryService {
item.folder = folder
// Update state based on folder
if (folder === 'archive') {
if (folder === FOLDERS.ARCHIVE) {
item.state = LibraryItemState.ARCHIVED
} else if (folder === 'trash') {
} else if (folder === FOLDERS.TRASH) {
item.state = LibraryItemState.DELETED
} else if (folder === 'inbox') {
} else if (folder === FOLDERS.INBOX) {
item.state = LibraryItemState.SUCCEEDED
}
@ -321,61 +256,8 @@ export class LibraryService {
)
}
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches for better performance
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
// Update items that belong to the user
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
state: archived
? LibraryItemState.ARCHIVED
: LibraryItemState.SUCCEEDED,
folder: archived ? 'archive' : 'inbox',
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
// Track which items failed
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(
`Failed to ${archived ? 'archive' : 'unarchive'} items: ${failedIds.join(', ')}`,
)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully ${archived ? 'archived' : 'unarchived'} ${successCount} item(s)`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
// Delegate to repository - transaction handling and batch processing in repository
return this.libraryRepository.bulkArchive(userId, itemIds, archived)
}
/**
@ -407,56 +289,8 @@ export class LibraryService {
)
}
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
// Mark items as deleted (soft delete)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
state: LibraryItemState.DELETED,
folder: 'trash',
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(`Failed to delete items: ${failedIds.join(', ')}`)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully deleted ${successCount} item(s)`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
// Delegate to repository
return this.libraryRepository.bulkDelete(userId, itemIds)
}
/**
@ -483,10 +317,9 @@ export class LibraryService {
}
// Validate folder
const validFolders = ['inbox', 'archive', 'trash']
if (!validFolders.includes(folder)) {
if (!VALID_FOLDERS.includes(folder as any)) {
throw new BadRequestException(
`Invalid folder. Must be one of: ${validFolders.join(', ')}`,
`Invalid folder. Must be one of: ${VALID_FOLDERS.join(', ')}`,
)
}
@ -498,67 +331,8 @@ export class LibraryService {
)
}
// Determine state based on folder
let state: LibraryItemState
if (folder === 'archive') {
state = LibraryItemState.ARCHIVED
} else if (folder === 'trash') {
state = LibraryItemState.DELETED
} else {
state = LibraryItemState.SUCCEEDED
}
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
folder,
state,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(
`Failed to move items to ${folder}: ${failedIds.join(', ')}`,
)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully moved ${successCount} item(s) to ${folder}`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
// Delegate to repository
return this.libraryRepository.bulkMoveToFolder(userId, itemIds, folder)
}
/**
@ -590,76 +364,29 @@ export class LibraryService {
)
}
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
readAt: new Date(),
readingProgressTopPercent: 100,
readingProgressBottomPercent: 100,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(`Failed to mark items as read: ${failedIds.join(', ')}`)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully marked ${successCount} item(s) as read`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
// Delegate to repository
return this.libraryRepository.bulkMarkAsRead(userId, itemIds)
}
/**
* Save a URL to the user's library
* Save a URL to the user's library and trigger background content extraction
* Creates a library item in CONTENT_NOT_FETCHED state and dispatches an event
* to the content processing queue for background extraction.
* @param userId - User ID who is saving the URL
* @param input - URL save request input (url, folder, source)
* @returns Created library item
* @throws ConflictException if URL already exists in user's library
*/
async saveUrl(
userId: string,
input: SaveUrlInput,
): Promise<LibraryItemEntity> {
const { url, folder = 'inbox' } = input
const { url, folder = FOLDERS.INBOX } = input
this.logger.log(`Saving URL for user ${userId}: ${url}`)
// Check for duplicate URL
const existingItem = await this.libraryRepository.findOne({
where: {
userId,
originalUrl: url,
},
})
const existingItem = await this.libraryRepository.findByUrl(url, userId)
if (existingItem) {
throw new ConflictException(
@ -732,7 +459,12 @@ export class LibraryService {
}
/**
* Generate a slug from URL
* Generate a unique, URL-safe slug from a URL
* Extracts meaningful parts from the URL pathname and adds a timestamp
* to ensure uniqueness across all library items.
* @param url - The URL to generate a slug from
* @returns A unique, URL-safe slug (max 100 chars + timestamp)
* @private
*/
private generateSlug(url: string): string {
try {

View file

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { EntityLabel } from '../label/entities/entity-label.entity'
import { IEntityLabelRepository } from './interfaces/entity-label-repository.interface'
/**
* TypeORM implementation of the IEntityLabelRepository interface
* Handles all data access operations for entity labels (library item <-> label relationships)
*/
@Injectable()
export class EntityLabelRepository implements IEntityLabelRepository {
constructor(
@InjectRepository(EntityLabel)
private readonly repository: Repository<EntityLabel>,
) {}
/**
* Find entity labels for a library item with label relations loaded
*/
async findByLibraryItemId(libraryItemId: string): Promise<EntityLabel[]> {
return this.repository.find({
where: { libraryItemId },
relations: ['label'],
})
}
/**
* Delete all entity labels for a library item
*/
async deleteByLibraryItemId(libraryItemId: string): Promise<void> {
await this.repository.delete({ libraryItemId })
}
/**
* Create a new entity label instance (without saving to database)
*/
create(data: Partial<EntityLabel>): EntityLabel {
return this.repository.create(data)
}
/**
* Save (create or update) entity labels in bulk
*/
async save(entityLabels: EntityLabel[]): Promise<EntityLabel[]> {
return this.repository.save(entityLabels)
}
}

View file

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { HighlightEntity } from '../highlight/entities/highlight.entity'
import { IHighlightRepository } from './interfaces/highlight-repository.interface'
/**
* TypeORM implementation of the IHighlightRepository interface
* Handles all data access operations for highlights
*/
@Injectable()
export class HighlightRepository implements IHighlightRepository {
constructor(
@InjectRepository(HighlightEntity)
private readonly repository: Repository<HighlightEntity>,
) {}
/**
* Find a highlight by ID and user ID
*/
async findById(
id: string,
userId: string,
): Promise<HighlightEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
})
}
/**
* Find all highlights for a library item
* Sorted by position (reading order)
*/
async findByLibraryItem(
libraryItemId: string,
userId: string,
): Promise<HighlightEntity[]> {
return this.repository.find({
where: {
libraryItemId,
userId,
},
order: {
highlightPositionPercent: 'ASC',
},
})
}
/**
* Create a new highlight instance (without saving to database)
*/
create(data: Partial<HighlightEntity>): HighlightEntity {
return this.repository.create(data)
}
/**
* Save (create or update) a highlight
*/
async save(highlight: HighlightEntity): Promise<HighlightEntity> {
return this.repository.save(highlight)
}
/**
* Remove a highlight from the database
*/
async remove(highlight: HighlightEntity): Promise<void> {
await this.repository.remove(highlight)
}
}

View file

@ -0,0 +1,35 @@
import { EntityLabel } from '../../label/entities/entity-label.entity'
/**
* Repository interface for EntityLabel entity
* Manages the many-to-many relationship between library items and labels
*/
export interface IEntityLabelRepository {
/**
* Find entity labels for a library item with label relations loaded
* @param libraryItemId - Library item ID
* @returns Array of entity labels with label relations
*/
findByLibraryItemId(libraryItemId: string): Promise<EntityLabel[]>
/**
* Delete all entity labels for a library item
* @param libraryItemId - Library item ID
* @returns void
*/
deleteByLibraryItemId(libraryItemId: string): Promise<void>
/**
* Create a new entity label instance (without saving to database)
* @param data - Partial entity label data
* @returns EntityLabel instance
*/
create(data: Partial<EntityLabel>): EntityLabel
/**
* Save (create or update) entity labels in bulk
* @param entityLabels - Array of entity labels to save
* @returns Saved entity labels
*/
save(entityLabels: EntityLabel[]): Promise<EntityLabel[]>
}

View file

@ -0,0 +1,47 @@
import { HighlightEntity } from '../../highlight/entities/highlight.entity'
/**
* Repository interface for Highlight entity
* Separates data access layer from business logic
*/
export interface IHighlightRepository {
/**
* Find a highlight by ID and user ID
* @param id - Highlight ID
* @param userId - User ID who owns the highlight
* @returns Highlight or null if not found
*/
findById(id: string, userId: string): Promise<HighlightEntity | null>
/**
* Find all highlights for a library item
* @param libraryItemId - Library item ID
* @param userId - User ID who owns the highlights
* @returns Array of highlights, sorted by position
*/
findByLibraryItem(
libraryItemId: string,
userId: string,
): Promise<HighlightEntity[]>
/**
* Create a new highlight instance (without saving to database)
* @param data - Partial highlight data
* @returns Highlight instance
*/
create(data: Partial<HighlightEntity>): HighlightEntity
/**
* Save (create or update) a highlight
* @param highlight - Highlight to save
* @returns Saved highlight
*/
save(highlight: HighlightEntity): Promise<HighlightEntity>
/**
* Remove a highlight
* @param highlight - Highlight to remove
* @returns void
*/
remove(highlight: HighlightEntity): Promise<void>
}

View file

@ -0,0 +1,59 @@
import { Label } from '../../label/entities/label.entity'
/**
* Repository interface for Label entity
* Separates data access layer from business logic
*/
export interface ILabelRepository {
/**
* Find all labels for a user, sorted by position
* @param userId - User ID who owns the labels
* @returns Array of labels, sorted by position
*/
findAll(userId: string): Promise<Label[]>
/**
* Find a label by ID and user ID
* @param id - Label ID
* @param userId - User ID who owns the label
* @returns Label or null if not found
*/
findById(id: string, userId: string): Promise<Label | null>
/**
* Find a label by name and user ID
* @param name - Label name
* @param userId - User ID who owns the label
* @returns Label or null if not found
*/
findByName(name: string, userId: string): Promise<Label | null>
/**
* Find multiple labels by IDs for a user
* @param labelIds - Array of label IDs
* @param userId - User ID who owns the labels
* @returns Array of labels, sorted by position
*/
findByIds(labelIds: string[], userId: string): Promise<Label[]>
/**
* Create a new label instance (without saving to database)
* @param data - Partial label data
* @returns Label instance
*/
create(data: Partial<Label>): Label
/**
* Save (create or update) a label
* @param label - Label to save
* @returns Saved label
*/
save(label: Label): Promise<Label>
/**
* Remove a label
* @param label - Label to remove
* @returns void
*/
remove(label: Label): Promise<void>
}

View file

@ -0,0 +1,130 @@
import { LibraryItemEntity } from '../../library/entities/library-item.entity'
import {
LibrarySearchInput,
ReadingProgressInput,
} from '../../library/dto/library-inputs.type'
/**
* Options for finding library items
*/
export interface FindOptions {
where?: Record<string, any>
order?: Record<string, 'ASC' | 'DESC'>
take?: number
skip?: number
}
/**
* Result of paginated library item query
*/
export interface PaginatedResult<T> {
items: T[]
nextCursor: string | null
}
/**
* Result of bulk operations
*/
export interface BulkOperationResult {
success: boolean
successCount: number
failureCount: number
errors?: string[]
message?: string
}
/**
* Repository interface for LibraryItem entity
* Separates data access layer from business logic
*/
export interface ILibraryItemRepository {
/**
* Find a library item by ID and user ID
* @param id - Library item ID
* @param userId - User ID who owns the item
* @returns Library item or null if not found
*/
findById(id: string, userId: string): Promise<LibraryItemEntity | null>
/**
* Find a library item by URL and user ID (for duplicate detection)
* @param url - Original URL
* @param userId - User ID who owns the item
* @returns Library item or null if not found
*/
findByUrl(url: string, userId: string): Promise<LibraryItemEntity | null>
/**
* List library items for a user with pagination and filtering
* @param userId - User ID
* @param first - Number of items to fetch
* @param after - Cursor for pagination
* @param search - Search and filter options
* @returns Paginated result with items and next cursor
*/
listForUser(
userId: string,
first: number,
after?: string,
search?: LibrarySearchInput,
): Promise<PaginatedResult<LibraryItemEntity>>
/**
* Save (create or update) a library item
* @param item - Library item to save
* @returns Saved library item
*/
save(item: LibraryItemEntity): Promise<LibraryItemEntity>
/**
* Create a new library item (without saving to database)
* @param data - Partial library item data
* @returns Library item instance
*/
create(data: Partial<LibraryItemEntity>): LibraryItemEntity
/**
* Bulk archive or unarchive library items
* @param userId - User ID who owns the items
* @param itemIds - List of library item IDs
* @param archived - Whether to archive (true) or unarchive (false)
* @returns Bulk operation result with success/failure counts
*/
bulkArchive(
userId: string,
itemIds: string[],
archived: boolean,
): Promise<BulkOperationResult>
/**
* Bulk delete library items (soft delete)
* @param userId - User ID who owns the items
* @param itemIds - List of library item IDs
* @returns Bulk operation result with success/failure counts
*/
bulkDelete(userId: string, itemIds: string[]): Promise<BulkOperationResult>
/**
* Bulk move library items to a different folder
* @param userId - User ID who owns the items
* @param itemIds - List of library item IDs
* @param folder - Target folder (inbox, archive, trash)
* @returns Bulk operation result with success/failure counts
*/
bulkMoveToFolder(
userId: string,
itemIds: string[],
folder: string,
): Promise<BulkOperationResult>
/**
* Bulk mark library items as read
* @param userId - User ID who owns the items
* @param itemIds - List of library item IDs
* @returns Bulk operation result with success/failure counts
*/
bulkMarkAsRead(
userId: string,
itemIds: string[],
): Promise<BulkOperationResult>
}

View file

@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository, In } from 'typeorm'
import { Label } from '../label/entities/label.entity'
import { ILabelRepository } from './interfaces/label-repository.interface'
/**
* TypeORM implementation of the ILabelRepository interface
* Handles all data access operations for labels
*/
@Injectable()
export class LabelRepository implements ILabelRepository {
constructor(
@InjectRepository(Label)
private readonly repository: Repository<Label>,
) {}
/**
* Find all labels for a user, sorted by position
*/
async findAll(userId: string): Promise<Label[]> {
return this.repository.find({
where: { userId },
order: { position: 'ASC' },
})
}
/**
* Find a label by ID and user ID
*/
async findById(id: string, userId: string): Promise<Label | null> {
return this.repository.findOne({
where: { id, userId },
})
}
/**
* Find a label by name and user ID
*/
async findByName(name: string, userId: string): Promise<Label | null> {
return this.repository.findOne({
where: { userId, name },
})
}
/**
* Find multiple labels by IDs for a user
*/
async findByIds(labelIds: string[], userId: string): Promise<Label[]> {
if (labelIds.length === 0) {
return []
}
return this.repository.find({
where: labelIds.map((id) => ({ id, userId })),
order: { position: 'ASC' },
})
}
/**
* Create a new label instance (without saving to database)
*/
create(data: Partial<Label>): Label {
return this.repository.create(data)
}
/**
* Save (create or update) a label
*/
async save(label: Label): Promise<Label> {
return this.repository.save(label)
}
/**
* Remove a label from the database
*/
async remove(label: Label): Promise<void> {
await this.repository.remove(label)
}
}

View file

@ -0,0 +1,411 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository, DataSource } from 'typeorm'
import {
LibraryItemEntity,
LibraryItemState,
} from '../library/entities/library-item.entity'
import {
LibrarySearchInput,
LibrarySortField,
SortOrder,
} from '../library/dto/library-inputs.type'
import {
ILibraryItemRepository,
PaginatedResult,
BulkOperationResult,
} from './interfaces/library-item-repository.interface'
import { FOLDERS } from '../constants/folders.constants'
/**
* Repository for LibraryItem entity
* Handles all data access operations for library items
*/
@Injectable()
export class LibraryItemRepository implements ILibraryItemRepository {
constructor(
@InjectRepository(LibraryItemEntity)
private readonly repository: Repository<LibraryItemEntity>,
private readonly dataSource: DataSource,
) {}
/**
* Find a library item by ID and user ID
*/
async findById(
id: string,
userId: string,
): Promise<LibraryItemEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
})
}
/**
* Find a library item by URL and user ID (for duplicate detection)
*/
async findByUrl(
url: string,
userId: string,
): Promise<LibraryItemEntity | null> {
return this.repository.findOne({
where: {
userId,
originalUrl: url,
},
})
}
/**
* List library items for a user with pagination and filtering
*/
async listForUser(
userId: string,
first: number,
after?: string,
search?: LibrarySearchInput,
): Promise<PaginatedResult<LibraryItemEntity>> {
const limit = Math.min(Math.max(first, 1), 100)
const query = this.repository
.createQueryBuilder('item')
.where('item.userId = :userId', { userId })
// Apply folder filter
if (search?.folder && search.folder !== FOLDERS.ALL) {
query.andWhere('item.folder = :folder', { folder: search.folder })
}
// Apply state filter
if (search?.state) {
query.andWhere('item.state = :state', { state: search.state })
}
// Apply full-text search
if (search?.query && search.query.trim()) {
const searchTerm = `%${search.query.trim()}%`
query.andWhere(
'(item.title ILIKE :searchTerm OR item.description ILIKE :searchTerm OR item.author ILIKE :searchTerm)',
{ searchTerm },
)
}
// Apply label filter
if (search?.labels && search.labels.length > 0) {
query.andWhere('item.labelNames && :labels', { labels: search.labels })
}
// Determine sort field and order
const sortBy = search?.sortBy || LibrarySortField.SAVED_AT
const sortOrder = search?.sortOrder || SortOrder.DESC
// Map sort field to column name
const sortColumn = `item.${sortBy}`
query.orderBy(sortColumn, sortOrder).take(limit + 1)
// Handle cursor-based pagination
if (after) {
const cursorDate = new Date(after)
if (!Number.isNaN(cursorDate.getTime())) {
const operator = sortOrder === SortOrder.DESC ? '<' : '>'
query.andWhere(`${sortColumn} ${operator} :cursor`, {
cursor: cursorDate,
})
}
}
const rows = await query.getMany()
const hasNext = rows.length > limit
const sliced = hasNext ? rows.slice(0, limit) : rows
// Generate next cursor based on sort field
let nextCursor: string | null = null
if (hasNext && sliced.length > 0) {
const lastItem = sliced[sliced.length - 1]
const cursorField = sortBy as keyof LibraryItemEntity
const cursorValue = lastItem[cursorField]
if (cursorValue instanceof Date) {
nextCursor = cursorValue.toISOString()
} else if (typeof cursorValue === 'string') {
nextCursor = cursorValue
} else {
nextCursor = lastItem.savedAt.toISOString()
}
}
return { items: sliced, nextCursor }
}
/**
* Save (create or update) a library item
*/
async save(item: LibraryItemEntity): Promise<LibraryItemEntity> {
return this.repository.save(item)
}
/**
* Create a new library item (without saving to database)
*/
create(data: Partial<LibraryItemEntity>): LibraryItemEntity {
return this.repository.create(data)
}
/**
* Bulk archive or unarchive library items
*/
async bulkArchive(
userId: string,
itemIds: string[],
archived: boolean,
): Promise<BulkOperationResult> {
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches for better performance
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
// Update items that belong to the user
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
state: archived
? LibraryItemState.ARCHIVED
: LibraryItemState.SUCCEEDED,
folder: archived ? FOLDERS.ARCHIVE : FOLDERS.INBOX,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
// Track which items failed
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(
`Failed to ${archived ? 'archive' : 'unarchive'} items: ${failedIds.join(', ')}`,
)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully ${archived ? 'archived' : 'unarchived'} ${successCount} item(s)`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
}
/**
* Bulk delete library items (soft delete)
*/
async bulkDelete(
userId: string,
itemIds: string[],
): Promise<BulkOperationResult> {
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
// Mark items as deleted (soft delete)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
state: LibraryItemState.DELETED,
folder: FOLDERS.TRASH,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(`Failed to delete items: ${failedIds.join(', ')}`)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully deleted ${successCount} item(s)`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
}
/**
* Bulk move library items to a different folder
*/
async bulkMoveToFolder(
userId: string,
itemIds: string[],
folder: string,
): Promise<BulkOperationResult> {
// Determine state based on folder
let state: LibraryItemState
if (folder === FOLDERS.ARCHIVE) {
state = LibraryItemState.ARCHIVED
} else if (folder === FOLDERS.TRASH) {
state = LibraryItemState.DELETED
} else {
state = LibraryItemState.SUCCEEDED
}
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
folder,
state,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(
`Failed to move items to ${folder}: ${failedIds.join(', ')}`,
)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully moved ${successCount} item(s) to ${folder}`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
}
/**
* Bulk mark library items as read
*/
async bulkMarkAsRead(
userId: string,
itemIds: string[],
): Promise<BulkOperationResult> {
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
const errors: string[] = []
let successCount = 0
let failureCount = 0
try {
// Process items in batches
const batchSize = 100
for (let i = 0; i < itemIds.length; i += batchSize) {
const batch = itemIds.slice(i, i + batchSize)
const result = await queryRunner.manager
.createQueryBuilder()
.update(LibraryItemEntity)
.set({
readAt: new Date(),
readingProgressTopPercent: 100,
readingProgressBottomPercent: 100,
})
.where('id IN (:...ids)', { ids: batch })
.andWhere('userId = :userId', { userId })
.execute()
successCount += result.affected || 0
failureCount += batch.length - (result.affected || 0)
if (result.affected !== batch.length) {
const failedIds = batch.slice(result.affected || 0)
errors.push(`Failed to mark items as read: ${failedIds.join(', ')}`)
}
}
await queryRunner.commitTransaction()
return {
success: failureCount === 0,
successCount,
failureCount,
errors: errors.length > 0 ? errors : undefined,
message: `Successfully marked ${successCount} item(s) as read`,
}
} catch (error) {
await queryRunner.rollbackTransaction()
throw error
} finally {
await queryRunner.release()
}
}
}

View file

@ -0,0 +1,55 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { LibraryItemRepository } from './library-item.repository'
import { HighlightEntity } from '../highlight/entities/highlight.entity'
import { HighlightRepository } from './highlight.repository'
import { Label } from '../label/entities/label.entity'
import { EntityLabel } from '../label/entities/entity-label.entity'
import { LabelRepository } from './label.repository'
import { EntityLabelRepository } from './entity-label.repository'
/**
* RepositoriesModule
*
* Centralized module for all repository implementations.
* This module can be imported by any module that needs repository access
* without creating circular dependencies.
*/
@Module({
imports: [
// Register all entities that repositories need
TypeOrmModule.forFeature([
LibraryItemEntity,
HighlightEntity,
Label,
EntityLabel,
]),
],
providers: [
{
provide: 'ILibraryItemRepository',
useClass: LibraryItemRepository,
},
{
provide: 'IHighlightRepository',
useClass: HighlightRepository,
},
{
provide: 'ILabelRepository',
useClass: LabelRepository,
},
{
provide: 'IEntityLabelRepository',
useClass: EntityLabelRepository,
},
],
exports: [
'ILibraryItemRepository',
'IHighlightRepository',
'ILabelRepository',
'IEntityLabelRepository',
TypeOrmModule, // Export TypeOrmModule to make raw repositories available in tests
],
})
export class RepositoriesModule {}

View file

@ -12,6 +12,7 @@ import {
LibraryItemState,
} from '../src/library/entities/library-item.entity'
import { HighlightEntity } from '../src/highlight/entities/highlight.entity'
import { FOLDERS } from '../src/constants/folders.constants'
const HIGHLIGHTS_QUERY = `
query Highlights($libraryItemId: String!) {
@ -47,7 +48,7 @@ const CREATE_HIGHLIGHT_MUTATION = `
quote
annotation
color
highlaryPositionPercent
highlightPositionPercent
createdAt
}
}
@ -138,7 +139,7 @@ describe('Highlight GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
readableContent: 'This is the content of the article that can be highlighted.',
})
@ -161,6 +162,8 @@ describe('Highlight GraphQL (e2e)', () => {
describe('Query highlights', () => {
beforeAll(async () => {
// Create test highlights with different colors
// Use shorter timestamp (last 8 digits) to fit in varchar(14) constraint
const shortTimestamp = Date.now().toString().slice(-8)
const highlights = [
{
id: randomUUID(),
@ -168,7 +171,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test001',
shortId: `t${shortTimestamp}1`,
quote: 'First important quote',
annotation: 'This is significant',
color: 'yellow',
@ -183,7 +186,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test002',
shortId: `t${shortTimestamp}2`,
quote: 'Second important quote',
annotation: 'Very interesting',
color: 'green',
@ -198,7 +201,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test003',
shortId: `t${shortTimestamp}3`,
quote: 'Third important quote',
color: 'red',
highlightPositionPercent: 50,
@ -212,7 +215,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test004',
shortId: `t${shortTimestamp}4`,
quote: 'Fourth important quote',
annotation: 'Key insight',
color: 'blue',
@ -264,9 +267,12 @@ describe('Highlight GraphQL (e2e)', () => {
})
it('retrieves a single highlight by id', async () => {
const existing = await highlightRepository.findOneBy({
libraryItemId: testLibraryItemId,
shortId: 'test001',
// Find the first highlight created in beforeAll
const existing = await highlightRepository.findOne({
where: {
libraryItemId: testLibraryItemId,
quote: 'First important quote',
},
})
const response = await executeQuery(HIGHLIGHT_QUERY, {
@ -293,7 +299,7 @@ describe('Highlight GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
})
@ -490,7 +496,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: `update-${Date.now()}`,
shortId: `u${Date.now().toString().slice(-8)}`,
quote: 'Original quote',
annotation: 'Original annotation',
color: 'yellow',
@ -642,7 +648,7 @@ describe('Highlight GraphQL (e2e)', () => {
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: `delete-${Date.now()}`,
shortId: `d${Date.now().toString().slice(-8)}`,
quote: 'To be deleted',
color: 'yellow',
highlightPositionPercent: 50,

View file

@ -11,6 +11,7 @@ import {
LibraryItemEntity,
LibraryItemState,
} from '../src/library/entities/library-item.entity'
import { FOLDERS } from '../src/constants/folders.constants'
const LIBRARY_ITEMS_QUERY = `
query LibraryItems($first: Int, $after: String, $search: LibrarySearchInput) {
@ -209,7 +210,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - 2000),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
labelNames: ['news'],
})
@ -224,7 +225,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
itemType: 'ARTICLE',
labelNames: ['tech'],
})
@ -238,7 +239,7 @@ describe('Library GraphQL (e2e)', () => {
expect(firstPage.body.data.libraryItems.items[0]).toMatchObject({
title: 'Second article',
slug: 'second-article',
folder: 'archive',
folder: FOLDERS.ARCHIVE,
state: 'SUCCEEDED',
})
@ -255,7 +256,7 @@ describe('Library GraphQL (e2e)', () => {
expect(secondPage.body.data.libraryItems.items[0]).toMatchObject({
title: 'First article',
slug: 'first-article',
folder: 'inbox',
folder: FOLDERS.INBOX,
state: 'SUCCEEDED',
})
expect(secondPage.body.data.libraryItems.nextCursor).toBeNull()
@ -290,7 +291,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
readingProgressTopPercent: 0,
readingProgressBottomPercent: 0,
@ -311,13 +312,13 @@ describe('Library GraphQL (e2e)', () => {
expect(response.body.data.archiveLibraryItem).toMatchObject({
id: testItemId,
state: 'ARCHIVED',
folder: 'archive',
folder: FOLDERS.ARCHIVE,
})
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.state).toBe(LibraryItemState.ARCHIVED)
expect(item?.folder).toBe('archive')
expect(item?.folder).toBe(FOLDERS.ARCHIVE)
})
it('unarchives a library item', async () => {
@ -337,13 +338,13 @@ describe('Library GraphQL (e2e)', () => {
expect(response.body.data.archiveLibraryItem).toMatchObject({
id: testItemId,
state: 'SUCCEEDED',
folder: 'inbox',
folder: FOLDERS.INBOX,
})
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.state).toBe(LibraryItemState.SUCCEEDED)
expect(item?.folder).toBe('inbox')
expect(item?.folder).toBe(FOLDERS.INBOX)
})
it('returns error for non-existent item', async () => {
@ -372,14 +373,14 @@ describe('Library GraphQL (e2e)', () => {
// Verify item is in trash
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.folder).toBe('trash')
expect(item?.folder).toBe(FOLDERS.TRASH)
expect(item?.state).toBe(LibraryItemState.DELETED)
})
it('permanently deletes item already in trash', async () => {
// First move to trash
await libraryRepository.update(testItemId, {
folder: 'trash',
folder: FOLDERS.TRASH,
state: LibraryItemState.DELETED,
})
@ -485,20 +486,20 @@ describe('Library GraphQL (e2e)', () => {
MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION,
{
id: testItemId,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
},
)
expect(response.body.errors).toBeUndefined()
expect(response.body.data.moveLibraryItemToFolder).toMatchObject({
id: testItemId,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
state: 'ARCHIVED',
})
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.folder).toBe('archive')
expect(item?.folder).toBe(FOLDERS.ARCHIVE)
expect(item?.state).toBe(LibraryItemState.ARCHIVED)
})
@ -507,14 +508,14 @@ describe('Library GraphQL (e2e)', () => {
MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION,
{
id: testItemId,
folder: 'trash',
folder: FOLDERS.TRASH,
},
)
expect(response.body.errors).toBeUndefined()
expect(response.body.data.moveLibraryItemToFolder).toMatchObject({
id: testItemId,
folder: 'trash',
folder: FOLDERS.TRASH,
state: 'DELETED',
})
})
@ -522,7 +523,7 @@ describe('Library GraphQL (e2e)', () => {
it('moves item back to inbox', async () => {
// First move to archive
await libraryRepository.update(testItemId, {
folder: 'archive',
folder: FOLDERS.ARCHIVE,
state: LibraryItemState.ARCHIVED,
})
@ -531,14 +532,14 @@ describe('Library GraphQL (e2e)', () => {
MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION,
{
id: testItemId,
folder: 'inbox',
folder: FOLDERS.INBOX,
},
)
expect(response.body.errors).toBeUndefined()
expect(response.body.data.moveLibraryItemToFolder).toMatchObject({
id: testItemId,
folder: 'inbox',
folder: FOLDERS.INBOX,
state: 'SUCCEEDED',
})
})
@ -561,7 +562,7 @@ describe('Library GraphQL (e2e)', () => {
MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION,
{
id: randomUUID(),
folder: 'archive',
folder: FOLDERS.ARCHIVE,
},
)
@ -587,7 +588,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - 5000),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
},
{
@ -602,7 +603,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - 4000),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
},
{
@ -617,7 +618,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - 3000),
state: LibraryItemState.ARCHIVED,
contentReader: ContentReaderType.WEB,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
itemType: 'ARTICLE',
},
{
@ -632,7 +633,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - 2000),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
},
]
@ -680,28 +681,28 @@ describe('Library GraphQL (e2e)', () => {
it('filters by folder (inbox)', async () => {
const response = await executeQuery(LIBRARY_ITEMS_QUERY, {
search: { folder: 'inbox' },
search: { folder: FOLDERS.INBOX },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0)
expect(
response.body.data.libraryItems.items.every(
(item: any) => item.folder === 'inbox',
(item: any) => item.folder === FOLDERS.INBOX,
),
).toBe(true)
})
it('filters by folder (archive)', async () => {
const response = await executeQuery(LIBRARY_ITEMS_QUERY, {
search: { folder: 'archive' },
search: { folder: FOLDERS.ARCHIVE },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0)
expect(
response.body.data.libraryItems.items.every(
(item: any) => item.folder === 'archive',
(item: any) => item.folder === FOLDERS.ARCHIVE,
),
).toBe(true)
})
@ -722,12 +723,12 @@ describe('Library GraphQL (e2e)', () => {
it('combines search query with folder filter', async () => {
const response = await executeQuery(LIBRARY_ITEMS_QUERY, {
search: { query: 'John Doe', folder: 'inbox' },
search: { query: 'John Doe', folder: FOLDERS.INBOX },
})
expect(response.body.errors).toBeUndefined()
const items = response.body.data.libraryItems.items
expect(items.every((item: any) => item.folder === 'inbox')).toBe(true)
expect(items.every((item: any) => item.folder === FOLDERS.INBOX)).toBe(true)
expect(items.every((item: any) => item.author === 'John Doe')).toBe(true)
})
@ -778,7 +779,7 @@ describe('Library GraphQL (e2e)', () => {
it('supports pagination with search filters', async () => {
const firstPage = await executeQuery(LIBRARY_ITEMS_QUERY, {
first: 2,
search: { folder: 'inbox' },
search: { folder: FOLDERS.INBOX },
})
expect(firstPage.body.errors).toBeUndefined()
@ -791,14 +792,14 @@ describe('Library GraphQL (e2e)', () => {
const secondPage = await executeQuery(LIBRARY_ITEMS_QUERY, {
first: 2,
after: nextCursor,
search: { folder: 'inbox' },
search: { folder: FOLDERS.INBOX },
})
expect(secondPage.body.errors).toBeUndefined()
// All items should still be from inbox
expect(
secondPage.body.data.libraryItems.items.every(
(item: any) => item.folder === 'inbox',
(item: any) => item.folder === FOLDERS.INBOX,
),
).toBe(true)
}
@ -821,7 +822,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(Date.now() - (i + 1) * 1000),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
}))
@ -876,7 +877,7 @@ describe('Library GraphQL (e2e)', () => {
where: { id: bulkTestItemIds[0] },
})
expect(item?.state).toBe(LibraryItemState.SUCCEEDED)
expect(item?.folder).toBe('inbox')
expect(item?.folder).toBe(FOLDERS.INBOX)
})
it('returns error for empty itemIds array', async () => {
@ -941,7 +942,7 @@ describe('Library GraphQL (e2e)', () => {
const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, {
itemIds: idsToMove,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
})
expect(response.body.errors).toBeUndefined()
@ -964,7 +965,7 @@ describe('Library GraphQL (e2e)', () => {
const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, {
itemIds: idsToMove,
folder: 'trash',
folder: FOLDERS.TRASH,
})
expect(response.body.errors).toBeUndefined()
@ -995,7 +996,7 @@ describe('Library GraphQL (e2e)', () => {
it('returns error for empty itemIds array', async () => {
const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, {
itemIds: [],
folder: 'archive',
folder: FOLDERS.ARCHIVE,
})
expect(response.body.errors).toBeDefined()
@ -1052,7 +1053,7 @@ describe('Library GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
}))

View file

@ -11,6 +11,7 @@ import {
LibraryItemEntity,
LibraryItemState,
} from '../src/library/entities/library-item.entity'
import { FOLDERS } from '../src/constants/folders.constants'
const LIBRARY_ITEM_QUERY = `
query LibraryItem($id: String!) {
@ -107,7 +108,7 @@ describe('Notebook GraphQL (e2e)', () => {
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
folder: FOLDERS.INBOX,
itemType: 'ARTICLE',
})
@ -226,7 +227,10 @@ describe('Notebook GraphQL (e2e)', () => {
it('retrieves notebook via libraryItem query', async () => {
const noteContent = 'My personal notes about this article'
await libraryRepository.update(testItemId, { note: noteContent })
await libraryRepository.update(testItemId, {
note: noteContent,
noteUpdatedAt: new Date(),
})
const response = await executeQuery(LIBRARY_ITEM_QUERY, {
id: testItemId,

View file

@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'
import { AppModule } from '../src/app/app.module'
import { ConfigService } from '@nestjs/config'
import { DataSource } from 'typeorm'
import { FOLDERS } from '../src/constants/folders.constants'
describe('SaveUrl E2E Tests', () => {
let app: INestApplication
@ -122,7 +123,7 @@ describe('SaveUrl E2E Tests', () => {
expect(response.body.errors).toBeUndefined()
expect(response.body.data.saveUrl).toMatchObject({
originalUrl: 'https://example.com/article',
folder: 'inbox', // Default folder
folder: FOLDERS.INBOX, // Default folder
contentReader: 'WEB',
state: 'CONTENT_NOT_FETCHED', // Content extraction deferred to ARC-012
})
@ -149,7 +150,7 @@ describe('SaveUrl E2E Tests', () => {
{
input: {
url: 'https://example.com/archived-article',
folder: 'archive',
folder: FOLDERS.ARCHIVE,
},
},
)
@ -157,7 +158,7 @@ describe('SaveUrl E2E Tests', () => {
expect(response.status).toBe(200)
expect(response.body.data.saveUrl).toMatchObject({
originalUrl: 'https://example.com/archived-article',
folder: 'archive',
folder: FOLDERS.ARCHIVE,
})
createdLibraryItemIds.push(response.body.data.saveUrl.id)
@ -561,14 +562,14 @@ describe('SaveUrl E2E Tests', () => {
`,
{
id: itemId,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
},
)
expect(moveResponse.status).toBe(200)
expect(moveResponse.body.data.moveLibraryItemToFolder).toMatchObject({
id: itemId,
folder: 'archive',
folder: FOLDERS.ARCHIVE,
})
})