From 82b4de56959ec73bd012aed684057a8507c446bf Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Sat, 18 Oct 2025 00:55:19 -0400 Subject: [PATCH] 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. --- .../auth/default-user-resources.service.ts | 45 +- .../src/auth/services/auth.service.ts | 79 +++- packages/api-nest/src/config/test.config.ts | 2 + .../src/constants/folders.constants.ts | 41 ++ .../api-nest/src/database/database.module.ts | 2 + .../src/database/seeds/library-items.seed.ts | 11 +- .../src/highlight/highlight.module.ts | 8 +- .../src/highlight/highlight.service.ts | 46 +- packages/api-nest/src/label/label.module.ts | 9 +- packages/api-nest/src/label/label.service.ts | 61 +-- .../src/library/dto/library-inputs.type.ts | 9 +- .../api-nest/src/library/library.module.ts | 5 +- .../api-nest/src/library/library.service.ts | 380 +++------------- .../repositories/entity-label.repository.ts | 48 ++ .../src/repositories/highlight.repository.ts | 72 +++ .../entity-label-repository.interface.ts | 35 ++ .../highlight-repository.interface.ts | 47 ++ .../interfaces/label-repository.interface.ts | 59 +++ .../library-item-repository.interface.ts | 130 ++++++ .../src/repositories/label.repository.ts | 80 ++++ .../repositories/library-item.repository.ts | 411 ++++++++++++++++++ .../src/repositories/repositories.module.ts | 55 +++ packages/api-nest/test/highlight.e2e-spec.ts | 30 +- packages/api-nest/test/library.e2e-spec.ts | 79 ++-- packages/api-nest/test/notebook.e2e-spec.ts | 8 +- packages/api-nest/test/save-url.e2e-spec.ts | 11 +- 26 files changed, 1274 insertions(+), 489 deletions(-) create mode 100644 packages/api-nest/src/constants/folders.constants.ts create mode 100644 packages/api-nest/src/repositories/entity-label.repository.ts create mode 100644 packages/api-nest/src/repositories/highlight.repository.ts create mode 100644 packages/api-nest/src/repositories/interfaces/entity-label-repository.interface.ts create mode 100644 packages/api-nest/src/repositories/interfaces/highlight-repository.interface.ts create mode 100644 packages/api-nest/src/repositories/interfaces/label-repository.interface.ts create mode 100644 packages/api-nest/src/repositories/interfaces/library-item-repository.interface.ts create mode 100644 packages/api-nest/src/repositories/label.repository.ts create mode 100644 packages/api-nest/src/repositories/library-item.repository.ts create mode 100644 packages/api-nest/src/repositories/repositories.module.ts diff --git a/packages/api-nest/src/auth/default-user-resources.service.ts b/packages/api-nest/src/auth/default-user-resources.service.ts index 6b794b3cb..eb74508f7 100644 --- a/packages/api-nest/src/auth/default-user-resources.service.ts +++ b/packages/api-nest/src/auth/default-user-resources.service.ts @@ -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, + 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 { + const nodeEnv = this.configService.get('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 + } + } } diff --git a/packages/api-nest/src/auth/services/auth.service.ts b/packages/api-nest/src/auth/services/auth.service.ts index 8ffb23cc7..8ca55a68e 100644 --- a/packages/api-nest/src/auth/services/auth.service.ts +++ b/packages/api-nest/src/auth/services/auth.service.ts @@ -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 { 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 { 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 { 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('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 { 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 = diff --git a/packages/api-nest/src/config/test.config.ts b/packages/api-nest/src/config/test.config.ts index 491b7f6d9..903f27865 100644 --- a/packages/api-nest/src/config/test.config.ts +++ b/packages/api-nest/src/config/test.config.ts @@ -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, diff --git a/packages/api-nest/src/constants/folders.constants.ts b/packages/api-nest/src/constants/folders.constants.ts new file mode 100644 index 000000000..2a21d83e5 --- /dev/null +++ b/packages/api-nest/src/constants/folders.constants.ts @@ -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]) +} diff --git a/packages/api-nest/src/database/database.module.ts b/packages/api-nest/src/database/database.module.ts index dca6fba30..74d9c781e 100644 --- a/packages/api-nest/src/database/database.module.ts +++ b/packages/api-nest/src/database/database.module.ts @@ -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 diff --git a/packages/api-nest/src/database/seeds/library-items.seed.ts b/packages/api-nest/src/database/seeds/library-items.seed.ts index 71b05b06f..1879ea4d5 100644 --- a/packages/api-nest/src/database/seeds/library-items.seed.ts +++ b/packages/api-nest/src/database/seeds/library-items.seed.ts @@ -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', diff --git a/packages/api-nest/src/highlight/highlight.module.ts b/packages/api-nest/src/highlight/highlight.module.ts index f5e5c03d8..cce353e1e 100644 --- a/packages/api-nest/src/highlight/highlight.module.ts +++ b/packages/api-nest/src/highlight/highlight.module.ts @@ -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], }) diff --git a/packages/api-nest/src/highlight/highlight.service.ts b/packages/api-nest/src/highlight/highlight.service.ts index 5526a7528..aa7fed1fc 100644 --- a/packages/api-nest/src/highlight/highlight.service.ts +++ b/packages/api-nest/src/highlight/highlight.service.ts @@ -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, - @InjectRepository(LibraryItemEntity) - private readonly libraryItemRepository: Repository, + @Inject('IHighlightRepository') + private readonly highlightRepository: IHighlightRepository, + @Inject('ILibraryItemRepository') + private readonly libraryItemRepository: ILibraryItemRepository, ) {} /** @@ -29,9 +29,10 @@ export class HighlightService { libraryItemId: string, ): Promise { // 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 { - return this.highlightRepository.findOne({ - where: { - id, - userId, - }, - }) + return this.highlightRepository.findById(id, userId) } /** @@ -70,9 +59,10 @@ export class HighlightService { input: CreateHighlightInput, ): Promise { // 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( diff --git a/packages/api-nest/src/label/label.module.ts b/packages/api-nest/src/label/label.module.ts index 4e905c7cc..b0810fb3f 100644 --- a/packages/api-nest/src/label/label.module.ts +++ b/packages/api-nest/src/label/label.module.ts @@ -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], }) diff --git a/packages/api-nest/src/label/label.service.ts b/packages/api-nest/src/label/label.service.ts index 79bbbf807..3bf1735ca 100644 --- a/packages/api-nest/src/label/label.service.ts +++ b/packages/api-nest/src/label/label.service.ts @@ -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