feat(api): ARC-008 Labels System - backend entities and migrations

Add comprehensive label system with entities, migrations and denormalized label_names column:

**Database Changes:**
- Migration 0191: Fix labels.updated_at default to NOW()
- Add Label and EntityLabel entities for label management
- Add denormalized label_names array column to library_item for efficient filtering

**Entity Updates:**
- LibraryItemEntity: Add label_names column (PostgreSQL array type)
- Label entity: User labels with name, color, description
- EntityLabel entity: Junction table for library_item ↔ label relationship

**Architecture:**
- Dual-column approach: junction table for relationships + denormalized array for fast filtering
- PostgreSQL array overlap operator (&&) enables efficient label-based queries
- Automatic sync of label_names when labels are attached/detached

Implements ARC-008 Labels System (backend foundation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Timothy Atapagra 2025-10-04 16:26:09 -04:00
parent 5e711f074b
commit 1ca8d2a448
10 changed files with 620 additions and 0 deletions

View file

@ -0,0 +1,47 @@
import { InputType, Field } from '@nestjs/graphql'
import { IsString, IsOptional, Matches, Length } from 'class-validator'
@InputType()
export class CreateLabelInput {
@Field()
@IsString()
@Length(1, 100)
name!: string
@Field({ nullable: true, defaultValue: '#000000' })
@IsString()
@Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: 'Color must be a valid hex color code (e.g., #FF5733)',
})
@IsOptional()
color?: string
@Field({ nullable: true })
@IsString()
@Length(0, 500)
@IsOptional()
description?: string
}
@InputType()
export class UpdateLabelInput {
@Field({ nullable: true })
@IsString()
@Length(1, 100)
@IsOptional()
name?: string
@Field({ nullable: true })
@IsString()
@Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: 'Color must be a valid hex color code (e.g., #FF5733)',
})
@IsOptional()
color?: string
@Field({ nullable: true })
@IsString()
@Length(0, 500)
@IsOptional()
description?: string
}

View file

@ -0,0 +1,28 @@
import { ObjectType, Field, ID, Int } from '@nestjs/graphql'
@ObjectType()
export class Label {
@Field(() => ID)
id!: string
@Field()
name!: string
@Field()
color!: string
@Field({ nullable: true })
description?: string | null
@Field(() => Int)
position!: number
@Field()
internal!: boolean
@Field()
createdAt!: Date
@Field()
updatedAt!: Date
}

View file

@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm'
import { Label } from './label.entity'
import { LibraryItemEntity } from '../../library/entities/library-item.entity'
@Entity('entity_labels', { schema: 'omnivore' })
export class EntityLabel {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ name: 'library_item_id', type: 'uuid', nullable: true })
libraryItemId?: string | null
@ManyToOne(() => LibraryItemEntity)
@JoinColumn({ name: 'library_item_id' })
libraryItem?: LibraryItemEntity | null
@Column({ name: 'highlight_id', type: 'uuid', nullable: true })
highlightId?: string | null
@Column({ name: 'label_id', type: 'uuid' })
labelId!: string
@ManyToOne(() => Label, (label) => label.entityLabels)
@JoinColumn({ name: 'label_id' })
label!: Label
@Column({ type: 'text', default: 'user' })
source!: string
}

View file

@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm'
import { User } from '../../user/entities/user.entity'
import { EntityLabel } from './entity-label.entity'
@Entity('labels', { schema: 'omnivore' })
export class Label {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ name: 'user_id', type: 'uuid' })
userId!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column({ type: 'text' })
name!: string
@Column({ type: 'text', default: '#000000' })
color!: string
@Column({ type: 'text', nullable: true })
description?: string | null
@Column({ type: 'integer', default: 0 })
position!: number
@Column({ type: 'boolean', default: false })
internal!: boolean
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date
@OneToMany(() => EntityLabel, (entityLabel) => entityLabel.label)
entityLabels!: EntityLabel[]
}

View file

@ -0,0 +1,14 @@
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'
@Module({
imports: [TypeOrmModule.forFeature([Label, EntityLabel, LibraryItemEntity])],
providers: [LabelService, LabelResolver],
exports: [LabelService],
})
export class LabelModule {}

View file

