From 1ca8d2a4485ea3e102842cb7830c71f722a268f1 Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Sat, 4 Oct 2025 16:26:09 -0400 Subject: [PATCH] feat(api): ARC-008 Labels System - backend entities and migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/label/dto/label-inputs.type.ts | 47 ++++ packages/api-nest/src/label/dto/label.type.ts | 28 +++ .../src/label/entities/entity-label.entity.ts | 35 +++ .../src/label/entities/label.entity.ts | 49 ++++ packages/api-nest/src/label/label.module.ts | 14 ++ packages/api-nest/src/label/label.resolver.ts | 81 +++++++ packages/api-nest/src/label/label.service.ts | 210 ++++++++++++++++++ .../library/entities/library-item.entity.ts | 121 ++++++++++ .../0191.do.fix_labels_updated_at_default.sql | 20 ++ ...191.undo.fix_labels_updated_at_default.sql | 15 ++ 10 files changed, 620 insertions(+) create mode 100644 packages/api-nest/src/label/dto/label-inputs.type.ts create mode 100644 packages/api-nest/src/label/dto/label.type.ts create mode 100644 packages/api-nest/src/label/entities/entity-label.entity.ts create mode 100644 packages/api-nest/src/label/entities/label.entity.ts create mode 100644 packages/api-nest/src/label/label.module.ts create mode 100644 packages/api-nest/src/label/label.resolver.ts create mode 100644 packages/api-nest/src/label/label.service.ts create mode 100644 packages/api-nest/src/library/entities/library-item.entity.ts create mode 100644 packages/db/migrations/0191.do.fix_labels_updated_at_default.sql create mode 100644 packages/db/migrations/0191.undo.fix_labels_updated_at_default.sql diff --git a/packages/api-nest/src/label/dto/label-inputs.type.ts b/packages/api-nest/src/label/dto/label-inputs.type.ts new file mode 100644 index 000000000..6766b40e5 --- /dev/null +++ b/packages/api-nest/src/label/dto/label-inputs.type.ts @@ -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 +} diff --git a/packages/api-nest/src/label/dto/label.type.ts b/packages/api-nest/src/label/dto/label.type.ts new file mode 100644 index 000000000..93fb82c03 --- /dev/null +++ b/packages/api-nest/src/label/dto/label.type.ts @@ -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 +} diff --git a/packages/api-nest/src/label/entities/entity-label.entity.ts b/packages/api-nest/src/label/entities/entity-label.entity.ts new file mode 100644 index 000000000..cd76617f8 --- /dev/null +++ b/packages/api-nest/src/label/entities/entity-label.entity.ts @@ -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 +} diff --git a/packages/api-nest/src/label/entities/label.entity.ts b/packages/api-nest/src/label/entities/label.entity.ts new file mode 100644 index 000000000..77248d2ac --- /dev/null +++ b/packages/api-nest/src/label/entities/label.entity.ts @@ -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[] +} diff --git a/packages/api-nest/src/label/label.module.ts b/packages/api-nest/src/label/label.module.ts new file mode 100644 index 000000000..4e905c7cc --- /dev/null +++ b/packages/api-nest/src/label/label.module.ts @@ -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 {} diff --git a/packages/api-nest/src/label/label.resolver.ts b/packages/api-nest/src/label/label.resolver.ts new file mode 100644 index 000000000..162570038 --- /dev/null +++ b/packages/api-nest/src/label/label.resolver.ts @@ -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 { + 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