Merge branch 'main' into tiptap3-migration

This commit is contained in:
Philipinho
2026-01-16 15:42:16 +00:00
29 changed files with 1101 additions and 964 deletions
+25 -25
View File
@@ -30,48 +30,48 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.9",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/core": "^11.1.11",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9",
"@nestjs/websockets": "^11.1.11",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.65",
"ai-sdk-ollama": "^0.12.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.0",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "20.0.10",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
@@ -79,9 +79,9 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.0",
"otpauth": "^9.4.1",
"p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@@ -95,11 +95,11 @@
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.3",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
},
"devDependencies": {
@@ -124,7 +124,7 @@
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.17.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
@@ -11,7 +11,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
+7 -10
View File
@@ -74,16 +74,13 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
+51 -49
View File
@@ -123,80 +123,82 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([
'id',
'slugId',
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'parentPageId',
'pages.parentPageId',
sql`0`.as('level'),
'shares.id as shareId',
'shares.key as shareKey',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
// Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'),
])
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.unionAll(
(union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where(sql`ph.level`, '<', sql`25`), // prevent loop
),
)
.selectFrom('page_hierarchy')
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([
'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
'shares.updatedAt',
])
.where('shares.id', 'is not', null)
.orderBy('page_hierarchy.level', 'asc')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.executeTakeFirst();
if (!share || share.workspaceId != workspaceId) {
if (!share || share.workspaceId !== workspaceId) {
return undefined;
}
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
if ((share.level as number) > 0 && !share.includeSubPages) {
return undefined;
}
return {
id: share.id,
key: share.key,
id: share.shareId,
key: share.shareKey,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.pageId,
pageId: share.id,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
id: share.id,
slugId: share.slugId,
title: share.title,
icon: share.icon,
},
};
}
@@ -9,7 +9,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string;
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsAlphanumeric()
slug: string;
}
@@ -293,24 +293,18 @@ export class PageRepo {
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -137,25 +137,19 @@ export class ShareRepo {
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,34 +209,33 @@ export class SpaceMemberRepo {
return roles;
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.db
getUserSpaceIdsQuery(userId: string) {
return this.db
.selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('userId', '=', userId)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('groupUsers.userId', '=', userId),
)
.execute();
);
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.getUserSpaceIdsQuery(userId).execute();
return membership.map((space) => space.id);
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
.orderBy('createdAt', 'asc');
if (pagination.query) {
@@ -253,14 +252,9 @@ export class SpaceMemberRepo {
);
}
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
}
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'gemini', 'ollama'])
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@@ -117,11 +117,10 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000'])
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@@ -129,13 +128,20 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@@ -10,46 +10,59 @@ import {
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User } from '@docmost/db/types/entity.types';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@Controller('file-tasks')
export class FileTaskController {
constructor(
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
async getFileTasks(@AuthUser() user: User) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (!userSpaceIds || userSpaceIds.length === 0) {
return [];
async getFileTasks(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
const fileTasks = await this.db
const query = this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', userSpaceIds)
.execute();
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
if (!fileTasks) {
throw new NotFoundException('File task not found');
}
return fileTasks;
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
}
@UseGuards(JwtAuthGuard)