@ -0,0 +1,81 @@
import { Resolver, Query, Mutation, Args } 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 { LabelService } from './label.service'
import { Label } from './dto/label.type'
import { CreateLabelInput, UpdateLabelInput } from './dto/label-inputs.type'
import { DeleteResult } from '../library/dto/library-inputs.type'
@Resolver(() => Label)
export class LabelResolver {
constructor(private readonly labelService: LabelService) {}
@Query(() => [Label], { description: 'Get all labels for the current user' })
@UseGuards(JwtAuthGuard)
async labels(@CurrentUser() user: User): Promise<Label[]> {
return this.labelService.findAll(user.id)
}
@Query(() => Label, {
nullable: true,
description: 'Get a single label by ID',
})
@UseGuards(JwtAuthGuard)
async label(
@CurrentUser() user: User,
@Args('id', { type: () => String }) id: string,
): Promise<Label | null> {
try {
return await this.labelService.findOne(user.id, id)
} catch (error) {
return null
}
}
@Mutation(() => Label, { description: 'Create a new label' })
@UseGuards(JwtAuthGuard)
async createLabel(
@CurrentUser() user: User,
@Args('input') input: CreateLabelInput,
): Promise<Label> {
return this.labelService.create(user.id, input)
}
@Mutation(() => Label, { description: 'Update an existing label' })
@UseGuards(JwtAuthGuard)
async updateLabel(
@CurrentUser() user: User,
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateLabelInput,
): Promise<Label> {
return this.labelService.update(user.id, id, input)
}
@Mutation(() => DeleteResult, { description: 'Delete a label' })
@UseGuards(JwtAuthGuard)
async deleteLabel(
@CurrentUser() user: User,
@Args('id', { type: () => String }) id: string,
): Promise<DeleteResult> {
const success = await this.labelService.delete(user.id, id)
return {
success,
message: success ? 'Label deleted successfully' : 'Failed to delete label',
itemId: id,
}
}
@Mutation(() => [Label], {
description: 'Set labels for a library item (replaces existing labels)',
})
@UseGuards(JwtAuthGuard)
async setLibraryItemLabels(
@CurrentUser() user: User,
@Args('itemId', { type: () => String }) itemId: string,
@Args('labelIds', { type: () => [String] }) labelIds: string[],
): Promise<Label[]> {
return this.labelService.setLibraryItemLabels(user.id, itemId, labelIds)
}
}

View file

@ -0,0 +1,210 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} 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'
@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>,
) {}
/**
* Get all labels for a user
*/
async findAll(userId: string): Promise<Label[]> {
return this.labelRepository.find({
where: { userId },
order: { position: 'ASC' },
})
}
/**
* Get a single label by ID
*/
async findOne(userId: string, labelId: string): Promise<Label> {
const label = await this.labelRepository.findOne({
where: { id: labelId, userId },
})
if (!label) {
throw new NotFoundException(`Label with ID ${labelId} not found`)
}
return label
}
/**
* Create a new label
*/
async create(userId: string, input: CreateLabelInput): Promise<Label> {
// Check for duplicate label name
const existing = await this.labelRepository.findOne({
where: { userId, name: input.name },
})
if (existing) {
throw new ConflictException(
`Label with name "${input.name}" already exists`,
)
}
const label = this.labelRepository.create({
userId,
name: input.name,
color: input.color || '#000000',
description: input.description,
})
return this.labelRepository.save(label)
}
/**
* Update an existing label
*/
async update(
userId: string,
labelId: string,
input: UpdateLabelInput,
): Promise<Label> {
const label = await this.findOne(userId, labelId)
// Check if the label is internal (system label)
if (label.internal) {
throw new BadRequestException('Cannot modify internal system labels')
}
// If updating name, check for duplicates
if (input.name && input.name !== label.name) {
const existing = await this.labelRepository.findOne({
where: { userId, name: input.name },
})
if (existing) {
throw new ConflictException(
`Label with name "${input.name}" already exists`,
)
}
}
// Update fields
if (input.name !== undefined) label.name = input.name
if (input.color !== undefined) label.color = input.color
if (input.description !== undefined) label.description = input.description
return this.labelRepository.save(label)
}
/**
* Delete a label
*/
async delete(userId: string, labelId: string): Promise<boolean> {
const label = await this.findOne(userId, labelId)
// Check if the label is internal (system label)
if (label.internal) {
throw new BadRequestException('Cannot delete internal system labels')
}
await this.labelRepository.remove(label)
return true
}
/**
* Set labels for a library item (replaces existing labels)
*/
async setLibraryItemLabels(
userId: string,
libraryItemId: string,
labelIds: string[],
): Promise<Label[]> {
// Verify library item exists and belongs to user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: libraryItemId, userId },
})
if (!libraryItem) {
throw new NotFoundException(
`Library item with ID ${libraryItemId} not found`,
)
}
// 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 })),
})
if (labels.length !== labelIds.length) {
throw new NotFoundException(
'One or more labels not found or do not belong to the user',
)
}
}
// Remove existing labels for this library item
await this.entityLabelRepository.delete({ libraryItemId })
// Add new labels
if (labelIds.length > 0) {
const entityLabels = labelIds.map((labelId) =>
this.entityLabelRepository.create({
libraryItemId,
labelId,
source: 'user',
}),
)
await this.entityLabelRepository.save(entityLabels)
}
// Update the label_names column on the library_item table for filtering
libraryItem.labelNames = labels.map((label) => label.name)
await this.libraryItemRepository.save(libraryItem)
// Return the updated labels
if (labelIds.length === 0) {
return []
}
return this.labelRepository.find({
where: labelIds.map((id) => ({ id, userId })),
order: { position: 'ASC' },
})
}
/**
* Get labels for a library item
*/
async getLibraryItemLabels(
userId: string,
libraryItemId: string,
): Promise<Label[]> {
const entityLabels = await this.entityLabelRepository.find({
where: { libraryItemId },
relations: ['label'],
})
// Filter to only return labels owned by the user
const labels = entityLabels
.map((el) => el.label)
.filter((label) => label.userId === userId)
// Sort by position
return labels.sort((a, b) => a.position - b.position)
}
}

