mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
feat: labels (WIP)
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,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<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, 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<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
|
||||
|
||||
Reference in New Issue
Block a user