full implementation

This commit is contained in:
Philipinho
2026-05-10 17:54:43 +01:00
parent 0369e727de
commit bda7e0099c
28 changed files with 1859 additions and 331 deletions
+47 -24
View File
@@ -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;
}
+67 -80
View File
@@ -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();
}
}
}
+82 -24
View File
@@ -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,
};
}
}
+3
View File
@@ -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(
+2
View File
@@ -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);
}
}