View file

@ -0,0 +1,121 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../../user/entities/user.entity'
import { EntityLabel } from '../../label/entities/entity-label.entity'
export enum LibraryItemState {
FAILED = 'FAILED',
PROCESSING = 'PROCESSING',
SUCCEEDED = 'SUCCEEDED',
DELETED = 'DELETED',
ARCHIVED = 'ARCHIVED',
CONTENT_NOT_FETCHED = 'CONTENT_NOT_FETCHED',
}
export enum ContentReaderType {
WEB = 'WEB',
PDF = 'PDF',
EPUB = 'EPUB',
}
@Entity({ name: 'library_item', schema: 'omnivore' })
export class LibraryItemEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column({ name: 'user_id', type: 'uuid' })
userId!: string
@Column({
type: 'enum',
enum: LibraryItemState,
default: LibraryItemState.SUCCEEDED,
})
state!: LibraryItemState
@Column({ name: 'original_url', type: 'text' })
originalUrl!: string
@Column({ type: 'text' })
slug!: string
@Column({ type: 'text' })
title!: string
@Column({ type: 'text', nullable: true })
author?: string | null
@Column({ type: 'text', nullable: true })
description?: string | null
@Column({ name: 'saved_at', type: 'timestamptz' })
savedAt!: Date
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
publishedAt?: Date | null
@Column({ name: 'read_at', type: 'timestamptz', nullable: true })
readAt?: Date | null
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date
@Column({ name: 'word_count', type: 'integer', nullable: true })
wordCount?: number | null
@Column({ name: 'site_name', type: 'text', nullable: true })
siteName?: string | null
@Column({ name: 'site_icon', type: 'text', nullable: true })
siteIcon?: string | null
@Column({ name: 'reading_progress_top_percent', type: 'real', default: 0 })
readingProgressTopPercent!: number
@Column({ name: 'reading_progress_bottom_percent', type: 'real', default: 0 })
readingProgressBottomPercent!: number
@Column({ name: 'reading_progress_last_read_anchor', type: 'integer', default: 0 })
readingProgressLastReadAnchor!: number
@Column({ name: 'reading_progress_highest_read_anchor', type: 'integer', default: 0 })
readingProgressHighestReadAnchor!: number
@Column({ type: 'text', nullable: true })
thumbnail?: string | null
@Column({ name: 'item_type', type: 'text', default: 'ARTICLE' })
itemType!: string
@Column({
name: 'content_reader',
type: 'enum',
enum: ContentReaderType,
default: ContentReaderType.WEB,
})
contentReader!: ContentReaderType
@Column({ type: 'text' })
folder!: string
@Column({ name: 'label_names', type: 'text', array: true, nullable: true, default: [] })
labelNames?: string[] | null
@OneToMany(() => EntityLabel, (entityLabel) => entityLabel.libraryItem)
entityLabels!: EntityLabel[]
}

View file

@ -0,0 +1,20 @@
-- Type: DO
-- Name: fix_labels_updated_at_default
-- Description: Add default value to labels.updated_at column
BEGIN;
-- Set default value for updated_at column
ALTER TABLE omnivore.labels
ALTER COLUMN updated_at SET DEFAULT current_timestamp;
-- Update existing NULL values to created_at
UPDATE omnivore.labels
SET updated_at = created_at
WHERE updated_at IS NULL;
-- Make the column NOT NULL now that all values are set
ALTER TABLE omnivore.labels
ALTER COLUMN updated_at SET NOT NULL;
COMMIT;

View file

@ -0,0 +1,15 @@
-- Type: UNDO
-- Name: fix_labels_updated_at_default
-- Description: Remove default value from labels.updated_at column
BEGIN;
-- Remove NOT NULL constraint
ALTER TABLE omnivore.labels
ALTER COLUMN updated_at DROP NOT NULL;
-- Remove default value
ALTER TABLE omnivore.labels
ALTER COLUMN updated_at DROP DEFAULT;
COMMIT;