mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +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:
@@ -17,6 +17,7 @@ export const Feature = {
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@@ -31,6 +32,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
CommentModule,
|
||||
FavoriteModule,
|
||||
SearchModule,
|
||||
SpaceModule,
|
||||
GroupModule,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -29,6 +30,7 @@ export class GroupUserService {
|
||||
@Inject(forwardRef(() => GroupService))
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -137,6 +139,12 @@ export class GroupUserService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
@@ -34,6 +35,7 @@ export class GroupService {
|
||||
@Inject(forwardRef(() => GroupUserService))
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -189,6 +191,12 @@ export class GroupService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreatedByUserDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { SpaceIdDto } from './page.dto';
|
||||
|
||||
export class SidebarPageDto {
|
||||
@IsOptional()
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import {
|
||||
@@ -336,6 +337,29 @@ export class PageController {
|
||||
return this.pageService.getRecentPages(user.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('created-by-user')
|
||||
async getCreatedByPages(
|
||||
@Body() dto: CreatedByUserDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const targetUserId = dto.userId ?? user.id;
|
||||
|
||||
if (dto.spaceId) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
dto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('trash')
|
||||
async getDeletedPages(
|
||||
|
||||
@@ -300,7 +300,7 @@ export class PageService {
|
||||
}
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: 200,
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
@@ -856,6 +856,33 @@ export class PageService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
const result = await this.pageRepo.getCreatedByPages(
|
||||
creatorId,
|
||||
requestingUserId,
|
||||
pagination,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId: requestingUserId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getDeletedSpacePages(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ export class SpaceMemberService {
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -272,6 +274,12 @@ export class SpaceMemberService {
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
affectedUserIds,
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
|
||||
@@ -53,7 +53,41 @@ export class SpaceController {
|
||||
pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.spaceMemberService.getUserSpaces(user.id, pagination);
|
||||
const result = await this.spaceMemberService.getUserSpaces(
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const spaceIds = result.items.map((s) => s.id);
|
||||
const roles = await this.spaceMemberRepo.getUserRolesForSpaces(
|
||||
user.id,
|
||||
spaceIds,
|
||||
);
|
||||
|
||||
const roleMap = new Map<string, string[]>();
|
||||
for (const row of roles) {
|
||||
const existing = roleMap.get(row.spaceId) || [];
|
||||
existing.push(row.role);
|
||||
roleMap.set(row.spaceId, existing);
|
||||
}
|
||||
|
||||
result.items = result.items.map((space) => {
|
||||
const spaceRoles = roleMap.get(space.id);
|
||||
const role = spaceRoles
|
||||
? findHighestUserSpaceRole(
|
||||
spaceRoles.map((r) => ({ userId: user.id, role: r })),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...space,
|
||||
membership: { userId: user.id, role },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -54,4 +54,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -64,6 +65,7 @@ export class WorkspaceService {
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
private shareRepo: ShareRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@@ -328,7 +330,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -351,7 +354,8 @@ export class WorkspaceService {
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
@@ -458,6 +462,20 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined') {
|
||||
const prev = settingsBefore?.templates?.allowMemberTemplates ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowMemberTemplates) {
|
||||
before.allowMemberTemplates = prev;
|
||||
after.allowMemberTemplates = updateWorkspaceDto.allowMemberTemplates;
|
||||
}
|
||||
await this.workspaceRepo.updateTemplateSettings(
|
||||
workspaceId,
|
||||
'allowMemberTemplates',
|
||||
updateWorkspaceDto.allowMemberTemplates,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiChat !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.chat ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiChat) {
|
||||
@@ -477,6 +495,7 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
@@ -808,6 +827,10 @@ export class WorkspaceService {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
@@ -75,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
PagePermissionRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
@@ -82,6 +85,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
TemplateRepo,
|
||||
PageListener,
|
||||
],
|
||||
exports: [
|
||||
@@ -95,6 +99,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
PagePermissionRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
@@ -102,6 +107,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
TemplateRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('templates')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('title', 'varchar')
|
||||
.addColumn('description', 'text')
|
||||
.addColumn('content', 'jsonb')
|
||||
.addColumn('ydoc', 'bytea')
|
||||
.addColumn('icon', 'varchar')
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('collaborator_ids', sql`uuid[]`)
|
||||
.addColumn('text_content', 'text', (col) => col)
|
||||
.addColumn('tsv', sql`tsvector`, (col) => col)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_templates_workspace_id')
|
||||
.on('templates')
|
||||
.columns(['workspace_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_templates_space_id')
|
||||
.on('templates')
|
||||
.columns(['space_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('templates_tsv_idx')
|
||||
.on('templates')
|
||||
.using('GIN')
|
||||
.column('tsv')
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION templates_tsvector_trigger() RETURNS trigger AS $$
|
||||
begin
|
||||
new.tsv :=
|
||||
setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') ||
|
||||
setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
|
||||
return new;
|
||||
end;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`.execute(db);
|
||||
|
||||
await sql`CREATE OR REPLACE TRIGGER templates_tsvector_update BEFORE INSERT OR UPDATE
|
||||
ON templates FOR EACH ROW EXECUTE FUNCTION templates_tsvector_trigger();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER IF EXISTS templates_tsvector_update ON templates`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS templates_tsvector_trigger`.execute(db);
|
||||
await db.schema.dropTable('templates').execute();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('favorites')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('template_id', 'uuid', (col) =>
|
||||
col.references('templates.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.defaultTo(sql`now()`).notNull(),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_page')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'page_id'])
|
||||
.unique()
|
||||
.where('page_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_space')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'space_id'])
|
||||
.unique()
|
||||
.where('space_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_template')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'template_id'])
|
||||
.unique()
|
||||
.where('template_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_workspace_type')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'workspace_id', 'type'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('favorites').execute();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
export const FavoriteType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
TEMPLATE: 'template',
|
||||
} as const;
|
||||
|
||||
export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async insert(favorite: InsertableFavorite): Promise<Favorite | undefined> {
|
||||
try {
|
||||
return await this.db
|
||||
.insertInto('favorites')
|
||||
.values(favorite)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
} catch (err: any) {
|
||||
if (err?.code === '23505') return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByUserAndPage(userId: string, pageId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('pageId', '=', pageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndSpace(userId: string, spaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('type', '=', FavoriteType.SPACE)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndTemplate(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('templateId', '=', templateId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findUserFavorites(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('favorites')
|
||||
.selectAll('favorites')
|
||||
.where('favorites.userId', '=', userId)
|
||||
.where('favorites.workspaceId', '=', workspaceId);
|
||||
|
||||
if (type) {
|
||||
query = query.where('favorites.type', '=', type);
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE || !type) {
|
||||
query = query.select((eb) => this.withPage(eb));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE) {
|
||||
query = query.select((eb) => this.withPageSpace(eb));
|
||||
} else if (type === FavoriteType.SPACE) {
|
||||
query = query.select((eb) => this.withSpace(eb));
|
||||
} else {
|
||||
query = query.select((eb) => this.withSpaceResolved(eb));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.TEMPLATE || !type) {
|
||||
query = query.select((eb) => this.withTemplate(eb));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'favorites.id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByUsersWithoutSpaceAccess(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
const usersWithAccess = db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'is not', null)
|
||||
.union(
|
||||
db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
);
|
||||
|
||||
await db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'not in', usersWithAccess)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndWorkspace(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
).as('page');
|
||||
}
|
||||
|
||||
private withSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'favorites.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withPageSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.innerJoin('pages', 'pages.spaceId', 'spaces.id')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withSpaceResolved(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.where(({ or, ref }) =>
|
||||
or([
|
||||
sql<boolean>`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
|
||||
sql<boolean>`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
|
||||
]),
|
||||
),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withTemplate(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('templates')
|
||||
.select([
|
||||
'templates.id',
|
||||
'templates.title',
|
||||
'templates.description',
|
||||
'templates.icon',
|
||||
'templates.spaceId',
|
||||
])
|
||||
.whereRef('templates.id', '=', 'favorites.templateId'),
|
||||
).as('template');
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,35 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'updatedAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
|
||||
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
|
||||
return membership.map((space) => space.id);
|
||||
}
|
||||
|
||||
async getUserRolesForSpaces(
|
||||
userId: string,
|
||||
spaceIds: string[],
|
||||
): Promise<{ spaceId: string; role: string }[]> {
|
||||
if (spaceIds.length === 0) return [];
|
||||
|
||||
return this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['spaceId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', 'in', spaceIds)
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['spaceMembers.spaceId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', 'in', spaceIds),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getUserSpaces(userId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('spaces')
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
InsertableTemplate,
|
||||
Page,
|
||||
Template,
|
||||
UpdatableTemplate,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class TemplateRepo {
|
||||
private baseFields: Array<keyof Template> = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'icon',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'creatorId',
|
||||
'lastUpdatedById',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeContent?: boolean; trx?: KyselyTransaction },
|
||||
): Promise<Template> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('templates')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
|
||||
.select((eb) => [this.withCreator(eb)])
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
return query.executeTakeFirst() as Promise<Template>;
|
||||
}
|
||||
|
||||
async findTemplates(
|
||||
workspaceId: string,
|
||||
accessibleSpaceIds: string[],
|
||||
pagination: PaginationOptions,
|
||||
opts?: { spaceId?: string },
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('templates')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => [this.withCreator(eb)])
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||
query = query.where('spaceId', 'is', null);
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
...(accessibleSpaceIds.length > 0
|
||||
? [eb('spaceId', 'in', accessibleSpaceIds)]
|
||||
: []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (pagination.query) {
|
||||
const searchTerm = `%${pagination.query}%`;
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
|
||||
eb(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${searchTerm})`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'title', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
title: cursor.title,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async insertTemplate(
|
||||
insertableTemplate: InsertableTemplate,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ id: string }> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('templates')
|
||||
.values(insertableTemplate)
|
||||
.returning('id')
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
updatableTemplate: UpdatableTemplate,
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('templates')
|
||||
.set({ ...updatableTemplate, updatedAt: new Date() })
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteTemplate(
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('templates')
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'templates'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'templates.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
}
|
||||
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplateSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+32
@@ -175,6 +175,17 @@ export interface Comments {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Favorites {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
templateId: string | null;
|
||||
type: string;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface FileTasks {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -430,6 +441,25 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Templates {
|
||||
id: Generated<string>;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
content: Json | null;
|
||||
ydoc: Buffer | null;
|
||||
icon: string | null;
|
||||
spaceId: string | null;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
lastUpdatedById: string | null;
|
||||
collaboratorIds: string[] | null;
|
||||
textContent: string | null;
|
||||
tsv: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface AiChats {
|
||||
id: Generated<string>;
|
||||
workspaceId: string;
|
||||
@@ -481,6 +511,7 @@ export interface DB {
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
favorites: Favorites;
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
@@ -492,6 +523,7 @@ export interface DB {
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
templates: Templates;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -135,6 +137,11 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
|
||||
|
||||
// File Task
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
@@ -184,3 +191,8 @@ export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
|
||||
export type Audit = Selectable<_Audit>;
|
||||
export type InsertableAudit = Insertable<_Audit>;
|
||||
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
||||
|
||||
// Template
|
||||
export type Template = Selectable<Templates>;
|
||||
export type InsertableTemplate = Insertable<Templates>;
|
||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: d3bc4c5160...d80f660b20
Reference in New Issue
Block a user