mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 18:16:57 +08:00
full implementation
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -10,52 +11,74 @@ import {
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PageIdDto } from '../../page/dto/page.dto';
|
||||
import { normalizeLabelName } from '../utils';
|
||||
|
||||
function normalizeLabel(name: string): string {
|
||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
export class AddLabelsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
//TODO: We may support SPACE/TEMPLATE labels in the future
|
||||
const SUPPORTED_LABEL_TYPES: LabelType[] = [LabelType.PAGE];
|
||||
|
||||
export class AddLabelsDto extends PageIdDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(25)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value.map(normalizeLabel) : value,
|
||||
Array.isArray(value) ? value.map(normalizeLabelName) : value,
|
||||
)
|
||||
@MaxLength(100, { each: true })
|
||||
@Matches(/^[a-z0-9_~-]+$/, {
|
||||
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, {
|
||||
each: true,
|
||||
message: 'Label names can only contain letters, numbers, hyphens, underscores, and tildes',
|
||||
message:
|
||||
'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde',
|
||||
})
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export class RemoveLabelDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
|
||||
export class RemoveLabelDto extends PageIdDto {
|
||||
@IsUUID()
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export class PageLabelsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class SearchPagesByLabelDto {
|
||||
export class FindPagesByLabelDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
labelId: string;
|
||||
labelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class LabelInfoDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class ListLabelsDto {
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
@@ -10,23 +11,22 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { LabelService } from './label.service';
|
||||
import {
|
||||
AddLabelsDto,
|
||||
PageLabelsDto,
|
||||
RemoveLabelDto,
|
||||
SearchPagesByLabelDto,
|
||||
FindPagesByLabelDto,
|
||||
LabelInfoDto,
|
||||
ListLabelsDto,
|
||||
} 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 { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { emptyCursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
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')
|
||||
@@ -34,102 +34,89 @@ 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() dto: ListLabelsDto,
|
||||
@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,
|
||||
return this.labelService.getLabels(
|
||||
workspace.id,
|
||||
user.id,
|
||||
dto.type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Post('pages')
|
||||
async findPagesByLabel(
|
||||
@Body() dto: FindPagesByLabelDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
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();
|
||||
await this.assertCanReadSpace(user, dto.spaceId);
|
||||
}
|
||||
|
||||
let labelId = dto.labelId;
|
||||
if (!labelId) {
|
||||
if (!dto.name) {
|
||||
throw new BadRequestException('labelId or name is required');
|
||||
}
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
dto.name,
|
||||
workspace.id,
|
||||
LabelType.PAGE,
|
||||
);
|
||||
if (!label) {
|
||||
return emptyCursorPaginationResult(pagination.limit);
|
||||
}
|
||||
labelId = label.id;
|
||||
} else {
|
||||
const label = await this.labelRepo.findById(labelId);
|
||||
if (!label) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
}
|
||||
|
||||
return this.labelService.searchPagesByLabel(label.id, user.id, {
|
||||
return this.labelService.findPagesByLabel(labelId, user.id, {
|
||||
spaceId: dto.spaceId,
|
||||
query: pagination.query,
|
||||
pagination,
|
||||
});
|
||||
}
|
||||
|
||||
// @HttpCode(HttpStatus.OK)
|
||||
// @Post('info')
|
||||
// async getLabelInfo(
|
||||
// @Body() dto: LabelInfoDto,
|
||||
// @AuthUser() user: User,
|
||||
// @AuthWorkspace() workspace: Workspace,
|
||||
// ) {
|
||||
// if (dto.spaceId) {
|
||||
// await this.assertCanReadSpace(user, dto.spaceId);
|
||||
// }
|
||||
//
|
||||
// return this.labelService.getLabelInfo(
|
||||
// dto.name,
|
||||
// dto.type,
|
||||
// workspace.id,
|
||||
// user.id,
|
||||
// dto.spaceId,
|
||||
// );
|
||||
// }
|
||||
|
||||
private async assertCanReadSpace(user: User, spaceId: string) {
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
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;
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { normalizeLabelName } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@@ -18,15 +20,9 @@ export class LabelService {
|
||||
pageId: string,
|
||||
names: string[],
|
||||
workspaceId: string,
|
||||
) {
|
||||
): Promise<Label[]> {
|
||||
const attached: Label[] = [];
|
||||
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(),
|
||||
@@ -35,22 +31,37 @@ export class LabelService {
|
||||
trx,
|
||||
);
|
||||
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
|
||||
attached.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
return this.labelRepo.findLabelsByPageId(pageId, { limit: 100 } as PaginationOptions);
|
||||
return attached;
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.labelRepo.removeLabelFromPage(pageId, labelId, trx);
|
||||
const label = await this.labelRepo.findById(labelId, trx);
|
||||
if (!label || label.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
const count = await this.labelRepo.getLabelPageCount(labelId, trx);
|
||||
await this.labelRepo.removeLabelFromPage(
|
||||
pageId,
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
const count = await this.labelRepo.getLabelPageCount(
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
if (count === 0) {
|
||||
await this.labelRepo.deleteLabel(labelId, trx);
|
||||
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -61,22 +72,69 @@ export class LabelService {
|
||||
|
||||
async getLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
return this.labelRepo.findLabels(workspaceId, LabelType.PAGE, pagination);
|
||||
return this.labelRepo.findLabels(
|
||||
workspaceId,
|
||||
userId,
|
||||
type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
async searchPagesByLabel(
|
||||
async findPagesByLabel(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts?: { spaceId?: string },
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
return this.labelRepo.findPagesByLabelId(labelId, userId, opts);
|
||||
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
|
||||
if (result.items.length === 0) return result;
|
||||
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: result.items.map((p) => p.id),
|
||||
userId,
|
||||
spaceId: opts.spaceId,
|
||||
});
|
||||
const accessible = new Set(accessibleIds);
|
||||
return {
|
||||
items: result.items.filter((p) => accessible.has(p.id)),
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupOrphanedLabels(pageIds: string[]): Promise<void> {
|
||||
const labelIds = await this.labelRepo.findLabelIdsByPageIds(pageIds);
|
||||
if (labelIds.length === 0) return;
|
||||
await this.labelRepo.deleteOrphanedLabels(labelIds);
|
||||
async getLabelInfo(
|
||||
name: string,
|
||||
type: LabelType,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const normalized = normalizeLabelName(name);
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
normalized,
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
|
||||
// Uniform response shape.
|
||||
// We don't want to expose whether the label row exists
|
||||
const usageCount = label
|
||||
? await this.labelRepo.getLabelPageCountForUser(
|
||||
label.id,
|
||||
userId,
|
||||
spaceId,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: normalized,
|
||||
usageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeLabelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { BacklinksListDto } from './dto/backlink.dto';
|
||||
import { LabelService } from '../label/label.service';
|
||||
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
@@ -61,6 +63,7 @@ export class PageController {
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly backlinkService: BacklinkService,
|
||||
private readonly labelService: LabelService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -99,6 +102,64 @@ export class PageController {
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels')
|
||||
async getPageLabels(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.labelService.getPageLabels(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/add')
|
||||
async addPageLabels(
|
||||
@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');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.labelService.addLabelsToPage(
|
||||
page.id,
|
||||
dto.names,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/remove')
|
||||
async removePageLabel(
|
||||
@Body() dto: RemoveLabelDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.labelService.removeLabelFromPage(
|
||||
page.id,
|
||||
dto.labelId,
|
||||
page.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks-count')
|
||||
async getBacklinksCount(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
import { LabelModule } from '../label/label.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
@@ -23,6 +24,7 @@ import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
CollaborationModule,
|
||||
WatcherModule,
|
||||
TransclusionModule,
|
||||
LabelModule,
|
||||
],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
} from '../../../integrations/export/utils';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
|
||||
@@ -73,7 +72,6 @@ export class PageService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
) {}
|
||||
|
||||
@@ -427,11 +425,7 @@ export class PageService {
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
await this.pageRepo.updatePages(
|
||||
{ spaceId },
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
|
||||
}
|
||||
|
||||
if (pageIdsToMove.length > 0) {
|
||||
@@ -478,9 +472,13 @@ export class PageService {
|
||||
);
|
||||
|
||||
// Update watchers and remove those without access to new space
|
||||
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||
trx,
|
||||
});
|
||||
await this.watcherService.movePageWatchersToSpace(
|
||||
pageIdsToMove,
|
||||
spaceId,
|
||||
{
|
||||
trx,
|
||||
},
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIdsToMove,
|
||||
@@ -860,13 +858,15 @@ export class PageService {
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
).as('hasChildren'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -1010,18 +1010,11 @@ 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,7 +5,6 @@ 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';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@@ -16,7 +15,6 @@ 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
|
||||
@@ -117,14 +115,7 @@ 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
|
||||
|
||||
@@ -20,9 +20,9 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('labels_workspace_id_name_unique')
|
||||
.createIndex('labels_workspace_id_type_name_unique')
|
||||
.on('labels')
|
||||
.columns(['workspace_id', 'name', 'type'])
|
||||
.columns(['workspace_id', 'type', 'name'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { normalizeLabelName } from '../../../core/label/utils';
|
||||
|
||||
export const LabelType = {
|
||||
PAGE: 'page',
|
||||
@@ -43,7 +45,7 @@ export class LabelRepo {
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('name', '=', name.toLowerCase())
|
||||
.where('name', '=', normalizeLabelName(name))
|
||||
.where('type', '=', type)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -56,22 +58,21 @@ export class LabelRepo {
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const normalizedName = normalizeLabelName(name);
|
||||
|
||||
const result = await db
|
||||
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
|
||||
// even on conflict. Avoids a race where a follow-up SELECT could miss a
|
||||
// row inserted by a concurrent transaction. The set is a no-op write.
|
||||
return db
|
||||
.insertInto('labels')
|
||||
.values({ name: normalizedName, type, workspaceId })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['name', 'type', 'workspaceId']).doNothing(),
|
||||
oc
|
||||
.columns(['name', 'type', 'workspaceId'])
|
||||
.doUpdateSet({ name: normalizedName }),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return this.findByNameAndWorkspace(normalizedName, workspaceId, type, trx);
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
|
||||
@@ -85,35 +86,59 @@ export class LabelRepo {
|
||||
'labels.createdAt',
|
||||
'labels.updatedAt',
|
||||
'labels.workspaceId',
|
||||
'pageLabels.id as joinId',
|
||||
])
|
||||
.where('pageLabels.pageId', '=', pageId)
|
||||
.where('labels.type', '=', LabelType.PAGE);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'labels.name', direction: 'asc', key: 'name' },
|
||||
{ expression: 'labels.id', direction: 'asc', key: 'id' },
|
||||
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
name: cursor.name,
|
||||
id: cursor.id,
|
||||
joinId: cursor.joinId,
|
||||
}),
|
||||
});
|
||||
|
||||
// joinId is an internal pagination cursor; don't leak it to callers.
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
|
||||
};
|
||||
}
|
||||
|
||||
async findLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
// Label visibility is scoped to space membership: a label surfaces if it
|
||||
// is attached to any non-deleted page in a space the user belongs to.
|
||||
// Per-page permission restrictions intentionally do not narrow this
|
||||
// further — labels are a space-level concept, not a page-level one.
|
||||
let query = this.db
|
||||
.selectFrom('labels')
|
||||
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type);
|
||||
.where('type', '=', type)
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select('pageLabels.labelId')
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
@@ -154,6 +179,7 @@ export class LabelRepo {
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
@@ -161,6 +187,15 @@ export class LabelRepo {
|
||||
.deleteFrom('pageLabels')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('labelId', '=', labelId)
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.select('id')
|
||||
.whereRef('labels.id', '=', 'pageLabels.labelId')
|
||||
.where('labels.workspaceId', '=', workspaceId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -180,13 +215,16 @@ export class LabelRepo {
|
||||
|
||||
async getLabelPageCount(
|
||||
labelId: string,
|
||||
workspaceId: 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)
|
||||
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('labels.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
@@ -194,49 +232,30 @@ export class LabelRepo {
|
||||
|
||||
async deleteLabel(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('labels')
|
||||
.where('id', '=', labelId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.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 },
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
|
||||
.select([
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
@@ -244,11 +263,32 @@ export class LabelRepo {
|
||||
'pages.spaceId',
|
||||
'pages.createdAt',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'pages.creatorId'),
|
||||
).as('creator'),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
|
||||
.select(['labels.id', 'labels.name'])
|
||||
.whereRef('pl.pageId', '=', 'pages.id')
|
||||
.where('labels.type', '=', LabelType.PAGE)
|
||||
.orderBy('pl.id', 'asc'),
|
||||
).as('labels'),
|
||||
])
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (opts.spaceId) {
|
||||
query = query.where('pages.spaceId', '=', opts.spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
@@ -258,22 +298,48 @@ export class LabelRepo {
|
||||
);
|
||||
}
|
||||
|
||||
return query.orderBy('pages.updatedAt', 'desc').execute();
|
||||
if (opts.query) {
|
||||
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async findLabelIdsByPageIds(
|
||||
pageIds: string[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const results = await db
|
||||
async getLabelPageCountForUser(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<number> {
|
||||
let query = this.db
|
||||
.selectFrom('pageLabels')
|
||||
.select('labelId')
|
||||
.where('pageId', 'in', pageIds)
|
||||
.groupBy('labelId')
|
||||
.execute();
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
return results.map((r) => r.labelId);
|
||||
if (spaceId) {
|
||||
query = query.where('pages.spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user