From aa657e7bad509e31ab3915d8030f6346a2cd6139 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 9 May 2026 17:06:38 +0100 Subject: [PATCH] feat: labels (WIP) --- apps/server/src/core/core.module.ts | 2 + apps/server/src/core/label/dto/label.dto.ts | 61 ++++ .../server/src/core/label/label.controller.ts | 135 +++++++++ apps/server/src/core/label/label.module.ts | 10 + apps/server/src/core/label/label.service.ts | 82 +++++ .../src/core/page/services/page.service.ts | 9 + .../page/services/trash-cleanup.service.ts | 9 + apps/server/src/database/database.module.ts | 3 + .../migrations/20260509T121236-labels.ts | 59 ++++ .../src/database/repos/label/label.repo.ts | 279 ++++++++++++++++++ apps/server/src/database/types/db.d.ts | 18 ++ .../server/src/database/types/db.interface.ts | 4 + .../server/src/database/types/entity.types.ts | 11 + apps/server/src/ee | 2 +- 14 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/core/label/dto/label.dto.ts create mode 100644 apps/server/src/core/label/label.controller.ts create mode 100644 apps/server/src/core/label/label.module.ts create mode 100644 apps/server/src/core/label/label.service.ts create mode 100644 apps/server/src/database/migrations/20260509T121236-labels.ts create mode 100644 apps/server/src/database/repos/label/label.repo.ts diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index f8b75cd0..94327cc6 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -16,6 +16,7 @@ import { GroupModule } from './group/group.module'; import { CaslModule } from './casl/casl.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { ShareModule } from './share/share.module'; +import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; @@ -32,6 +33,7 @@ import { WatcherModule } from './watcher/watcher.module'; GroupModule, CaslModule, ShareModule, + LabelModule, NotificationModule, WatcherModule, ], diff --git a/apps/server/src/core/label/dto/label.dto.ts b/apps/server/src/core/label/dto/label.dto.ts new file mode 100644 index 00000000..9b3540e0 --- /dev/null +++ b/apps/server/src/core/label/dto/label.dto.ts @@ -0,0 +1,61 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +function normalizeLabel(name: string): string { + return name.trim().replace(/\s+/g, '-').toLowerCase(); +} + +export class AddLabelsDto { + @IsString() + @IsNotEmpty() + pageId: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(25) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @Transform(({ value }) => + Array.isArray(value) ? value.map(normalizeLabel) : value, + ) + @MaxLength(100, { each: true }) + @Matches(/^[a-z0-9_~-]+$/, { + each: true, + message: 'Label names can only contain letters, numbers, hyphens, underscores, and tildes', + }) + names: string[]; +} + +export class RemoveLabelDto { + @IsString() + @IsNotEmpty() + pageId: string; + + @IsUUID() + labelId: string; +} + +export class PageLabelsDto { + @IsString() + @IsNotEmpty() + pageId: string; +} + +export class SearchPagesByLabelDto { + @IsUUID() + labelId: string; + + @IsOptional() + @IsUUID() + spaceId?: string; +} diff --git a/apps/server/src/core/label/label.controller.ts b/apps/server/src/core/label/label.controller.ts new file mode 100644 index 00000000..ee0f196b --- /dev/null +++ b/apps/server/src/core/label/label.controller.ts @@ -0,0 +1,135 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { LabelService } from './label.service'; +import { + AddLabelsDto, + PageLabelsDto, + RemoveLabelDto, + SearchPagesByLabelDto, +} from './dto/label.dto'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { LabelRepo } from '@docmost/db/repos/label/label.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; + +@UseGuards(JwtAuthGuard) +@Controller('labels') +export class LabelController { + constructor( + private readonly labelService: LabelService, + private readonly labelRepo: LabelRepo, + private readonly pageRepo: PageRepo, + private readonly spaceAbility: SpaceAbilityFactory, + ) {} + + @HttpCode(HttpStatus.OK) + @Post('/') + async getLabels( + @Body() pagination: PaginationOptions, + @AuthWorkspace() workspace: Workspace, + ) { + return this.labelService.getLabels(workspace.id, pagination); + } + + @HttpCode(HttpStatus.OK) + @Post('add') + async addLabels( + @Body() dto: AddLabelsDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.labelService.addLabelsToPage( + page.id, + dto.names, + workspace.id, + ); + } + + @HttpCode(HttpStatus.OK) + @Post('remove') + async removeLabel( + @Body() dto: RemoveLabelDto, + @AuthUser() user: User, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + await this.labelService.removeLabelFromPage(page.id, dto.labelId); + } + + @HttpCode(HttpStatus.OK) + @Post('page') + async getPageLabels( + @Body() dto: PageLabelsDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.labelService.getPageLabels(page.id, pagination); + } + + @HttpCode(HttpStatus.OK) + @Post('search-pages') + async searchPagesByLabel( + @Body() dto: SearchPagesByLabelDto, + @AuthUser() user: User, + ) { + const label = await this.labelRepo.findById(dto.labelId); + if (!label) { + throw new NotFoundException('Label not found'); + } + + if (dto.spaceId) { + const ability = await this.spaceAbility.createForUser(user, dto.spaceId); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + } + + return this.labelService.searchPagesByLabel(label.id, user.id, { + spaceId: dto.spaceId, + }); + } +} diff --git a/apps/server/src/core/label/label.module.ts b/apps/server/src/core/label/label.module.ts new file mode 100644 index 00000000..a68210e9 --- /dev/null +++ b/apps/server/src/core/label/label.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LabelController } from './label.controller'; +import { LabelService } from './label.service'; + +@Module({ + controllers: [LabelController], + providers: [LabelService], + exports: [LabelService], +}) +export class LabelModule {} diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts new file mode 100644 index 00000000..912cca54 --- /dev/null +++ b/apps/server/src/core/label/label.service.ts @@ -0,0 +1,82 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; + +const MAX_LABELS_PER_PAGE = 25; + +@Injectable() +export class LabelService { + constructor( + private readonly labelRepo: LabelRepo, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async addLabelsToPage( + pageId: string, + names: string[], + workspaceId: string, + ) { + await executeTx(this.db, async (trx) => { + const currentCount = await this.labelRepo.getPageLabelCount(pageId, trx); + if (currentCount + names.length > MAX_LABELS_PER_PAGE) { + throw new BadRequestException( + `A page can have a maximum of ${MAX_LABELS_PER_PAGE} labels`, + ); + } + + for (const name of names) { + const label = await this.labelRepo.findOrCreate( + name.trim(), + workspaceId, + LabelType.PAGE, + trx, + ); + await this.labelRepo.addLabelToPage(pageId, label.id, trx); + } + }); + + return this.labelRepo.findLabelsByPageId(pageId, { limit: 100 } as PaginationOptions); + } + + async removeLabelFromPage( + pageId: string, + labelId: string, + ): Promise { + await executeTx(this.db, async (trx) => { + await this.labelRepo.removeLabelFromPage(pageId, labelId, trx); + + const count = await this.labelRepo.getLabelPageCount(labelId, trx); + if (count === 0) { + await this.labelRepo.deleteLabel(labelId, trx); + } + }); + } + + async getPageLabels(pageId: string, pagination: PaginationOptions) { + return this.labelRepo.findLabelsByPageId(pageId, pagination); + } + + async getLabels( + workspaceId: string, + pagination: PaginationOptions, + ) { + return this.labelRepo.findLabels(workspaceId, LabelType.PAGE, pagination); + } + + async searchPagesByLabel( + labelId: string, + userId: string, + opts?: { spaceId?: string }, + ) { + return this.labelRepo.findPagesByLabelId(labelId, userId, opts); + } + + async cleanupOrphanedLabels(pageIds: string[]): Promise { + const labelIds = await this.labelRepo.findLabelIdsByPageIds(pageIds); + if (labelIds.length === 0) return; + await this.labelRepo.deleteOrphanedLabels(labelIds); + } +} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 0f9957ff..6da14b0b 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -48,6 +48,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { CollaborationGateway } from '../../../collaboration/collaboration.gateway'; import { markdownToHtml } from '@docmost/editor-ext'; import { WatcherService } from '../../watcher/watcher.service'; +import { LabelRepo } from '@docmost/db/repos/label/label.repo'; @Injectable() export class PageService { @@ -64,6 +65,7 @@ export class PageService { private eventEmitter: EventEmitter2, private collaborationGateway: CollaborationGateway, private readonly watcherService: WatcherService, + private readonly labelRepo: LabelRepo, ) {} async findById( @@ -729,11 +731,18 @@ export class PageService { } if (pageIds.length > 0) { + const affectedLabelIds = + await this.labelRepo.findLabelIdsByPageIds(pageIds); + await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); this.eventEmitter.emit(EventName.PAGE_DELETED, { pageIds: pageIds, workspaceId, }); + + if (affectedLabelIds.length > 0) { + await this.labelRepo.deleteOrphanedLabels(affectedLabelIds); + } } } diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts index f0646367..3eb8032e 100644 --- a/apps/server/src/core/page/services/trash-cleanup.service.ts +++ b/apps/server/src/core/page/services/trash-cleanup.service.ts @@ -5,6 +5,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; +import { LabelRepo } from '@docmost/db/repos/label/label.repo'; @Injectable() export class TrashCleanupService { @@ -14,6 +15,7 @@ export class TrashCleanupService { constructor( @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, + private readonly labelRepo: LabelRepo, ) {} @Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours @@ -104,7 +106,14 @@ export class TrashCleanupService { try { if (pageIds.length > 0) { + const affectedLabelIds = + await this.labelRepo.findLabelIdsByPageIds(pageIds); + await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); + + if (affectedLabelIds.length > 0) { + await this.labelRepo.deleteOrphanedLabels(affectedLabelIds); + } } } catch (error) { // Log but don't throw - pages might have been deleted by another node diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 6272ead1..ab0ad355 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -26,6 +26,7 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { LabelRepo } from '@docmost/db/repos/label/label.repo'; import { PageListener } from '@docmost/db/listeners/page.listener'; import { PostgresJSDialect } from 'kysely-postgres-js'; import * as postgres from 'postgres'; @@ -84,6 +85,7 @@ import { normalizePostgresUrl } from '../common/helpers'; ShareRepo, NotificationRepo, WatcherRepo, + LabelRepo, PageListener, ], exports: [ @@ -102,6 +104,7 @@ import { normalizePostgresUrl } from '../common/helpers'; ShareRepo, NotificationRepo, WatcherRepo, + LabelRepo, ], }) export class DatabaseModule diff --git a/apps/server/src/database/migrations/20260509T121236-labels.ts b/apps/server/src/database/migrations/20260509T121236-labels.ts new file mode 100644 index 00000000..5d2c5812 --- /dev/null +++ b/apps/server/src/database/migrations/20260509T121236-labels.ts @@ -0,0 +1,59 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('labels') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('type', 'varchar', (col) => col.notNull().defaultTo('page')) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('labels_workspace_id_name_unique') + .on('labels') + .columns(['workspace_id', 'name', 'type']) + .unique() + .execute(); + + await db.schema + .createTable('page_labels') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('label_id', 'uuid', (col) => + col.references('labels.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('page_labels_page_id_label_id_unique', [ + 'page_id', + 'label_id', + ]) + .execute(); + + await db.schema + .createIndex('page_labels_label_id_idx') + .on('page_labels') + .column('label_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_labels').execute(); + await db.schema.dropTable('labels').execute(); +} diff --git a/apps/server/src/database/repos/label/label.repo.ts b/apps/server/src/database/repos/label/label.repo.ts new file mode 100644 index 00000000..62abd678 --- /dev/null +++ b/apps/server/src/database/repos/label/label.repo.ts @@ -0,0 +1,279 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { Label } from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; + +export const LabelType = { + PAGE: 'page', + SPACE: 'space', +} as const; + +export type LabelType = (typeof LabelType)[keyof typeof LabelType]; + +@Injectable() +export class LabelRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} + + async findById( + labelId: string, + trx?: KyselyTransaction, + ): Promise