mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b22dfa27c2 | |||
| 0cb565c77a | |||
| 2f99cd8f7d | |||
| 75f7f9b296 | |||
| ca173a9c98 |
@@ -16,6 +16,7 @@ import { GroupModule } from './group/group.module';
|
|||||||
import { CaslModule } from './casl/casl.module';
|
import { CaslModule } from './casl/casl.module';
|
||||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
|
import { LabelModule } from './label/label.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ import { WatcherModule } from './watcher/watcher.module';
|
|||||||
GroupModule,
|
GroupModule,
|
||||||
CaslModule,
|
CaslModule,
|
||||||
ShareModule,
|
ShareModule,
|
||||||
|
LabelModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
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 { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||||
import { markdownToHtml } from '@docmost/editor-ext';
|
import { markdownToHtml } from '@docmost/editor-ext';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
|
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@@ -64,6 +65,7 @@ export class PageService {
|
|||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private collaborationGateway: CollaborationGateway,
|
private collaborationGateway: CollaborationGateway,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
|
private readonly labelRepo: LabelRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@@ -729,11 +731,18 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pageIds.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
|
const affectedLabelIds =
|
||||||
|
await this.labelRepo.findLabelIdsByPageIds(pageIds);
|
||||||
|
|
||||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||||
this.eventEmitter.emit(EventName.PAGE_DELETED, {
|
this.eventEmitter.emit(EventName.PAGE_DELETED, {
|
||||||
pageIds: pageIds,
|
pageIds: pageIds,
|
||||||
workspaceId,
|
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 { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
|
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrashCleanupService {
|
export class TrashCleanupService {
|
||||||
@@ -14,6 +15,7 @@ export class TrashCleanupService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
|
private readonly labelRepo: LabelRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
|
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
|
||||||
@@ -104,7 +106,14 @@ export class TrashCleanupService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (pageIds.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
|
const affectedLabelIds =
|
||||||
|
await this.labelRepo.findLabelIdsByPageIds(pageIds);
|
||||||
|
|
||||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||||
|
|
||||||
|
if (affectedLabelIds.length > 0) {
|
||||||
|
await this.labelRepo.deleteOrphanedLabels(affectedLabelIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log but don't throw - pages might have been deleted by another node
|
// 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 { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.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 { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import * as postgres from 'postgres';
|
import * as postgres from 'postgres';
|
||||||
@@ -84,6 +85,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
|
LabelRepo,
|
||||||
PageListener,
|
PageListener,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -102,6 +104,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
|
LabelRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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('type', 'varchar', (col) => col.notNull().defaultTo('page'))
|
||||||
|
.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_name_unique')
|
||||||
|
.on('labels')
|
||||||
|
.columns(['workspace_id', 'name', 'type'])
|
||||||
|
.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,279 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
export const LabelType = {
|
||||||
|
PAGE: 'page',
|
||||||
|
SPACE: 'space',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
|
||||||
|
|
||||||
|
@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,
|
||||||
|
type: LabelType,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Label | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('labels')
|
||||||
|
.selectAll()
|
||||||
|
.where('name', '=', name.toLowerCase())
|
||||||
|
.where('type', '=', type)
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOrCreate(
|
||||||
|
name: string,
|
||||||
|
workspaceId: string,
|
||||||
|
type: LabelType,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Label> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.insertInto('labels')
|
||||||
|
.values({ name: normalizedName, type, workspaceId })
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['name', 'type', 'workspaceId']).doNothing(),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findByNameAndWorkspace(normalizedName, workspaceId, type, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
|
||||||
|
const query = this.db
|
||||||
|
.selectFrom('labels')
|
||||||
|
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
|
||||||
|
.select([
|
||||||
|
'labels.id',
|
||||||
|
'labels.name',
|
||||||
|
'labels.type',
|
||||||
|
'labels.createdAt',
|
||||||
|
'labels.updatedAt',
|
||||||
|
'labels.workspaceId',
|
||||||
|
])
|
||||||
|
.where('pageLabels.pageId', '=', pageId)
|
||||||
|
.where('labels.type', '=', LabelType.PAGE);
|
||||||
|
|
||||||
|
return 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' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
name: cursor.name,
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLabels(
|
||||||
|
workspaceId: string,
|
||||||
|
type: LabelType,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
) {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('labels')
|
||||||
|
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('type', '=', type);
|
||||||
|
|
||||||
|
if (pagination.query) {
|
||||||
|
query = query.where(
|
||||||
|
'name',
|
||||||
|
'like',
|
||||||
|
`%${pagination.query.toLowerCase()}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeWithCursorPagination(query, {
|
||||||
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
beforeCursor: pagination.beforeCursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'name', direction: 'asc' },
|
||||||
|
{ expression: 'id', direction: 'asc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor) => ({
|
||||||
|
name: cursor.name,
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getPageLabelCount(
|
||||||
|
pageId: 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('pageId', '=', pageId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -390,6 +390,22 @@ export interface Watchers {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Labels {
|
||||||
|
id: Generated<string>;
|
||||||
|
name: string;
|
||||||
|
type: Generated<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 {
|
export interface DB {
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -401,8 +417,10 @@ export interface DB {
|
|||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
|
labels: Labels;
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pageLabels: PageLabels;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
FileTasks,
|
FileTasks,
|
||||||
Groups,
|
Groups,
|
||||||
GroupUsers,
|
GroupUsers,
|
||||||
|
Labels,
|
||||||
Notifications,
|
Notifications,
|
||||||
PageHistory,
|
PageHistory,
|
||||||
|
PageLabels,
|
||||||
Pages,
|
Pages,
|
||||||
Shares,
|
Shares,
|
||||||
SpaceMembers,
|
SpaceMembers,
|
||||||
@@ -34,9 +36,11 @@ export interface DbInterface {
|
|||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
|
labels: Labels;
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pageLabels: PageLabels;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
|
Labels,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
PageLabels,
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
Users,
|
Users,
|
||||||
@@ -143,3 +145,12 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
|||||||
export type Watcher = Selectable<Watchers>;
|
export type Watcher = Selectable<Watchers>;
|
||||||
export type InsertableWatcher = Insertable<Watchers>;
|
export type InsertableWatcher = Insertable<Watchers>;
|
||||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user