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..e4ff7d4c --- /dev/null +++ b/apps/server/src/core/label/dto/label.dto.ts @@ -0,0 +1,48 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class AddLabelsDto { + @IsString() + @IsNotEmpty() + pageId: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(25) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @MaxLength(100, { each: true }) + 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..bd18eb19 --- /dev/null +++ b/apps/server/src/core/label/label.controller.ts @@ -0,0 +1,124 @@ +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'; + +@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('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, + @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); + } + + @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..0d32ba56 --- /dev/null +++ b/apps/server/src/core/label/label.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { LabelRepo } from '@docmost/db/repos/label/label.repo'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { Label } from '@docmost/db/types/entity.types'; +import { executeTx } from '@docmost/db/utils'; + +@Injectable() +export class LabelService { + constructor( + private readonly labelRepo: LabelRepo, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async addLabelsToPage( + pageId: string, + names: string[], + workspaceId: string, + ): Promise { + await executeTx(this.db, async (trx) => { + for (const name of names) { + const label = await this.labelRepo.findOrCreate( + name.trim(), + workspaceId, + trx, + ); + await this.labelRepo.addLabelToPage(pageId, label.id, trx); + } + }); + + return this.labelRepo.findLabelsByPageId(pageId); + } + + 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): Promise { + return this.labelRepo.findLabelsByPageId(pageId); + } + + 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/20260217T120000-labels.ts b/apps/server/src/database/migrations/20260217T120000-labels.ts new file mode 100644 index 00000000..4811baad --- /dev/null +++ b/apps/server/src/database/migrations/20260217T120000-labels.ts @@ -0,0 +1,58 @@ +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('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_lower_name_unique') + .on('labels') + .expression(sql`workspace_id, LOWER(name)`) + .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..c5e75125 --- /dev/null +++ b/apps/server/src/database/repos/label/label.repo.ts @@ -0,0 +1,202 @@ +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 { sql } from 'kysely'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; + +@Injectable() +export class LabelRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} + + async findById( + labelId: string, + trx?: KyselyTransaction, + ): Promise