feat: labels (WIP)

This commit is contained in:
Philipinho
2026-02-17 02:01:32 +00:00
parent 0aeaa43112
commit ca173a9c98
13 changed files with 562 additions and 0 deletions
+2
View File
@@ -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,
],
@@ -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;
}
@@ -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,
});
}
}
@@ -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 {}
@@ -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<Label[]> {
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<void> {
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<Label[]> {
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<void> {
const labelIds = await this.labelRepo.findLabelIdsByPageIds(pageIds);
if (labelIds.length === 0) return;
await this.labelRepo.deleteOrphanedLabels(labelIds);
}
}
@@ -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);
}
}
}
@@ -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
@@ -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
@@ -0,0 +1,58 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('page_labels').execute();
await db.schema.dropTable('labels').execute();
}
@@ -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<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where('id', '=', labelId)
.executeTakeFirst();
}
async findByNameAndWorkspace(
name: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where(sql`LOWER(name)`, '=', name.toLowerCase())
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findOrCreate(
name: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Label> {
const db = dbOrTx(this.db, trx);
const trimmedName = name.trim();
const result = await db
.insertInto('labels')
.values({ name: trimmedName, workspaceId })
.onConflict((oc) =>
oc
.expression(sql`workspace_id, LOWER(name)`)
.doNothing(),
)
.returningAll()
.executeTakeFirst();
if (result) {
return result;
}
return this.findByNameAndWorkspace(trimmedName, workspaceId, trx);
}
async findLabelsByPageId(pageId: string): Promise<Label[]> {
return this.db
.selectFrom('labels')
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
.select(['labels.id', 'labels.name', 'labels.createdAt', 'labels.updatedAt', 'labels.workspaceId'])
.where('pageLabels.pageId', '=', pageId)
.orderBy('labels.name', 'asc')
.execute();
}
async addLabelToPage(
pageId: string,
labelId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.insertInto('pageLabels')
.values({ pageId, labelId })
.onConflict((oc) => oc.doNothing())
.execute();
}
async removeLabelFromPage(
pageId: string,
labelId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pageLabels')
.where('pageId', '=', pageId)
.where('labelId', '=', labelId)
.execute();
}
async getLabelPageCount(
labelId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pageLabels')
.select((eb) => eb.fn.count('id').as('count'))
.where('labelId', '=', labelId)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async deleteLabel(
labelId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('labels')
.where('id', '=', labelId)
.execute();
}
async deleteOrphanedLabels(
labelIds: string[],
trx?: KyselyTransaction,
): Promise<void> {
if (labelIds.length === 0) return;
const db = dbOrTx(this.db, trx);
const labelsWithPages = await db
.selectFrom('pageLabels')
.select('labelId')
.where('labelId', 'in', labelIds)
.groupBy('labelId')
.execute();
const labelsStillInUse = new Set(labelsWithPages.map((r) => r.labelId));
const orphanedIds = labelIds.filter((id) => !labelsStillInUse.has(id));
if (orphanedIds.length > 0) {
await db
.deleteFrom('labels')
.where('id', 'in', orphanedIds)
.execute();
}
}
async findPagesByLabelId(
labelId: string,
userId: string,
opts?: { spaceId?: string },
) {
let query = this.db
.selectFrom('pages')
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
'pages.createdAt',
'pages.updatedAt',
])
.where('pageLabels.labelId', '=', labelId)
.where('pages.deletedAt', 'is', null);
if (opts?.spaceId) {
query = query.where('pages.spaceId', '=', opts.spaceId);
} else {
query = query.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
);
}
return query.orderBy('pages.updatedAt', 'desc').execute();
}
async findLabelIdsByPageIds(
pageIds: string[],
trx?: KyselyTransaction,
): Promise<string[]> {
if (pageIds.length === 0) return [];
const db = dbOrTx(this.db, trx);
const results = await db
.selectFrom('pageLabels')
.select('labelId')
.where('pageId', 'in', pageIds)
.groupBy('labelId')
.execute();
return results.map((r) => r.labelId);
}
}
+17
View File
@@ -390,6 +390,21 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Labels {
id: Generated<string>;
name: string;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageLabels {
id: Generated<string>;
pageId: string;
labelId: string;
createdAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -401,8 +416,10 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
labels: Labels;
notifications: Notifications;
pageHistory: PageHistory;
pageLabels: PageLabels;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -9,8 +9,10 @@ import {
FileTasks,
Groups,
GroupUsers,
Labels,
Notifications,
PageHistory,
PageLabels,
Pages,
Shares,
SpaceMembers,
@@ -34,9 +36,11 @@ export interface DbInterface {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
labels: Labels;
notifications: Notifications;
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pageLabels: PageLabels;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -3,7 +3,9 @@ import {
Attachments,
Comments,
Groups,
Labels,
Notifications,
PageLabels,
Pages,
Spaces,
Users,
@@ -143,3 +145,12 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Label
export type Label = Selectable<Labels>;
export type InsertableLabel = Insertable<Labels>;
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
// PageLabel
export type PageLabel = Selectable<PageLabels>;
export type InsertablePageLabel = Insertable<PageLabels>;