mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
parent
da6850a7f4
commit
82b4de5695
26 changed files with 1274 additions and 489 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
41
packages/api-nest/src/constants/folders.constants.ts
Normal file
41
packages/api-nest/src/constants/folders.constants.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
72
packages/api-nest/src/repositories/highlight.repository.ts
Normal file
72
packages/api-nest/src/repositories/highlight.repository.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
80
packages/api-nest/src/repositories/label.repository.ts
Normal file
80
packages/api-nest/src/repositories/label.repository.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
411
packages/api-nest/src/repositories/library-item.repository.ts
Normal file
411
packages/api-nest/src/repositories/library-item.repository.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/api-nest/src/repositories/repositories.module.ts
Normal file
55
packages/api-nest/src/repositories/repositories.module.ts
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue