mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
feat: favorites (#2103)
* feat: favorites and templates(ee) * rename migrations * fix sidebar * cleanup tabs * fix * turn off templates * cleanup * uuid validation
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class AddFavoriteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
export class RemoveFavoriteDto extends AddFavoriteDto {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ListFavoritesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type?: 'page' | 'space' | 'template';
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto';
|
||||
import { ListFavoritesDto } from './dto/list-favorites.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { FavoriteType } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('favorites')
|
||||
export class FavoriteController {
|
||||
constructor(
|
||||
private readonly favoriteService: FavoriteService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly templateRepo: TemplateRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('add')
|
||||
async addFavorite(
|
||||
@Body() dto: AddFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const resolved = await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.addFavorite(user.id, workspace.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.type === 'space' ? resolved.spaceId : undefined,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('remove')
|
||||
async removeFavorite(
|
||||
@Body() dto: RemoveFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.removeFavorite(user.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.spaceId,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async getUserFavorites(
|
||||
@Body() dto: ListFavoritesDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.favoriteService.getUserFavorites(
|
||||
user.id,
|
||||
workspace.id,
|
||||
pagination,
|
||||
dto.type as FavoriteType | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAndValidate(
|
||||
dto: AddFavoriteDto | RemoveFavoriteDto,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<{ spaceId: string; page?: Page }> {
|
||||
if (dto.type === 'page') {
|
||||
if (!dto.pageId) throw new BadRequestException('pageId is required');
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) throw new NotFoundException('Page not found');
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
return { spaceId: page.spaceId, page };
|
||||
}
|
||||
|
||||
if (dto.type === 'space') {
|
||||
if (!dto.spaceId) throw new BadRequestException('spaceId is required');
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) throw new NotFoundException('Space not found');
|
||||
await this.validateSpaceAccess(user.id, space.id);
|
||||
return { spaceId: space.id };
|
||||
}
|
||||
|
||||
if (dto.type === 'template') {
|
||||
if (!dto.templateId)
|
||||
throw new BadRequestException('templateId is required');
|
||||
const template = await this.templateRepo.findById(
|
||||
dto.templateId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!template) throw new NotFoundException('Template not found');
|
||||
if (template.spaceId) {
|
||||
await this.validateSpaceAccess(user.id, template.spaceId);
|
||||
}
|
||||
return { spaceId: template.spaceId };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid favorite type');
|
||||
}
|
||||
|
||||
private async validateSpaceAccess(
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<void> {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
if (!userSpaceIds.includes(spaceId)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { FavoriteController } from './favorite.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FavoriteController],
|
||||
providers: [FavoriteService],
|
||||
exports: [FavoriteService],
|
||||
})
|
||||
export class FavoriteModule {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FavoriteRepo,
|
||||
FavoriteType,
|
||||
} from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { InsertableFavorite } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async addFavorite(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const favorite: InsertableFavorite = {
|
||||
userId,
|
||||
pageId: opts.pageId ?? null,
|
||||
spaceId: opts.spaceId ?? null,
|
||||
templateId: opts.templateId ?? null,
|
||||
type: opts.type,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
await this.favoriteRepo.insert(favorite);
|
||||
}
|
||||
|
||||
async removeFavorite(
|
||||
userId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.type === FavoriteType.PAGE && opts.pageId) {
|
||||
await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId);
|
||||
} else if (opts.type === FavoriteType.SPACE && opts.spaceId) {
|
||||
await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId);
|
||||
} else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) {
|
||||
await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFavorites(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
) {
|
||||
const result = await this.favoriteRepo.findUserFavorites(
|
||||
userId,
|
||||
workspaceId,
|
||||
pagination,
|
||||
type,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
|
||||
const pageFavorites = result.items.filter(
|
||||
(f) => f.type === FavoriteType.PAGE && f.pageId,
|
||||
);
|
||||
|
||||
let accessiblePageSet: Set<string> | undefined;
|
||||
if (pageFavorites.length > 0) {
|
||||
const pageIds = pageFavorites.map((f) => f.pageId as string);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
accessiblePageSet = new Set(accessibleIds);
|
||||
}
|
||||
|
||||
result.items = result.items.filter((f) => {
|
||||
if (f.type === FavoriteType.PAGE) {
|
||||
return f.pageId && accessiblePageSet?.has(f.pageId);
|
||||
}
|
||||
if (f.type === FavoriteType.SPACE) {
|
||||
return f.spaceId && spaceSet.has(f.spaceId);
|
||||
}
|
||||
if (f.type === FavoriteType.TEMPLATE) {
|
||||
const templateSpaceId = (f as any).template?.spaceId;
|
||||
return !templateSpaceId || spaceSet.has(templateSpaceId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user