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:
Philip Okugbe
2026-04-12 22:06:25 +01:00
committed by GitHub
parent 57efb91bd3
commit d42091ccb1
90 changed files with 4557 additions and 187 deletions
@@ -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;
}
}