diff --git a/packages/api-nest/schema.graphql b/packages/api-nest/schema.graphql index 4db46d137..2944a856f 100644 --- a/packages/api-nest/schema.graphql +++ b/packages/api-nest/schema.graphql @@ -23,6 +23,35 @@ enum ContentReaderType { WEB } +input CreateHighlightInput { + """User annotation/note on the highlight""" + annotation: String + + """Highlight color (yellow, red, green, blue)""" + color: String + + """Anchor index for position""" + highlightPositionAnchorIndex: Int = 0 + + """Position in document as percentage (0-100)""" + highlightPositionPercent: Float = 0 + + """HTML content of the highlight""" + html: String + + """Library item ID""" + libraryItemId: String! + + """Text before the quote (for context)""" + prefix: String + + """Quoted text from the document""" + quote: String! + + """Text after the quote (for context)""" + suffix: String +} + input CreateLabelInput { color: String = "#000000" description: String @@ -45,6 +74,32 @@ type DeleteResult { success: Boolean! } +type Highlight { + annotation: String + color: String + createdAt: DateTime! + highlightPositionAnchorIndex: Int! + highlightPositionPercent: Float! + highlightType: HighlightType! + html: String + id: ID! + libraryItemId: String! + patch: String + prefix: String + quote: String + representation: RepresentationType! + sharedAt: DateTime + shortId: String! + suffix: String + updatedAt: DateTime! +} + +enum HighlightType { + HIGHLIGHT + NOTE + REDACTION +} + type Label { color: String! createdAt: DateTime! @@ -65,6 +120,8 @@ type LibraryItem { folder: String! id: ID! labels: [Label!] + note: String + noteUpdatedAt: DateTime originalUrl: String! publishedAt: DateTime readAt: DateTime @@ -162,9 +219,21 @@ type Mutation { itemIds: [String!]! ): BulkActionResult! + """Create a new highlight with optional color""" + createHighlight( + """Highlight creation data""" + input: CreateHighlightInput! + ): Highlight! + """Create a new label""" createLabel(input: CreateLabelInput!): Label! + """Delete a highlight""" + deleteHighlight( + """Highlight ID""" + id: String! + ): DeleteResult! + """Delete a label""" deleteLabel(id: String!): DeleteResult! @@ -191,9 +260,27 @@ type Mutation { """Set labels for a library item (replaces existing labels)""" setLibraryItemLabels(itemId: String!, labelIds: [String!]!): [Label!]! + """Update a highlight (annotation and/or color)""" + updateHighlight( + """Highlight ID""" + id: String! + + """Highlight update data""" + input: UpdateHighlightInput! + ): Highlight! + """Update an existing label""" updateLabel(id: String!, input: UpdateLabelInput!): Label! + """Update notebook content for a library item""" + updateNotebook( + """Library item ID""" + id: String! + + """Notebook content""" + input: UpdateNotebookInput! + ): LibraryItem! + """Update reading progress for a library item""" updateReadingProgress( """Library item ID""" @@ -205,6 +292,18 @@ type Mutation { } type Query { + """Get a single highlight by ID""" + highlight( + """Highlight ID""" + id: String! + ): Highlight + + """Get all highlights for a library item""" + highlights( + """Library item ID""" + libraryItemId: String! + ): [Highlight!]! + """Get a single label by ID""" label(id: String!): Label @@ -238,6 +337,11 @@ enum RegistrationType { TWITTER } +enum RepresentationType { + CONTENT + FEED_CONTENT +} + input SaveUrlInput { """Folder to save the URL to (inbox, archive)""" folder: String = "inbox" @@ -261,12 +365,25 @@ enum StatusType { PENDING } +input UpdateHighlightInput { + """User annotation/note on the highlight""" + annotation: String + + """Highlight color (yellow, red, green, blue)""" + color: String +} + input UpdateLabelInput { color: String description: String name: String } +input UpdateNotebookInput { + """Notebook content (supports markdown)""" + note: String! +} + type User { createdAt: DateTime! email: String diff --git a/packages/api-nest/src/app/app.module.ts b/packages/api-nest/src/app/app.module.ts index 148952089..ed426d548 100644 --- a/packages/api-nest/src/app/app.module.ts +++ b/packages/api-nest/src/app/app.module.ts @@ -8,6 +8,7 @@ import { LoggingModule } from '../logging/logging.module' import { GraphqlModule } from '../graphql/graphql.module' import { LibraryModule } from '../library/library.module' import { LabelModule } from '../label/label.module' +import { HighlightModule } from '../highlight/highlight.module' import { QueueModule } from '../queue/queue.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -44,6 +45,12 @@ import { configValidationSchema } from '../config/config.schema' // Library / Reader LibraryModule, + // Labels + LabelModule, + + // Highlights + HighlightModule, + // Queue and Background Processing QueueModule, diff --git a/packages/api-nest/src/config/test.config.ts b/packages/api-nest/src/config/test.config.ts index 2b03cfc28..491b7f6d9 100644 --- a/packages/api-nest/src/config/test.config.ts +++ b/packages/api-nest/src/config/test.config.ts @@ -13,11 +13,11 @@ import { EntityLabel } from '../label/entities/entity-label.entity' export const testDatabaseConfig: TypeOrmModuleOptions = { type: 'postgres', - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432'), - username: process.env.DATABASE_USER || 'app_user', - password: process.env.DATABASE_PASSWORD || '', - database: process.env.DATABASE_NAME || 'omnivore', // Use same DB as dev + host: process.env.TEST_DATABASE_HOST, + port: Number.parseInt(process.env.TEST_DATABASE_PORT || '5432'), + username: process.env.TEST_DATABASE_USER, + password: process.env.TEST_DATABASE_PASSWORD, + database: process.env.TEST_DATABASE_NAME, entities: [ User, UserProfile, @@ -30,7 +30,7 @@ export const testDatabaseConfig: TypeOrmModuleOptions = { Label, EntityLabel, ], - synchronize: false, // Use existing schema + synchronize: false, logging: false, } diff --git a/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts b/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts new file mode 100644 index 000000000..ba05fef22 --- /dev/null +++ b/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts @@ -0,0 +1,109 @@ +import { InputType, Field, Float, Int } from '@nestjs/graphql' +import { + IsString, + IsOptional, + IsNumber, + Min, + Max, + IsInt, + IsIn, +} from 'class-validator' + +/** + * Input type for creating a new highlight + */ +@InputType() +export class CreateHighlightInput { + @Field(() => String, { description: 'Library item ID' }) + @IsString() + libraryItemId!: string + + @Field(() => String, { description: 'Quoted text from the document' }) + @IsString() + quote!: string + + @Field(() => String, { + nullable: true, + description: 'Text before the quote (for context)', + }) + @IsOptional() + @IsString() + prefix?: string + + @Field(() => String, { + nullable: true, + description: 'Text after the quote (for context)', + }) + @IsOptional() + @IsString() + suffix?: string + + @Field(() => String, { + nullable: true, + description: 'User annotation/note on the highlight', + }) + @IsOptional() + @IsString() + annotation?: string + + @Field(() => Float, { + nullable: true, + defaultValue: 0, + description: 'Position in document as percentage (0-100)', + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + highlightPositionPercent?: number + + @Field(() => Int, { + nullable: true, + defaultValue: 0, + description: 'Anchor index for position', + }) + @IsOptional() + @IsInt() + @Min(0) + highlightPositionAnchorIndex?: number + + @Field(() => String, { + nullable: true, + description: 'Highlight color (yellow, red, green, blue)', + }) + @IsOptional() + @IsString() + @IsIn(['yellow', 'red', 'green', 'blue']) + color?: string + + @Field(() => String, { + nullable: true, + description: 'HTML content of the highlight', + }) + @IsOptional() + @IsString() + html?: string +} + +/** + * Input type for updating an existing highlight + */ +@InputType() +export class UpdateHighlightInput { + @Field(() => String, { + nullable: true, + description: 'User annotation/note on the highlight', + }) + @IsOptional() + @IsString() + annotation?: string + + @Field(() => String, { + nullable: true, + description: 'Highlight color (yellow, red, green, blue)', + }) + @IsOptional() + @IsString() + @IsIn(['yellow', 'red', 'green', 'blue']) + color?: string +} diff --git a/packages/api-nest/src/highlight/dto/highlight.type.ts b/packages/api-nest/src/highlight/dto/highlight.type.ts new file mode 100644 index 000000000..7bc32f8c0 --- /dev/null +++ b/packages/api-nest/src/highlight/dto/highlight.type.ts @@ -0,0 +1,64 @@ +import { Field, Float, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql' +import { HighlightType, RepresentationType } from '../entities/highlight.entity' + +registerEnumType(HighlightType, { + name: 'HighlightType', +}) + +registerEnumType(RepresentationType, { + name: 'RepresentationType', +}) + +@ObjectType() +export class Highlight { + @Field(() => ID) + id!: string + + @Field() + shortId!: string + + @Field() + libraryItemId!: string + + @Field({ nullable: true }) + quote?: string | null + + @Field({ nullable: true }) + prefix?: string | null + + @Field({ nullable: true }) + suffix?: string | null + + @Field({ nullable: true }) + patch?: string | null + + @Field({ nullable: true }) + annotation?: string | null + + @Field(() => Date) + createdAt!: Date + + @Field(() => Date) + updatedAt!: Date + + @Field(() => Date, { nullable: true }) + sharedAt?: Date | null + + @Field(() => Float) + highlightPositionPercent!: number + + @Field(() => Int) + highlightPositionAnchorIndex!: number + + @Field(() => HighlightType) + highlightType!: HighlightType + + @Field({ nullable: true }) + html?: string | null + + @Field({ nullable: true }) + color?: string | null + + @Field(() => RepresentationType) + representation!: RepresentationType +} diff --git a/packages/api-nest/src/highlight/entities/highlight.entity.ts b/packages/api-nest/src/highlight/entities/highlight.entity.ts new file mode 100644 index 000000000..6fa0408be --- /dev/null +++ b/packages/api-nest/src/highlight/entities/highlight.entity.ts @@ -0,0 +1,96 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { User } from '../../user/entities/user.entity' +import { LibraryItemEntity } from '../../library/entities/library-item.entity' + +export enum HighlightType { + HIGHLIGHT = 'HIGHLIGHT', + REDACTION = 'REDACTION', + NOTE = 'NOTE', // Legacy - being phased out in favor of library_item.note +} + +export enum RepresentationType { + CONTENT = 'CONTENT', + FEED_CONTENT = 'FEED_CONTENT', +} + +@Entity({ name: 'highlight', schema: 'omnivore' }) +export class HighlightEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column({ name: 'short_id', type: 'varchar', length: 14 }) + shortId!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string + + @ManyToOne(() => LibraryItemEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'library_item_id' }) + libraryItem!: LibraryItemEntity + + @Column({ name: 'library_item_id', type: 'uuid' }) + libraryItemId!: string + + @Column({ type: 'text', nullable: true }) + quote?: string | null + + @Column({ type: 'varchar', length: 5000, nullable: true }) + prefix?: string | null + + @Column({ type: 'varchar', length: 5000, nullable: true }) + suffix?: string | null + + @Column({ type: 'text', nullable: true }) + patch?: string | null + + @Column({ type: 'text', nullable: true }) + annotation?: string | null + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date + + @Column({ name: 'shared_at', type: 'timestamptz', nullable: true }) + sharedAt?: Date | null + + @Column({ name: 'highlight_position_percent', type: 'real', default: 0 }) + highlightPositionPercent!: number + + @Column({ name: 'highlight_position_anchor_index', type: 'integer', default: 0 }) + highlightPositionAnchorIndex!: number + + @Column({ + name: 'highlight_type', + type: 'enum', + enum: HighlightType, + default: HighlightType.HIGHLIGHT, + }) + highlightType!: HighlightType + + @Column({ type: 'text', nullable: true }) + html?: string | null + + @Column({ type: 'text', nullable: true }) + color?: string | null + + @Column({ + type: 'enum', + enum: RepresentationType, + default: RepresentationType.CONTENT, + }) + representation!: RepresentationType +} diff --git a/packages/api-nest/src/highlight/highlight.module.ts b/packages/api-nest/src/highlight/highlight.module.ts new file mode 100644 index 000000000..f5e5c03d8 --- /dev/null +++ b/packages/api-nest/src/highlight/highlight.module.ts @@ -0,0 +1,13 @@ +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' + +@Module({ + imports: [TypeOrmModule.forFeature([HighlightEntity, LibraryItemEntity])], + providers: [HighlightService, HighlightResolver], + exports: [HighlightService], +}) +export class HighlightModule {} diff --git a/packages/api-nest/src/highlight/highlight.resolver.ts b/packages/api-nest/src/highlight/highlight.resolver.ts new file mode 100644 index 000000000..44bca5782 --- /dev/null +++ b/packages/api-nest/src/highlight/highlight.resolver.ts @@ -0,0 +1,126 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' +import { UseGuards } from '@nestjs/common' +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' +import { CurrentUser } from '../user/decorators/current-user.decorator' +import { User } from '../user/entities/user.entity' +import { HighlightService } from './highlight.service' +import { Highlight } from './dto/highlight.type' +import { + CreateHighlightInput, + UpdateHighlightInput, +} from './dto/highlight-inputs.type' +import { DeleteResult } from '../library/dto/library-inputs.type' + +@Resolver(() => Highlight) +export class HighlightResolver { + constructor(private readonly highlightService: HighlightService) {} + + // ==================== QUERIES ==================== + + @Query(() => [Highlight], { + description: 'Get all highlights for a library item', + }) + @UseGuards(JwtAuthGuard) + async highlights( + @CurrentUser() user: User, + @Args('libraryItemId', { + type: () => String, + description: 'Library item ID', + }) + libraryItemId: string, + ): Promise { + const entities = await this.highlightService.findByLibraryItem( + user.id, + libraryItemId, + ) + return entities.map(mapEntityToGraph) + } + + @Query(() => Highlight, { + nullable: true, + description: 'Get a single highlight by ID', + }) + @UseGuards(JwtAuthGuard) + async highlight( + @CurrentUser() user: User, + @Args('id', { type: () => String, description: 'Highlight ID' }) + id: string, + ): Promise { + const entity = await this.highlightService.findById(user.id, id) + return entity ? mapEntityToGraph(entity) : null + } + + // ==================== MUTATIONS ==================== + + @Mutation(() => Highlight, { + description: 'Create a new highlight with optional color', + }) + @UseGuards(JwtAuthGuard) + async createHighlight( + @CurrentUser() user: User, + @Args('input', { + type: () => CreateHighlightInput, + description: 'Highlight creation data', + }) + input: CreateHighlightInput, + ): Promise { + const entity = await this.highlightService.createHighlight(user.id, input) + return mapEntityToGraph(entity) + } + + @Mutation(() => Highlight, { + description: 'Update a highlight (annotation and/or color)', + }) + @UseGuards(JwtAuthGuard) + async updateHighlight( + @CurrentUser() user: User, + @Args('id', { type: () => String, description: 'Highlight ID' }) + id: string, + @Args('input', { + type: () => UpdateHighlightInput, + description: 'Highlight update data', + }) + input: UpdateHighlightInput, + ): Promise { + const entity = await this.highlightService.updateHighlight( + user.id, + id, + input, + ) + return mapEntityToGraph(entity) + } + + @Mutation(() => DeleteResult, { + description: 'Delete a highlight', + }) + @UseGuards(JwtAuthGuard) + async deleteHighlight( + @CurrentUser() user: User, + @Args('id', { type: () => String, description: 'Highlight ID' }) + id: string, + ): Promise { + return await this.highlightService.deleteHighlight(user.id, id) + } +} + +function mapEntityToGraph(entity: any): Highlight { + return { + id: entity.id, + shortId: entity.shortId, + libraryItemId: entity.libraryItemId, + quote: entity.quote ?? null, + prefix: entity.prefix ?? null, + suffix: entity.suffix ?? null, + patch: entity.patch ?? null, + annotation: entity.annotation ?? null, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + sharedAt: entity.sharedAt ?? null, + highlightPositionPercent: entity.highlightPositionPercent ?? 0, + highlightPositionAnchorIndex: entity.highlightPositionAnchorIndex ?? 0, + highlightType: entity.highlightType, + html: entity.html ?? null, + color: entity.color ?? 'yellow', + representation: entity.representation, + } +} diff --git a/packages/api-nest/src/highlight/highlight.service.ts b/packages/api-nest/src/highlight/highlight.service.ts new file mode 100644 index 000000000..5526a7528 --- /dev/null +++ b/packages/api-nest/src/highlight/highlight.service.ts @@ -0,0 +1,164 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} 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' + +@Injectable() +export class HighlightService { + private readonly logger = new Logger(HighlightService.name) + + constructor( + @InjectRepository(HighlightEntity) + private readonly highlightRepository: Repository, + @InjectRepository(LibraryItemEntity) + private readonly libraryItemRepository: Repository, + ) {} + + /** + * Get all highlights for a library item + */ + async findByLibraryItem( + userId: string, + libraryItemId: string, + ): Promise { + // Verify the library item belongs to the user + const libraryItem = await this.libraryItemRepository.findOne({ + where: { id: libraryItemId, userId }, + }) + + if (!libraryItem) { + throw new NotFoundException( + `Library item with ID ${libraryItemId} not found`, + ) + } + + return this.highlightRepository.find({ + where: { + libraryItemId, + userId, + }, + order: { + highlightPositionPercent: 'ASC', + }, + }) + } + + /** + * Get a single highlight by ID + */ + async findById(userId: string, id: string): Promise { + return this.highlightRepository.findOne({ + where: { + id, + userId, + }, + }) + } + + /** + * Create a new highlight + */ + async createHighlight( + userId: string, + input: CreateHighlightInput, + ): Promise { + // Verify the library item exists and belongs to the user + const libraryItem = await this.libraryItemRepository.findOne({ + where: { id: input.libraryItemId, userId }, + }) + + if (!libraryItem) { + throw new NotFoundException( + `Library item with ID ${input.libraryItemId} not found`, + ) + } + + // Generate a short ID (8 characters) + const shortId = this.generateShortId() + + const highlight = this.highlightRepository.create({ + userId, + libraryItemId: input.libraryItemId, + shortId, + quote: input.quote, + prefix: input.prefix, + suffix: input.suffix, + annotation: input.annotation, + highlightPositionPercent: input.highlightPositionPercent ?? 0, + highlightPositionAnchorIndex: input.highlightPositionAnchorIndex ?? 0, + color: input.color ?? 'yellow', + html: input.html, + highlightType: HighlightType.HIGHLIGHT, + representation: 'CONTENT' as any, + }) + + return this.highlightRepository.save(highlight) + } + + /** + * Update an existing highlight + */ + async updateHighlight( + userId: string, + id: string, + input: UpdateHighlightInput, + ): Promise { + const highlight = await this.findById(userId, id) + + if (!highlight) { + throw new NotFoundException(`Highlight with ID ${id} not found`) + } + + // Update only the fields that are provided + if (input.annotation !== undefined) { + highlight.annotation = input.annotation + } + + if (input.color !== undefined) { + highlight.color = input.color + } + + return this.highlightRepository.save(highlight) + } + + /** + * Delete a highlight + */ + async deleteHighlight( + userId: string, + id: string, + ): Promise<{ success: boolean; message?: string; itemId: string }> { + const highlight = await this.findById(userId, id) + + if (!highlight) { + throw new NotFoundException(`Highlight with ID ${id} not found`) + } + + await this.highlightRepository.remove(highlight) + + return { + success: true, + message: 'Highlight deleted successfully', + itemId: id, + } + } + + /** + * Generate a short ID for the highlight + */ + private generateShortId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + let result = '' + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result + } +} diff --git a/packages/api-nest/src/library/dto/library-inputs.type.ts b/packages/api-nest/src/library/dto/library-inputs.type.ts index c03df41a1..279dfa2f6 100644 --- a/packages/api-nest/src/library/dto/library-inputs.type.ts +++ b/packages/api-nest/src/library/dto/library-inputs.type.ts @@ -205,3 +205,13 @@ export class SaveUrlInput { @IsIn(['web', 'mobile', 'api', 'extension']) source?: 'web' | 'mobile' | 'api' | 'extension' } + +/** + * Input type for updating notebook content + */ +@InputType() +export class UpdateNotebookInput { + @Field(() => String, { description: 'Notebook content (supports markdown)' }) + @IsString() + note: string +} diff --git a/packages/api-nest/src/library/dto/library-item.type.ts b/packages/api-nest/src/library/dto/library-item.type.ts index e745a200d..fd3da37d6 100644 --- a/packages/api-nest/src/library/dto/library-item.type.ts +++ b/packages/api-nest/src/library/dto/library-item.type.ts @@ -65,6 +65,12 @@ export class LibraryItem { @Field({ nullable: true }) content?: string | null + + @Field({ nullable: true }) + note?: string | null + + @Field(() => Date, { nullable: true }) + noteUpdatedAt?: Date | null } @ObjectType() diff --git a/packages/api-nest/src/library/entities/library-item.entity.ts b/packages/api-nest/src/library/entities/library-item.entity.ts index 32c3e6ed3..be022e57c 100644 --- a/packages/api-nest/src/library/entities/library-item.entity.ts +++ b/packages/api-nest/src/library/entities/library-item.entity.ts @@ -119,6 +119,12 @@ export class LibraryItemEntity { @Column({ name: 'readable_content', type: 'text', default: '' }) readableContent!: string + @Column({ type: 'text', nullable: true }) + note?: string | null + + @Column({ name: 'note_updated_at', type: 'timestamptz', nullable: true }) + noteUpdatedAt?: Date | null + @OneToMany(() => EntityLabel, (entityLabel) => entityLabel.libraryItem) entityLabels!: EntityLabel[] } diff --git a/packages/api-nest/src/library/library.resolver.ts b/packages/api-nest/src/library/library.resolver.ts index de6791a90..9f8f7a0b6 100644 --- a/packages/api-nest/src/library/library.resolver.ts +++ b/packages/api-nest/src/library/library.resolver.ts @@ -10,6 +10,7 @@ import { DeleteResult, LibrarySearchInput, SaveUrlInput, + UpdateNotebookInput, } from './dto/library-inputs.type' import { LabelService } from '../label/label.service' import { Label } from '../label/dto/label.type' @@ -122,6 +123,28 @@ export class LibraryResolver { return mapEntityToGraph(entity) } + @Mutation(() => LibraryItem, { + description: 'Update notebook content for a library item', + }) + @UseGuards(JwtAuthGuard) + async updateNotebook( + @CurrentUser() user: User, + @Args('id', { type: () => String, description: 'Library item ID' }) + id: string, + @Args('input', { + type: () => UpdateNotebookInput, + description: 'Notebook content', + }) + input: UpdateNotebookInput, + ): Promise { + const entity = await this.libraryService.updateNotebook( + user.id, + id, + input.note, + ) + return mapEntityToGraph(entity) + } + @Mutation(() => LibraryItem, { description: 'Move a library item to a different folder', }) @@ -247,6 +270,8 @@ function mapEntityToGraph(entity: any): LibraryItem { contentReader: entity.contentReader, folder: entity.folder, content: entity.readableContent ?? null, + note: entity.note ?? null, + noteUpdatedAt: entity.noteUpdatedAt ?? null, labels: null, // Labels will be resolved by the field resolver } } diff --git a/packages/api-nest/src/library/library.service.ts b/packages/api-nest/src/library/library.service.ts index 5dc4c72ed..eb8477aa5 100644 --- a/packages/api-nest/src/library/library.service.ts +++ b/packages/api-nest/src/library/library.service.ts @@ -705,6 +705,32 @@ export class LibraryService { return savedItem } + /** + * Update notebook content for a library item + * @param userId - User ID who owns the item + * @param itemId - Library item ID + * @param note - Notebook content (supports markdown) + * @returns Updated library item + */ + async updateNotebook( + userId: string, + itemId: string, + note: string, + ): Promise { + const item = await this.findById(userId, itemId) + + if (!item) { + throw new NotFoundException(`Library item with ID ${itemId} not found`) + } + + // Update note and timestamp + item.note = note + item.noteUpdatedAt = new Date() + + await this.libraryRepository.save(item) + return item + } + /** * Generate a slug from URL */ diff --git a/packages/api-nest/test/helpers/test-db-setup.ts b/packages/api-nest/test/helpers/test-db-setup.ts new file mode 100644 index 000000000..4751ca1dd --- /dev/null +++ b/packages/api-nest/test/helpers/test-db-setup.ts @@ -0,0 +1,166 @@ +import { DataSource } from 'typeorm' +import { testDatabaseConfig } from '../../src/config/test.config' + +/** + * Test Database Setup Utility + * + * Provides utilities for managing test database lifecycle: + * - Creating test database before tests + * - Cleaning up test database after tests + * - Ensuring database isolation + */ + +/** + * Creates a PostgreSQL connection to the default 'postgres' database + * Used to create/drop test databases + */ +async function getAdminConnection(): Promise { + const adminConfig = { + ...testDatabaseConfig, + database: 'postgres', // Connect to default postgres database + } + + // Remove TypeORM-specific options that DataSource doesn't use + const { entities, synchronize, logging, ...connectionOptions } = adminConfig as any + + const dataSource = new DataSource(connectionOptions as any) + await dataSource.initialize() + return dataSource +} + +/** + * Creates the test database if it doesn't exist + * This should be run before test suite starts + */ +export async function createTestDatabase(): Promise { + const testDbName = testDatabaseConfig.database as string + + console.log(`\nšŸ”§ Setting up test database: ${testDbName}`) + + let adminConnection: DataSource | null = null + + try { + adminConnection = await getAdminConnection() + + // Check if database exists + const result = await adminConnection.query( + `SELECT 1 FROM pg_database WHERE datname = $1`, + [testDbName] + ) + + if (result.length === 0) { + // Database doesn't exist, create it + await adminConnection.query(`CREATE DATABASE ${testDbName}`) + console.log(`āœ… Created test database: ${testDbName}`) + } else { + console.log(`ā„¹ļø Test database already exists: ${testDbName}`) + } + } catch (error) { + console.error(`āŒ Failed to create test database:`, error) + throw error + } finally { + if (adminConnection) { + await adminConnection.destroy() + } + } +} + +/** + * Drops the test database completely + * WARNING: This will delete ALL test data + * This should be run after test suite completes + */ +export async function dropTestDatabase(): Promise { + const testDbName = testDatabaseConfig.database as string + + console.log(`\n🧹 Cleaning up test database: ${testDbName}`) + + let adminConnection: DataSource | null = null + + try { + adminConnection = await getAdminConnection() + + // Terminate all active connections to the test database + await adminConnection.query(` + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = $1 + AND pid <> pg_backend_pid() + `, [testDbName]) + + // Drop the database + await adminConnection.query(`DROP DATABASE IF EXISTS ${testDbName}`) + console.log(`āœ… Dropped test database: ${testDbName}`) + } catch (error) { + console.error(`āŒ Failed to drop test database:`, error) + // Don't throw - cleanup failures shouldn't fail tests + } finally { + if (adminConnection) { + await adminConnection.destroy() + } + } +} + +/** + * Truncates all tables in the test database + * Faster than dropping/recreating for between-test cleanup + */ +export async function cleanTestDatabase(): Promise { + const testDbName = testDatabaseConfig.database as string + + let testConnection: DataSource | null = null + + try { + const { entities, synchronize, logging, ...connectionOptions } = testDatabaseConfig as any + testConnection = new DataSource(connectionOptions as any) + await testConnection.initialize() + + // Get all table names from omnivore schema + const tables = await testConnection.query(` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'omnivore' + `) + + if (tables.length > 0) { + const tableNames = tables.map((t: any) => `omnivore.${t.tablename}`).join(', ') + + // Truncate all tables with CASCADE to handle foreign keys + await testConnection.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE`) + + console.log(`āœ… Cleaned ${tables.length} tables in test database`) + } + } catch (error) { + console.error(`āŒ Failed to clean test database:`, error) + throw error + } finally { + if (testConnection) { + await testConnection.destroy() + } + } +} + +/** + * Ensures test database exists and runs migrations + * Call this in your test setup (e.g., jest globalSetup) + */ +export async function setupTestDatabase(): Promise { + await createTestDatabase() + + // Note: Migrations should be run separately using the migration script + // This is to ensure the test database schema matches production + console.log(`\nāš ļø Remember to run migrations on test database:`) + console.log(` TEST_DATABASE_NAME=omnivore_test npm run migration:run\n`) +} + +/** + * Tears down test database + * Call this in your test teardown (e.g., jest globalTeardown) + */ +export async function teardownTestDatabase(): Promise { + // Option 1: Drop entire database (clean slate for next run) + // await dropTestDatabase() + + // Option 2: Just clean tables (faster, keeps schema) + await cleanTestDatabase() +} diff --git a/packages/api-nest/test/highlight.e2e-spec.ts b/packages/api-nest/test/highlight.e2e-spec.ts new file mode 100644 index 000000000..72d30abb3 --- /dev/null +++ b/packages/api-nest/test/highlight.e2e-spec.ts @@ -0,0 +1,758 @@ +import { randomUUID } from 'crypto' +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import request from 'supertest' +import { Repository } from 'typeorm' +import { AppModule } from '../src/app/app.module' +import { testDatabaseConfig } from '../src/config/test.config' +import { + ContentReaderType, + LibraryItemEntity, + LibraryItemState, +} from '../src/library/entities/library-item.entity' +import { HighlightEntity } from '../src/highlight/entities/highlight.entity' + +const HIGHLIGHTS_QUERY = ` + query Highlights($libraryItemId: String!) { + highlights(libraryItemId: $libraryItemId) { + id + shortId + quote + annotation + color + highlightPositionPercent + createdAt + updatedAt + } + } +` + +const HIGHLIGHT_QUERY = ` + query Highlight($id: String!) { + highlight(id: $id) { + id + quote + annotation + color + } + } +` + +const CREATE_HIGHLIGHT_MUTATION = ` + mutation CreateHighlight($input: CreateHighlightInput!) { + createHighlight(input: $input) { + id + shortId + quote + annotation + color + highlaryPositionPercent + createdAt + } + } +` + +const UPDATE_HIGHLIGHT_MUTATION = ` + mutation UpdateHighlight($id: String!, $input: UpdateHighlightInput!) { + updateHighlight(id: $id, input: $input) { + id + annotation + color + updatedAt + } + } +` + +const DELETE_HIGHLIGHT_MUTATION = ` + mutation DeleteHighlight($id: String!) { + deleteHighlight(id: $id) { + success + message + itemId + } + } +` + +describe('Highlight GraphQL (e2e)', () => { + let app: INestApplication + let authToken: string + let userId: string + let libraryRepository: Repository + let highlightRepository: Repository + let testLibraryItemId: string + + beforeAll(async () => { + // Set required environment variables for tests + process.env.GOOGLE_CLIENT_ID = 'test-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' + process.env.JWT_SECRET = 'test-jwt-secret' + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(TypeOrmModule) + .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + libraryRepository = moduleFixture.get>( + getRepositoryToken(LibraryItemEntity), + ) + + highlightRepository = moduleFixture.get>( + getRepositoryToken(HighlightEntity), + ) + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: `highlight-test-${Date.now()}@omnivore.app`, + name: 'Highlight Test User', + password: 'highlightPassword123', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + + // Create a test library item for all highlight tests + const testItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Test Article for Highlights', + slug: `highlight-article-${Date.now()}`, + originalUrl: `https://example.com/highlight-test-${Date.now()}`, + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + readableContent: 'This is the content of the article that can be highlighted.', + }) + + const saved = await libraryRepository.save(testItem) + testLibraryItemId = saved.id + }) + + afterAll(async () => { + await app.close() + }, 30000) + + const executeQuery = (query: string, variables: Record = {}) => + request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + .expect(200) + + describe('Query highlights', () => { + beforeAll(async () => { + // Create test highlights with different colors + const highlights = [ + { + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: 'test001', + quote: 'First important quote', + annotation: 'This is significant', + color: 'yellow', + highlightPositionPercent: 10, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: 'test002', + quote: 'Second important quote', + annotation: 'Very interesting', + color: 'green', + highlightPositionPercent: 25, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: 'test003', + quote: 'Third important quote', + color: 'red', + highlightPositionPercent: 50, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: 'test004', + quote: 'Fourth important quote', + annotation: 'Key insight', + color: 'blue', + highlightPositionPercent: 75, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }, + ] + + await highlightRepository.save(highlights) + }) + + it('retrieves all highlights for a library item', async () => { + const response = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: testLibraryItemId, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.highlights).toHaveLength(4) + expect(response.body.data.highlights[0]).toHaveProperty('id') + expect(response.body.data.highlights[0]).toHaveProperty('quote') + expect(response.body.data.highlights[0]).toHaveProperty('color') + }) + + it('returns highlights sorted by position', async () => { + const response = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: testLibraryItemId, + }) + + expect(response.body.errors).toBeUndefined() + const positions = response.body.data.highlights.map( + (h: any) => h.highlightPositionPercent, + ) + expect(positions).toEqual([10, 25, 50, 75]) + }) + + it('returns highlights with all color variations', async () => { + const response = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: testLibraryItemId, + }) + + expect(response.body.errors).toBeUndefined() + const colors = response.body.data.highlights.map((h: any) => h.color) + expect(colors).toContain('yellow') + expect(colors).toContain('green') + expect(colors).toContain('red') + expect(colors).toContain('blue') + }) + + it('retrieves a single highlight by id', async () => { + const existing = await highlightRepository.findOneBy({ + libraryItemId: testLibraryItemId, + shortId: 'test001', + }) + + const response = await executeQuery(HIGHLIGHT_QUERY, { + id: existing!.id, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.highlight).toMatchObject({ + id: existing!.id, + quote: 'First important quote', + annotation: 'This is significant', + color: 'yellow', + }) + }) + + it('returns empty array for library item with no highlights', async () => { + const emptyItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Empty Article', + slug: `empty-${Date.now()}`, + originalUrl: `https://example.com/empty-${Date.now()}`, + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + }) + + const saved = await libraryRepository.save(emptyItem) + + const response = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: saved.id, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.highlights).toHaveLength(0) + }) + }) + + describe('createHighlight', () => { + it('creates a highlight with default yellow color', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'New highlight quote', + annotation: 'My thoughts', + highlightPositionPercent: 33, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight).toMatchObject({ + quote: 'New highlight quote', + annotation: 'My thoughts', + color: 'yellow', + }) + expect(response.body.data.createHighlight.id).toBeTruthy() + expect(response.body.data.createHighlight.shortId).toBeTruthy() + + // Verify in database + const highlight = await highlightRepository.findOneBy({ + id: response.body.data.createHighlight.id, + }) + expect(highlight?.color).toBe('yellow') + }) + + it('creates a highlight with red color', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Important red highlight', + color: 'red', + highlightPositionPercent: 42, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight).toMatchObject({ + quote: 'Important red highlight', + color: 'red', + }) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ + id: response.body.data.createHighlight.id, + }) + expect(highlight?.color).toBe('red') + }) + + it('creates a highlight with green color', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Positive green highlight', + color: 'green', + highlightPositionPercent: 55, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.color).toBe('green') + }) + + it('creates a highlight with blue color', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Information blue highlight', + color: 'blue', + highlightPositionPercent: 68, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.color).toBe('blue') + }) + + it('creates a highlight without annotation', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Quote without annotation', + color: 'yellow', + highlightPositionPercent: 20, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.quote).toBe( + 'Quote without annotation', + ) + expect(response.body.data.createHighlight.annotation).toBeNull() + }) + + it('creates a highlight with prefix and suffix context', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'highlighted text', + prefix: 'This is the ', + suffix: ' with context', + color: 'yellow', + highlightPositionPercent: 30, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight).toMatchObject({ + quote: 'highlighted text', + }) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ + id: response.body.data.createHighlight.id, + }) + expect(highlight?.prefix).toBe('This is the ') + expect(highlight?.suffix).toBe(' with context') + }) + + it('returns error for invalid color', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Test quote', + color: 'purple', // Invalid color + highlightPositionPercent: 40, + }, + }) + + expect(response.body.errors).toBeDefined() + }) + + it('returns error for non-existent library item', async () => { + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: randomUUID(), + quote: 'Test quote', + color: 'yellow', + highlightPositionPercent: 40, + }, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + + it('generates unique shortId for each highlight', async () => { + const response1 = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'First quote', + color: 'yellow', + highlightPositionPercent: 11, + }, + }) + + const response2 = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Second quote', + color: 'yellow', + highlightPositionPercent: 12, + }, + }) + + expect(response1.body.data.createHighlight.shortId).not.toBe( + response2.body.data.createHighlight.shortId, + ) + }) + }) + + describe('updateHighlight', () => { + let testHighlightId: string + + beforeEach(async () => { + const highlight = highlightRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: `update-${Date.now()}`, + quote: 'Original quote', + annotation: 'Original annotation', + color: 'yellow', + highlightPositionPercent: 50, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }) + + const saved = await highlightRepository.save(highlight) + testHighlightId = saved.id + }) + + it('updates highlight annotation', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { annotation: 'Updated annotation' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateHighlight).toMatchObject({ + id: testHighlightId, + annotation: 'Updated annotation', + color: 'yellow', // Unchanged + }) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(highlight?.annotation).toBe('Updated annotation') + expect(highlight?.color).toBe('yellow') + }) + + it('updates highlight color from yellow to red', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { color: 'red' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateHighlight).toMatchObject({ + id: testHighlightId, + color: 'red', + annotation: 'Original annotation', // Unchanged + }) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(highlight?.color).toBe('red') + expect(highlight?.annotation).toBe('Original annotation') + }) + + it('updates both annotation and color simultaneously', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { + annotation: 'New annotation', + color: 'blue', + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateHighlight).toMatchObject({ + id: testHighlightId, + annotation: 'New annotation', + color: 'blue', + }) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(highlight?.annotation).toBe('New annotation') + expect(highlight?.color).toBe('blue') + }) + + it('clears annotation with empty string', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { annotation: '' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateHighlight.annotation).toBe('') + + // Verify in database + const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(highlight?.annotation).toBe('') + }) + + it('cycles through all color options', async () => { + const colors = ['yellow', 'red', 'green', 'blue'] + + for (const color of colors) { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { color }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateHighlight.color).toBe(color) + } + + // Verify final state in database + const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(highlight?.color).toBe('blue') + }) + + it('returns error for invalid color', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { color: 'orange' }, // Invalid color + }) + + expect(response.body.errors).toBeDefined() + }) + + it('returns error for non-existent highlight', async () => { + const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: randomUUID(), + input: { annotation: 'Test' }, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + + it('updates updatedAt timestamp', async () => { + const before = await highlightRepository.findOneBy({ id: testHighlightId }) + const originalUpdatedAt = before!.updatedAt + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)) + + await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: testHighlightId, + input: { annotation: 'Updated' }, + }) + + const after = await highlightRepository.findOneBy({ id: testHighlightId }) + expect(after!.updatedAt.getTime()).toBeGreaterThan( + originalUpdatedAt.getTime(), + ) + }) + }) + + describe('deleteHighlight', () => { + it('deletes a highlight successfully', async () => { + const highlight = highlightRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + libraryItemId: testLibraryItemId, + libraryItem: { id: testLibraryItemId } as any, + shortId: `delete-${Date.now()}`, + quote: 'To be deleted', + color: 'yellow', + highlightPositionPercent: 50, + highlightPositionAnchorIndex: 0, + highlightType: 'HIGHLIGHT' as any, + representation: 'CONTENT' as any, + }) + + const saved = await highlightRepository.save(highlight) + + const response = await executeQuery(DELETE_HIGHLIGHT_MUTATION, { + id: saved.id, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.deleteHighlight).toMatchObject({ + success: true, + message: 'Highlight deleted successfully', + itemId: saved.id, + }) + + // Verify deleted in database + const deleted = await highlightRepository.findOneBy({ id: saved.id }) + expect(deleted).toBeNull() + }) + + it('returns error for non-existent highlight', async () => { + const response = await executeQuery(DELETE_HIGHLIGHT_MUTATION, { + id: randomUUID(), + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + + describe('Color-based workflows', () => { + it('supports creating highlights with different colors for organization', async () => { + // Create highlights with semantic colors + const importantQuote = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Critical information', + annotation: 'Must remember', + color: 'red', + highlightPositionPercent: 15, + }, + }) + + const actionItem = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'To do item', + annotation: 'Action required', + color: 'green', + highlightPositionPercent: 35, + }, + }) + + const reference = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Reference material', + annotation: 'For later', + color: 'blue', + highlightPositionPercent: 65, + }, + }) + + expect(importantQuote.body.data.createHighlight.color).toBe('red') + expect(actionItem.body.data.createHighlight.color).toBe('green') + expect(reference.body.data.createHighlight.color).toBe('blue') + + // Verify all highlights are retrievable + const allHighlights = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: testLibraryItemId, + }) + + const colors = allHighlights.body.data.highlights.map((h: any) => h.color) + expect(colors).toContain('red') + expect(colors).toContain('green') + expect(colors).toContain('blue') + }) + + it('supports changing highlight color based on re-evaluation', async () => { + // Create with initial color + const createResponse = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'Initially interesting', + color: 'yellow', + highlightPositionPercent: 45, + }, + }) + + const highlightId = createResponse.body.data.createHighlight.id + + // Re-evaluate as more important + const updateResponse = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { + id: highlightId, + input: { + color: 'red', + annotation: 'Actually very important!', + }, + }) + + expect(updateResponse.body.data.updateHighlight).toMatchObject({ + color: 'red', + annotation: 'Actually very important!', + }) + }) + }) +}) diff --git a/packages/api-nest/test/notebook.e2e-spec.ts b/packages/api-nest/test/notebook.e2e-spec.ts new file mode 100644 index 000000000..56efa36d3 --- /dev/null +++ b/packages/api-nest/test/notebook.e2e-spec.ts @@ -0,0 +1,285 @@ +import { randomUUID } from 'crypto' +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import request from 'supertest' +import { Repository } from 'typeorm' +import { AppModule } from '../src/app/app.module' +import { testDatabaseConfig } from '../src/config/test.config' +import { + ContentReaderType, + LibraryItemEntity, + LibraryItemState, +} from '../src/library/entities/library-item.entity' + +const LIBRARY_ITEM_QUERY = ` + query LibraryItem($id: String!) { + libraryItem(id: $id) { + id + title + note + noteUpdatedAt + } + } +` + +const UPDATE_NOTEBOOK_MUTATION = ` + mutation UpdateNotebook($id: String!, $input: UpdateNotebookInput!) { + updateNotebook(id: $id, input: $input) { + id + note + noteUpdatedAt + } + } +` + +describe('Notebook GraphQL (e2e)', () => { + let app: INestApplication + let authToken: string + let userId: string + let libraryRepository: Repository + + beforeAll(async () => { + // Set required environment variables for tests + process.env.GOOGLE_CLIENT_ID = 'test-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' + process.env.JWT_SECRET = 'test-jwt-secret' + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(TypeOrmModule) + .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + libraryRepository = moduleFixture.get>( + getRepositoryToken(LibraryItemEntity), + ) + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: `notebook-test-${Date.now()}@omnivore.app`, + name: 'Notebook Test User', + password: 'notebookPassword123', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + }) + + afterAll(async () => { + await app.close() + }, 30000) + + const executeQuery = (query: string, variables: Record = {}) => + request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + .expect(200) + + describe('updateNotebook', () => { + let testItemId: string + + beforeEach(async () => { + const timestamp = Date.now() + const testItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Test Article with Notebook', + slug: `notebook-test-${timestamp}`, + originalUrl: `https://example.com/notebook-test-${timestamp}`, + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + }) + + const saved = await libraryRepository.save(testItem) + testItemId = saved.id + }) + + it('creates a new notebook for a library item', async () => { + const noteContent = '# My Thoughts\n\nThis is an interesting article about TypeScript.' + + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: noteContent }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateNotebook).toMatchObject({ + id: testItemId, + note: noteContent, + }) + expect(response.body.data.updateNotebook.noteUpdatedAt).toBeTruthy() + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe(noteContent) + expect(item?.noteUpdatedAt).toBeInstanceOf(Date) + }) + + it('updates an existing notebook', async () => { + const initialNote = 'Initial thoughts' + await libraryRepository.update(testItemId, { note: initialNote }) + + const updatedNote = 'Updated thoughts with more details' + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: updatedNote }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateNotebook).toMatchObject({ + id: testItemId, + note: updatedNote, + }) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe(updatedNote) + expect(item?.noteUpdatedAt).toBeInstanceOf(Date) + }) + + it('clears a notebook with empty string', async () => { + await libraryRepository.update(testItemId, { note: 'Some notes' }) + + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: '' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateNotebook.note).toBe('') + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe('') + }) + + it('supports markdown formatting in notebooks', async () => { + const markdownNote = `# Summary + +## Key Points +- **Important**: TypeScript provides type safety +- *Interesting*: Works well with React +- \`code example\`: const x: number = 5 + +### Links +[Official Docs](https://typescriptlang.org)` + + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: markdownNote }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateNotebook.note).toBe(markdownNote) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe(markdownNote) + }) + + it('handles long notebook content', async () => { + const longNote = 'A'.repeat(10000) // 10KB of text + + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: longNote }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateNotebook.note).toBe(longNote) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe(longNote) + }) + + it('returns error for non-existent library item', async () => { + const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: randomUUID(), + input: { note: 'Test note' }, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + + it('retrieves notebook via libraryItem query', async () => { + const noteContent = 'My personal notes about this article' + await libraryRepository.update(testItemId, { note: noteContent }) + + const response = await executeQuery(LIBRARY_ITEM_QUERY, { + id: testItemId, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItem).toMatchObject({ + id: testItemId, + note: noteContent, + }) + expect(response.body.data.libraryItem.noteUpdatedAt).toBeTruthy() + }) + + it('updates noteUpdatedAt timestamp on each update', async () => { + // First update + const firstResponse = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: 'First version' }, + }) + + const firstTimestamp = new Date( + firstResponse.body.data.updateNotebook.noteUpdatedAt, + ) + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Second update + const secondResponse = await executeQuery(UPDATE_NOTEBOOK_MUTATION, { + id: testItemId, + input: { note: 'Second version' }, + }) + + const secondTimestamp = new Date( + secondResponse.body.data.updateNotebook.noteUpdatedAt, + ) + + expect(secondTimestamp.getTime()).toBeGreaterThan(firstTimestamp.getTime()) + }) + + it('preserves notebook when updating other library item fields', async () => { + const noteContent = 'My preserved notes' + await libraryRepository.update(testItemId, { note: noteContent }) + + // Update reading progress (different field) + await libraryRepository.update(testItemId, { + readingProgressTopPercent: 50, + }) + + // Verify notebook is preserved + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.note).toBe(noteContent) + expect(item?.readingProgressTopPercent).toBe(50) + }) + }) +}) diff --git a/packages/db/migrations/0192.do.consolidate_notebooks.sql b/packages/db/migrations/0192.do.consolidate_notebooks.sql new file mode 100644 index 000000000..fcd314cfe --- /dev/null +++ b/packages/db/migrations/0192.do.consolidate_notebooks.sql @@ -0,0 +1,123 @@ +-- Type: DO +-- Name: consolidate_notebooks +-- Description: Consolidate notebooks from highlight(type='NOTE') to library_item.note +-- +-- Context: The notebook feature was originally stored as a special highlight type='NOTE', +-- but migration 0120 added library_item.note as the preferred location. This migration +-- completes the consolidation by moving any remaining type='NOTE' highlights to the +-- library_item table and removing the redundancy. +-- +-- Safety: This is a read-only copy operation. Existing library_item.note values are preserved. +-- If both exist, library_item.note takes precedence (source of truth). + +BEGIN; + +-- Step 1: Add note_updated_at column to track when notebooks are modified +ALTER TABLE omnivore.library_item +ADD COLUMN IF NOT EXISTS note_updated_at timestamptz; + +-- Step 2: Log current state for verification +DO $$ +DECLARE + notes_in_lib_item integer; + notes_in_highlights integer; +BEGIN + SELECT COUNT(*) INTO notes_in_lib_item + FROM omnivore.library_item + WHERE note IS NOT NULL AND note != ''; + + SELECT COUNT(*) INTO notes_in_highlights + FROM omnivore.highlight + WHERE highlight_type = 'NOTE'; + + RAISE NOTICE 'Before migration: % notebooks in library_item, % in highlights', + notes_in_lib_item, notes_in_highlights; +END $$; + +-- Step 3: Migrate notebooks from highlights to library_item +-- Strategy: Only copy if library_item.note is NULL or empty (preserve existing notes) +UPDATE omnivore.library_item li +SET + note = h.annotation, + note_updated_at = h.updated_at, + updated_at = CURRENT_TIMESTAMP +FROM omnivore.highlight h +WHERE h.library_item_id = li.id + AND h.highlight_type = 'NOTE' + AND h.annotation IS NOT NULL + AND h.annotation != '' + AND (li.note IS NULL OR li.note = ''); + +-- Step 4: Log conflicts (library items with both note and NOTE highlight) +-- These are cases where library_item.note already exists and differs from highlight +DO $$ +DECLARE + conflict_record RECORD; + conflict_count integer := 0; +BEGIN + FOR conflict_record IN + SELECT + li.id, + li.title, + li.note, + h.annotation, + li.updated_at as note_updated, + h.updated_at as highlight_updated + FROM omnivore.library_item li + INNER JOIN omnivore.highlight h ON h.library_item_id = li.id + WHERE h.highlight_type = 'NOTE' + AND li.note IS NOT NULL + AND li.note != '' + AND h.annotation IS NOT NULL + AND h.annotation != '' + AND li.note != h.annotation + LOOP + conflict_count := conflict_count + 1; + RAISE NOTICE 'CONFLICT on library_item % ("%"): library_item.note="%..." (updated %), highlight.annotation="%..." (updated %)', + conflict_record.id, + LEFT(conflict_record.title, 30), + LEFT(conflict_record.note, 40), + conflict_record.note_updated, + LEFT(conflict_record.annotation, 40), + conflict_record.highlight_updated; + END LOOP; + + IF conflict_count > 0 THEN + RAISE NOTICE 'Found % conflicts. library_item.note was kept as source of truth.', conflict_count; + ELSE + RAISE NOTICE 'No conflicts found. All notebooks consolidated cleanly.'; + END IF; +END $$; + +-- Step 5: Delete all type='NOTE' highlights (data now safely in library_item.note) +-- This removes the redundancy and completes the consolidation +DELETE FROM omnivore.highlight +WHERE highlight_type = 'NOTE'; + +-- Step 6: Log final state +DO $$ +DECLARE + notes_after integer; + type_note_remaining integer; +BEGIN + SELECT COUNT(*) INTO notes_after + FROM omnivore.library_item + WHERE note IS NOT NULL AND note != ''; + + SELECT COUNT(*) INTO type_note_remaining + FROM omnivore.highlight + WHERE highlight_type = 'NOTE'; + + RAISE NOTICE 'After migration: % notebooks in library_item, % type=NOTE highlights remaining', + notes_after, type_note_remaining; + + IF type_note_remaining > 0 THEN + RAISE WARNING 'Unexpected: % type=NOTE highlights still exist after deletion!', type_note_remaining; + END IF; +END $$; + +-- Step 7: Update table statistics for query planner +ANALYZE omnivore.library_item; +ANALYZE omnivore.highlight; + +COMMIT; diff --git a/packages/db/migrations/0192.undo.consolidate_notebooks.sql b/packages/db/migrations/0192.undo.consolidate_notebooks.sql new file mode 100644 index 000000000..c863a2df9 --- /dev/null +++ b/packages/db/migrations/0192.undo.consolidate_notebooks.sql @@ -0,0 +1,98 @@ +-- Type: UNDO +-- Name: consolidate_notebooks +-- Description: Rollback notebook consolidation (restores type='NOTE' highlights from library_item.note) +-- +-- WARNING: This undo is LOSSY. We cannot perfectly restore the deleted highlights because: +-- 1. Original highlight metadata (position, created_at, etc.) is lost +-- 2. We can only recreate highlights from library_item.note +-- 3. Any conflicts that were resolved in favor of library_item.note cannot be restored +-- +-- This undo should only be used in emergency situations where the migration caused issues. +-- Recommendation: Do NOT run this unless absolutely necessary. Instead, fix forward. + +BEGIN; + +-- Step 1: Log current state +DO $$ +DECLARE + notes_in_lib_item integer; + notes_in_highlights integer; +BEGIN + SELECT COUNT(*) INTO notes_in_lib_item + FROM omnivore.library_item + WHERE note IS NOT NULL AND note != ''; + + SELECT COUNT(*) INTO notes_in_highlights + FROM omnivore.highlight + WHERE highlight_type = 'NOTE'; + + RAISE NOTICE 'Before undo: % notebooks in library_item, % in highlights', + notes_in_lib_item, notes_in_highlights; + + RAISE WARNING 'Running LOSSY undo migration. Original highlight metadata will not be restored.'; +END $$; + +-- Step 2: Recreate type='NOTE' highlights from library_item.note +-- Note: This creates NEW highlights, not restoring the originals +INSERT INTO omnivore.highlight ( + user_id, + library_item_id, + short_id, + annotation, + highlight_type, + highlight_position_percent, + highlight_position_anchor_index, + created_at, + updated_at +) +SELECT + li.user_id, + li.id, + -- Generate new short_id (original is lost) + substring(md5(random()::text) from 1 for 8), + li.note, + 'NOTE'::highlight_type, + 0, -- Default position (original lost) + 0, -- Default anchor (original lost) + COALESCE(li.note_updated_at, li.updated_at, li.created_at), + COALESCE(li.note_updated_at, li.updated_at, li.created_at) +FROM omnivore.library_item li +WHERE li.note IS NOT NULL + AND li.note != '' + -- Only create highlight if one doesn't already exist + AND NOT EXISTS ( + SELECT 1 FROM omnivore.highlight h + WHERE h.library_item_id = li.id + AND h.highlight_type = 'NOTE' + ); + +-- Step 3: Remove note_updated_at column +ALTER TABLE omnivore.library_item +DROP COLUMN IF EXISTS note_updated_at; + +-- Step 4: Log final state +DO $$ +DECLARE + notes_in_lib_item integer; + notes_in_highlights integer; +BEGIN + SELECT COUNT(*) INTO notes_in_lib_item + FROM omnivore.library_item + WHERE note IS NOT NULL AND note != ''; + + SELECT COUNT(*) INTO notes_in_highlights + FROM omnivore.highlight + WHERE highlight_type = 'NOTE'; + + RAISE NOTICE 'After undo: % notebooks in library_item, % in highlights', + notes_in_lib_item, notes_in_highlights; + + RAISE NOTICE 'Undo complete. type=NOTE highlights recreated from library_item.note.'; + RAISE WARNING 'Remember: This is a lossy restoration. Original highlight metadata was not recovered.'; +END $$; + +-- Step 5: Update table statistics +ANALYZE omnivore.library_item; +ANALYZE omnivore.highlight; + +COMMIT;