diff --git a/apps/server/package.json b/apps/server/package.json index 8fa76122..75b1d21b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,17 +18,11 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config test/jest-e2e.json", - "typeorm": "typeorm-ts-node-commonjs -d src/database/typeorm.config.ts", - "migration:generate": "cd ./src/database/migrations/ && pnpm run typeorm migration:generate", - "migration:create": "cd ./src/database/migrations/ && typeorm-ts-node-commonjs migration:create", - "migration:run": "pnpm run typeorm migration:run", - "migration:revert": "pnpm run typeorm migration:revert", - "migration:show": "pnpm run typeorm migration:show", - "migration:make": "tsx ./src/kysely/migrate.ts create", + "migration:create": "tsx ./src/kysely/migrate.ts create", "migration:up": "tsx ./src/kysely/migrate.ts up", "migration:down": "tsx ./src/kysely/migrate.ts down", "migration:latest": "tsx ./src/kysely/migrate.ts latest", - "migration:reset": "tsx ./src/kysely/migrate.ts redo", + "migration:redo": "tsx ./src/kysely/migrate.ts redo", "migration:codegen": "kysely-codegen --dialect=postgres --env-file=../../.env --out-file=./src/kysely/types/db.d.ts" }, "dependencies": { @@ -46,7 +40,6 @@ "@nestjs/platform-fastify": "^10.3.5", "@nestjs/platform-socket.io": "^10.3.5", "@nestjs/serve-static": "^4.0.1", - "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.3.5", "@types/pg": "^8.11.4", "bcrypt": "^5.1.1", @@ -60,14 +53,13 @@ "mime-types": "^2.1.35", "nestjs-kysely": "^0.1.6", "passport-jwt": "^4.0.1", - "pg": "^8.11.3", + "pg": "^8.", "pg-tsquery": "^8.4.2", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "sanitize-filename-ts": "^1.0.2", "slugify": "^1.6.6", "socket.io": "^4.7.5", - "typeorm": "^0.3.20", "uuid": "^9.0.1", "ws": "^8.16.0" }, diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 13dc5a80..6c469571 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -4,38 +4,21 @@ import { AppService } from './app.service'; import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './integrations/environment/environment.module'; import { CollaborationModule } from './collaboration/collaboration.module'; -import { DatabaseModule } from './database/database.module'; import { WsModule } from './ws/ws.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; -import { KyselyModule } from 'nestjs-kysely'; -import { EnvironmentService } from './integrations/environment/environment.service'; -import { PostgresDialect } from 'kysely'; -import { Pool } from 'pg'; +import { KyselyDbModule } from './kysely/kysely-db.module'; @Module({ imports: [ CoreModule, + KyselyDbModule, EnvironmentModule, - DatabaseModule, CollaborationModule, WsModule, ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', '..', '..', 'client/dist'), }), - KyselyModule.forRootAsync({ - imports: [], - inject: [EnvironmentService], - useFactory: (envService: EnvironmentService) => { - return { - dialect: new PostgresDialect({ - pool: new Pool({ - connectionString: envService.getDatabaseURL(), - }) as any, - }), - }; - }, - }), ], controllers: [AppController], providers: [AppService], diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 1cbff20b..db9cb946 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -1,5 +1,4 @@ import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { UserModule } from '../core/user/user.module'; import { AuthModule } from '../core/auth/auth.module'; import { AuthenticationExtension } from './extensions/authentication.extension'; import { PersistenceExtension } from './extensions/persistence.extension'; @@ -18,7 +17,7 @@ import { HistoryExtension } from './extensions/history.extension'; PersistenceExtension, HistoryExtension, ], - imports: [UserModule, AuthModule, PageModule], + imports: [AuthModule, PageModule], }) export class CollaborationModule implements OnModuleInit, OnModuleDestroy { private collabWsAdapter: CollabWsAdapter; diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 7158c944..852c3049 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -1,13 +1,13 @@ import { Extension, onAuthenticatePayload } from '@hocuspocus/server'; -import { UserService } from '../../core/user/user.service'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { TokenService } from '../../core/auth/services/token.service'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; @Injectable() export class AuthenticationExtension implements Extension { constructor( private tokenService: TokenService, - private userService: UserService, + private userRepo: UserRepo, ) {} async onAuthenticate(data: onAuthenticatePayload) { @@ -22,7 +22,9 @@ export class AuthenticationExtension implements Extension { } const userId = jwtPayload.sub; - const user = await this.userService.findById(userId); + const workspaceId = jwtPayload.workspaceId; + + const user = await this.userRepo.findById(userId, workspaceId); if (!user) { throw new UnauthorizedException(); diff --git a/apps/server/src/collaboration/extensions/history.extension.ts b/apps/server/src/collaboration/extensions/history.extension.ts index 393ed79b..1c15dc1b 100644 --- a/apps/server/src/collaboration/extensions/history.extension.ts +++ b/apps/server/src/collaboration/extensions/history.extension.ts @@ -53,7 +53,8 @@ export class HistoryExtension implements Extension { async recordHistory(pageId: string) { try { - const page = await this.pageService.findWithContent(pageId); + const includeContent = true; + const page = await this.pageService.findById(pageId, includeContent); // Todo: compare if data is the same as the previous version await this.pageHistoryService.saveHistory(page); console.log(`New history created for: ${pageId}`); diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index e7f45dd9..05b04cf3 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -21,7 +21,7 @@ export class PersistenceExtension implements Extension { return; } - const page = await this.pageService.findWithAllFields(pageId); + const page = await this.pageService.findById(pageId, true, true); if (!page) { console.log('page does not exist.'); diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index d57e1c24..bb0f64f8 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -14,10 +14,9 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { AttachmentInterceptor } from './attachment.interceptor'; import * as bytes from 'bytes'; import { AuthUser } from '../../decorators/auth-user.decorator'; -import { User } from '../user/entities/user.entity'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @Controller('attachments') export class AttachmentController { @@ -31,6 +30,7 @@ export class AttachmentController { @Req() req: FastifyRequest, @Res() res: FastifyReply, @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, ) { const maxFileSize = bytes('5MB'); @@ -42,6 +42,7 @@ export class AttachmentController { const fileResponse = await this.attachmentService.uploadAvatar( file, user.id, + workspace.id, ); return res.send(fileResponse); diff --git a/apps/server/src/core/attachment/attachment.module.ts b/apps/server/src/core/attachment/attachment.module.ts index 8e5df457..9cb10065 100644 --- a/apps/server/src/core/attachment/attachment.module.ts +++ b/apps/server/src/core/attachment/attachment.module.ts @@ -2,20 +2,12 @@ import { Module } from '@nestjs/common'; import { AttachmentService } from './attachment.service'; import { AttachmentController } from './attachment.controller'; import { StorageModule } from '../../integrations/storage/storage.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Attachment } from './entities/attachment.entity'; -import { AttachmentRepository } from './repositories/attachment.repository'; import { UserModule } from '../user/user.module'; import { WorkspaceModule } from '../workspace/workspace.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Attachment]), - StorageModule, - UserModule, - WorkspaceModule, - ], + imports: [StorageModule, UserModule, WorkspaceModule], controllers: [AttachmentController], - providers: [AttachmentService, AttachmentRepository], + providers: [AttachmentService], }) export class AttachmentModule {} diff --git a/apps/server/src/core/attachment/attachment.service.ts b/apps/server/src/core/attachment/attachment.service.ts index 8257a9d0..14ee645d 100644 --- a/apps/server/src/core/attachment/attachment.service.ts +++ b/apps/server/src/core/attachment/attachment.service.ts @@ -1,8 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { StorageService } from '../../integrations/storage/storage.service'; import { MultipartFile } from '@fastify/multipart'; -import { AttachmentRepository } from './repositories/attachment.repository'; -import { Attachment } from './entities/attachment.entity'; import { UserService } from '../user/user.service'; import { UpdateUserDto } from '../user/dto/update-user.dto'; import { @@ -15,14 +13,16 @@ import { import { v4 as uuid4 } from 'uuid'; import { WorkspaceService } from '../workspace/services/workspace.service'; import { UpdateWorkspaceDto } from '../workspace/dto/update-workspace.dto'; +import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; +// TODO: make code better @Injectable() export class AttachmentService { constructor( private readonly storageService: StorageService, - private readonly attachmentRepo: AttachmentRepository, private readonly workspaceService: WorkspaceService, private readonly userService: UserService, + private readonly attachmentRepo: AttachmentRepo, ) {} async uploadToDrive(preparedFile: PreparedFile, filePath: string) { @@ -34,10 +34,10 @@ export class AttachmentService { } } - async updateUserAvatar(userId: string, avatarUrl: string) { + async updateUserAvatar(avatarUrl: string, userId: string, workspaceId) { const updateUserDto = new UpdateUserDto(); updateUserDto.avatarUrl = avatarUrl; - await this.userService.update(userId, updateUserDto); + await this.userService.update(updateUserDto, userId, workspaceId); } async updateWorkspaceLogo(workspaceId: string, logoUrl: string) { @@ -46,7 +46,11 @@ export class AttachmentService { await this.workspaceService.update(workspaceId, updateWorkspaceDto); } - async uploadAvatar(filePromise: Promise, userId: string) { + async uploadAvatar( + filePromise: Promise, + userId: string, + workspaceId: string, + ) { try { const preparedFile: PreparedFile = await prepareFile(filePromise); const allowedImageTypes = ['.jpg', '.jpeg', '.png']; @@ -60,19 +64,19 @@ export class AttachmentService { await this.uploadToDrive(preparedFile, filePath); - const attachment = new Attachment(); + // todo: in transaction + const attachment = await this.attachmentRepo.insertAttachment({ + creatorId: userId, + type: AttachmentType.Avatar, + filePath: filePath, + fileName: preparedFile.fileName, + fileSize: preparedFile.fileSize, + mimeType: preparedFile.mimeType, + fileExt: preparedFile.fileExtension, + workspaceId: workspaceId, + }); - attachment.creatorId = userId; - attachment.pageId = null; - attachment.workspaceId = null; - attachment.type = AttachmentType.Avatar; - attachment.filePath = filePath; - attachment.fileName = preparedFile.fileName; - attachment.fileSize = preparedFile.fileSize; - attachment.mimeType = preparedFile.mimeType; - attachment.fileExt = preparedFile.fileExtension; - - await this.updateUserAvatar(userId, filePath); + await this.updateUserAvatar(filePath, userId, workspaceId); return attachment; } catch (err) { @@ -102,17 +106,17 @@ export class AttachmentService { await this.uploadToDrive(preparedFile, filePath); - const attachment = new Attachment(); - - attachment.creatorId = userId; - attachment.pageId = null; - attachment.workspaceId = workspaceId; - attachment.type = AttachmentType.WorkspaceLogo; - attachment.filePath = filePath; - attachment.fileName = preparedFile.fileName; - attachment.fileSize = preparedFile.fileSize; - attachment.mimeType = preparedFile.mimeType; - attachment.fileExt = preparedFile.fileExtension; + // todo: in trx + const attachment = await this.attachmentRepo.insertAttachment({ + creatorId: userId, + type: AttachmentType.WorkspaceLogo, + filePath: filePath, + fileName: preparedFile.fileName, + fileSize: preparedFile.fileSize, + mimeType: preparedFile.mimeType, + fileExt: preparedFile.fileExtension, + workspaceId: workspaceId, + }); await this.updateWorkspaceLogo(workspaceId, filePath); @@ -143,17 +147,17 @@ export class AttachmentService { await this.uploadToDrive(preparedFile, filePath); - const attachment = new Attachment(); - - attachment.creatorId = userId; - attachment.pageId = pageId; - attachment.workspaceId = workspaceId; - attachment.type = AttachmentType.WorkspaceLogo; - attachment.filePath = filePath; - attachment.fileName = preparedFile.fileName; - attachment.fileSize = preparedFile.fileSize; - attachment.mimeType = preparedFile.mimeType; - attachment.fileExt = preparedFile.fileExtension; + const attachment = await this.attachmentRepo.insertAttachment({ + creatorId: userId, + pageId: pageId, + type: AttachmentType.File, + filePath: filePath, + fileName: preparedFile.fileName, + fileSize: preparedFile.fileSize, + mimeType: preparedFile.mimeType, + fileExt: preparedFile.fileExtension, + workspaceId: workspaceId, + }); return attachment; } catch (err) { diff --git a/apps/server/src/core/attachment/entities/attachment.entity.ts b/apps/server/src/core/attachment/entities/attachment.entity.ts deleted file mode 100644 index 0a26b592..00000000 --- a/apps/server/src/core/attachment/entities/attachment.entity.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, - CreateDateColumn, - DeleteDateColumn, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Page } from '../../page/entities/page.entity'; -import { Workspace } from '../../workspace/entities/workspace.entity'; - -@Entity('attachments') -export class Attachment { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 255 }) - fileName: string; - - @Column({ type: 'varchar' }) - filePath: string; - - @Column({ type: 'bigint' }) - fileSize: number; - - @Column({ type: 'varchar', length: 55 }) - fileExt: string; - - @Column({ type: 'varchar', length: 255 }) - mimeType: string; - - @Column({ type: 'varchar', length: 55 }) - type: string; // e.g. page / workspace / avatar - - @Column() - creatorId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @Column({ nullable: true }) - pageId: string; - - @ManyToOne(() => Page) - @JoinColumn({ name: 'pageId' }) - page: Page; - - @Column({ nullable: true }) - workspaceId: string; - - @ManyToOne(() => Workspace, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @CreateDateColumn() - createdAt: Date; - - @DeleteDateColumn({ nullable: true }) - deletedAt: Date; -} diff --git a/apps/server/src/core/attachment/repositories/attachment.repository.ts b/apps/server/src/core/attachment/repositories/attachment.repository.ts deleted file mode 100644 index 0f3a4b34..00000000 --- a/apps/server/src/core/attachment/repositories/attachment.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; -import { Injectable } from '@nestjs/common'; -import { Attachment } from '../entities/attachment.entity'; - -@Injectable() -export class AttachmentRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Attachment, dataSource.createEntityManager()); - } - - async findById(id: string) { - return this.findOneBy({ id: id }); - } -} diff --git a/apps/server/src/core/auth/auth.utils.ts b/apps/server/src/core/auth/auth.utils.ts deleted file mode 100644 index 308f1a90..00000000 --- a/apps/server/src/core/auth/auth.utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as bcrypt from 'bcrypt'; - -export async function comparePasswordHash( - plainPassword: string, - passwordHash: string, -): Promise { - return bcrypt.compare(plainPassword, passwordHash); -} diff --git a/apps/server/src/core/auth/guards/setup.guard.ts b/apps/server/src/core/auth/guards/setup.guard.ts index 534b9372..07231c77 100644 --- a/apps/server/src/core/auth/guards/setup.guard.ts +++ b/apps/server/src/core/auth/guards/setup.guard.ts @@ -1,11 +1,12 @@ import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common'; -import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @Injectable() export class SetupGuard implements CanActivate { - constructor(private workspaceRepository: WorkspaceRepository) {} + constructor(private workspaceRepo: WorkspaceRepo) {} + async canActivate(): Promise { - const workspaceCount = await this.workspaceRepository.count(); + const workspaceCount = await this.workspaceRepo.count(); if (workspaceCount > 0) { throw new ForbiddenException('Workspace setup already completed.'); } diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 789f8946..e1a562a4 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -1,14 +1,13 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { LoginDto } from '../dto/login.dto'; -import { User } from '../../user/entities/user.entity'; import { CreateUserDto } from '../dto/create-user.dto'; import { UserService } from '../../user/user.service'; import { TokenService } from './token.service'; import { TokensDto } from '../dto/tokens.dto'; -import { UserRepository } from '../../user/repositories/user.repository'; -import { comparePasswordHash } from '../auth.utils'; import { SignupService } from './signup.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { comparePasswordHash } from '../../../helpers/utils'; @Injectable() export class AuthService { @@ -16,14 +15,11 @@ export class AuthService { private userService: UserService, private signupService: SignupService, private tokenService: TokenService, - private userRepository: UserRepository, + private userRepo: UserRepo, ) {} async login(loginDto: LoginDto, workspaceId: string) { - const user = await this.userRepository.findOneByEmail( - loginDto.email, - workspaceId, - ); + const user = await this.userRepo.findByEmail(loginDto.email, workspaceId); if ( !user || @@ -33,17 +29,14 @@ export class AuthService { } user.lastLoginAt = new Date(); - await this.userRepository.save(user); + await this.userRepo.updateLastLogin(user.id, workspaceId); const tokens: TokensDto = await this.tokenService.generateTokens(user); return { tokens }; } async register(createUserDto: CreateUserDto, workspaceId: string) { - const user: User = await this.signupService.signup( - createUserDto, - workspaceId, - ); + const user = await this.signupService.signup(createUserDto, workspaceId); const tokens: TokensDto = await this.tokenService.generateTokens(user); @@ -51,8 +44,7 @@ export class AuthService { } async setup(createAdminUserDto: CreateAdminUserDto) { - const user: User = - await this.signupService.initialSetup(createAdminUserDto); + const user = await this.signupService.initialSetup(createAdminUserDto); const tokens: TokensDto = await this.tokenService.generateTokens(user); diff --git a/apps/server/src/core/auth/services/signup.service.ts b/apps/server/src/core/auth/services/signup.service.ts index 834e5a4d..3c5c8936 100644 --- a/apps/server/src/core/auth/services/signup.service.ts +++ b/apps/server/src/core/auth/services/signup.service.ts @@ -1,140 +1,95 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { CreateUserDto } from '../dto/create-user.dto'; -import { DataSource, EntityManager } from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { transactionWrapper } from '../../../helpers/db.helper'; -import { UserRepository } from '../../user/repositories/user.repository'; -import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository'; import { WorkspaceService } from '../../workspace/services/workspace.service'; import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto'; -import { Workspace } from '../../workspace/entities/workspace.entity'; import { SpaceService } from '../../space/services/space.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { GroupUserService } from '../../group/services/group-user.service'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { InjectKysely } from 'nestjs-kysely'; +import { User } from '@docmost/db/types/entity.types'; @Injectable() export class SignupService { constructor( - private userRepository: UserRepository, - private workspaceRepository: WorkspaceRepository, + private userRepo: UserRepo, private workspaceService: WorkspaceService, private spaceService: SpaceService, private groupUserService: GroupUserService, - private dataSource: DataSource, + @InjectKysely() private readonly db: KyselyDB, ) {} - prepareUser(createUserDto: CreateUserDto): User { - const user = new User(); - user.name = createUserDto.name || createUserDto.email.split('@')[0]; - user.email = createUserDto.email.toLowerCase(); - user.password = createUserDto.password; - user.locale = 'en'; - user.lastLoginAt = new Date(); - return user; - } - - async createUser( - createUserDto: CreateUserDto, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (transactionManager: EntityManager) => { - let user = this.prepareUser(createUserDto); - user = await transactionManager.save(user); - return user; - }, - this.dataSource, - manager, - ); - } - async signup( createUserDto: CreateUserDto, workspaceId: string, - manager?: EntityManager, + trx?: KyselyTransaction, ): Promise { - const userCheck = await this.userRepository.findOneByEmail( + const userCheck = await this.userRepo.findByEmail( createUserDto.email, workspaceId, ); + if (userCheck) { throw new BadRequestException( 'You already have an account on this workspace', ); } - return await transactionWrapper( - async (manager: EntityManager) => { + return await executeTx( + this.db, + async (trx) => { // create user - const user = await this.createUser(createUserDto, manager); + const user = await this.userRepo.insertUser(createUserDto, trx); // add user to workspace await this.workspaceService.addUserToWorkspace( - user, + user.id, workspaceId, undefined, - manager, + trx, ); // add user to default group await this.groupUserService.addUserToDefaultGroup( user.id, workspaceId, - manager, + trx, ); - return user; }, - this.dataSource, - manager, + trx, ); } - async createWorkspace( - user: User, - workspaceName, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - // for cloud + async createWorkspace(user, workspaceName, trx?: KyselyTransaction) { + return await executeTx( + this.db, + async (trx) => { const workspaceData: CreateWorkspaceDto = { name: workspaceName, - // hostname: '', // generate }; - return await this.workspaceService.create(user, workspaceData, manager); + return await this.workspaceService.create(user, workspaceData, trx); }, - this.dataSource, - manager, + trx, ); } async initialSetup( createAdminUserDto: CreateAdminUserDto, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { // create user - const user = await this.createUser(createAdminUserDto, manager); - await this.createWorkspace( - user, - createAdminUserDto.workspaceName, - manager, - ); + const user = await this.userRepo.insertUser(createAdminUserDto, trx); + await this.createWorkspace(user, createAdminUserDto.workspaceName, trx); return user; }, - this.dataSource, - manager, + trx, ); } } - -// create user - -// create workspace - -// create default group -// create space -// add group to space instead of user - -// add new users to default group diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index 2c616b82..040d8fcc 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; -import { User } from '../../user/entities/user.entity'; import { TokensDto } from '../dto/tokens.dto'; import { JwtPayload, JwtRefreshPayload, JwtType } from '../dto/jwt-payload'; +import { User } from '@docmost/db/types/entity.types'; @Injectable() export class TokenService { @@ -32,7 +32,7 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn }); } - async generateTokens(user: User): Promise { + async generateTokens(user): Promise { return { accessToken: await this.generateAccessToken(user), refreshToken: await this.generateRefreshToken(user.id, user.workspaceId), diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index b437b8c0..e521e03e 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -7,18 +7,14 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { JwtPayload, JwtType } from '../dto/jwt-payload'; -import { AuthService } from '../services/auth.service'; -import { UserRepository } from '../../user/repositories/user.repository'; -import { UserService } from '../../user/user.service'; -import { WorkspaceRepository } from '../../workspace/repositories/workspace.repository'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( - private authService: AuthService, - private userService: UserService, - private userRepository: UserRepository, - private workspaceRepository: WorkspaceRepository, + private userRepo: UserRepo, + private workspaceRepo: WorkspaceRepo, private readonly environmentService: EnvironmentService, ) { super({ @@ -29,7 +25,11 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(req, payload: JwtPayload) { + async validate(req: any, payload: JwtPayload) { + if (!payload.workspaceId || payload.type !== JwtType.ACCESS) { + throw new UnauthorizedException(); + } + // CLOUD ENV if (this.environmentService.isCloud()) { if (req.raw.workspaceId && req.raw.workspaceId !== payload.workspaceId) { @@ -37,23 +37,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } } - if (!payload.workspaceId || payload.type !== JwtType.ACCESS) { - throw new UnauthorizedException(); - } - - const workspace = await this.workspaceRepository.findById( - payload.workspaceId, - ); + const workspace = await this.workspaceRepo.findById(payload.workspaceId); if (!workspace) { throw new UnauthorizedException(); } - const user = await this.userRepository.findOne({ - where: { - id: payload.sub, - workspaceId: payload.workspaceId, - }, - }); + const user = await this.userRepo.findById(payload.sub, payload.workspaceId); if (!user) { throw new UnauthorizedException(); diff --git a/apps/server/src/core/casl/abilities/casl-ability.factory.ts b/apps/server/src/core/casl/abilities/casl-ability.factory.ts index 5176450d..75f83750 100644 --- a/apps/server/src/core/casl/abilities/casl-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/casl-ability.factory.ts @@ -3,87 +3,62 @@ import { AbilityBuilder, createMongoAbility, ExtractSubjectType, - InferSubjects, MongoAbility, } from '@casl/ability'; -import { User } from '../../user/entities/user.entity'; import { Action } from '../ability.action'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { WorkspaceInvitation } from '../../workspace/entities/workspace-invitation.entity'; import { UserRole } from '../../../helpers/types/permission'; -import { Group } from '../../group/entities/group.entity'; -import { GroupUser } from '../../group/entities/group-user.entity'; -import { Attachment } from '../../attachment/entities/attachment.entity'; -import { Space } from '../../space/entities/space.entity'; -import { Page } from '../../page/entities/page.entity'; -import { Comment } from '../../comment/entities/comment.entity'; -import { SpaceMember } from '../../space/entities/space-member.entity'; +import { User, Workspace } from '@docmost/db/types/entity.types'; export type Subjects = - | InferSubjects< - | typeof Workspace - | typeof WorkspaceInvitation - | typeof Space - | typeof SpaceMember - | typeof Group - | typeof GroupUser - | typeof Attachment - | typeof Comment - | typeof Page - | typeof User - > - | 'workspaceUser' + | 'Workspace' + | 'WorkspaceInvitation' + | 'Space' + | 'SpaceMember' + | 'Group' + | 'GroupUser' + | 'Attachment' + | 'Comment' + | 'Page' + | 'User' + | 'WorkspaceUser' | 'all'; export type AppAbility = MongoAbility<[Action, Subjects]>; @Injectable() export default class CaslAbilityFactory { - createForWorkspace(user: User, workspace: Workspace) { + createForUser(user: User, workspace: Workspace) { const { can, build } = new AbilityBuilder(createMongoAbility); const userRole = user.role; if (userRole === UserRole.OWNER || userRole === UserRole.ADMIN) { // Workspace Users - can([Action.Manage], Workspace); - can([Action.Manage], 'workspaceUser'); + can([Action.Manage], 'Workspace'); + can([Action.Manage], 'WorkspaceUser'); - can([Action.Manage], WorkspaceInvitation); + can([Action.Manage], 'WorkspaceInvitation'); // Groups - can([Action.Manage], Group); - can([Action.Manage], GroupUser); + can([Action.Manage], 'Group'); + can([Action.Manage], 'GroupUser'); // Attachments - can([Action.Manage], Attachment); + can([Action.Manage], 'Attachment'); } if (userRole === UserRole.MEMBER) { // can([Action.Read], WorkspaceUser); // Groups - can([Action.Read], Group); - can([Action.Read], GroupUser); + can([Action.Read], 'Group'); + can([Action.Read], 'GroupUser'); // Attachments - can([Action.Read, Action.Create], Attachment); + can([Action.Read, Action.Create], 'Attachment'); } return build({ - detectSubjectType: (item) => - item.constructor as ExtractSubjectType, - }); - } - - createForUser(user: User) { - const { can, build } = new AbilityBuilder(createMongoAbility); - - can([Action.Manage], User, { id: user.id }); - can([Action.Read], User); - - return build({ - detectSubjectType: (item) => - item.constructor as ExtractSubjectType, + detectSubjectType: (item) => item as ExtractSubjectType, }); } } diff --git a/apps/server/src/core/casl/guards/policies.guard.ts b/apps/server/src/core/casl/guards/policies.guard.ts index 2ba18e08..6a27e3c0 100644 --- a/apps/server/src/core/casl/guards/policies.guard.ts +++ b/apps/server/src/core/casl/guards/policies.guard.ts @@ -24,7 +24,7 @@ export class PoliciesGuard implements CanActivate { const user = request.user.user; const workspace = request.user.workspace; - const ability = this.caslAbilityFactory.createForWorkspace(user, workspace); + const ability = this.caslAbilityFactory.createForUser(user, workspace); return policyHandlers.every((handler) => this.execPolicyHandler(handler, ability), diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 2e4646f4..367d7fa9 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -10,12 +10,11 @@ import { CommentService } from './comment.service'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentsInput, SingleCommentInput } from './dto/comments.input'; -import { ResolveCommentDto } from './dto/resolve-comment.dto'; import { AuthUser } from '../../decorators/auth-user.decorator'; -import { User } from '../user/entities/user.entity'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('comments') @@ -34,8 +33,14 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post() - findPageComments(@Body() input: CommentsInput) { - return this.commentService.findByPageId(input.pageId); + findPageComments( + @Body() input: CommentsInput, + @Body() + pagination: PaginationOptions, + //@AuthUser() user: User, + // @AuthWorkspace() workspace: Workspace, + ) { + return this.commentService.findByPageId(input.pageId, pagination); } @HttpCode(HttpStatus.OK) @@ -50,15 +55,6 @@ export class CommentController { return this.commentService.update(updateCommentDto.id, updateCommentDto); } - @HttpCode(HttpStatus.OK) - @Post('resolve') - resolve( - @Body() resolveCommentDto: ResolveCommentDto, - @AuthUser() user: User, - ) { - return this.commentService.resolveComment(user.id, resolveCommentDto); - } - @HttpCode(HttpStatus.OK) @Post('delete') remove(@Body() input: SingleCommentInput) { diff --git a/apps/server/src/core/comment/comment.module.ts b/apps/server/src/core/comment/comment.module.ts index a3949848..a32b2fde 100644 --- a/apps/server/src/core/comment/comment.module.ts +++ b/apps/server/src/core/comment/comment.module.ts @@ -1,15 +1,12 @@ import { Module } from '@nestjs/common'; import { CommentService } from './comment.service'; import { CommentController } from './comment.controller'; -import { CommentRepository } from './repositories/comment.repository'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Comment } from './entities/comment.entity'; import { PageModule } from '../page/page.module'; @Module({ - imports: [TypeOrmModule.forFeature([Comment]), PageModule], + imports: [PageModule], controllers: [CommentController], - providers: [CommentService, CommentRepository], - exports: [CommentService, CommentRepository], + providers: [CommentService], + exports: [CommentService], }) export class CommentModule {} diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 23d50095..d74714cb 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -1,24 +1,22 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; -import { plainToInstance } from 'class-transformer'; -import { Comment } from './entities/comment.entity'; -import { CommentRepository } from './repositories/comment.repository'; -import { ResolveCommentDto } from './dto/resolve-comment.dto'; import { PageService } from '../page/services/page.service'; +import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; +import { Comment } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { PaginatedResult } from 'src/helpers/pagination/paginated-result'; +import { PaginationMetaDto } from 'src/helpers/pagination/pagination-meta-dto'; @Injectable() export class CommentService { constructor( - private commentRepository: CommentRepository, + private commentRepo: CommentRepo, private pageService: PageService, ) {} async findWithCreator(commentId: string) { - return await this.commentRepository.findOne({ - where: { id: commentId }, - relations: ['creator'], - }); + // todo: find comment with creator object } async create( @@ -26,25 +24,19 @@ export class CommentService { workspaceId: string, createCommentDto: CreateCommentDto, ) { - const comment = plainToInstance(Comment, createCommentDto); - comment.creatorId = userId; - comment.workspaceId = workspaceId; - comment.content = JSON.parse(createCommentDto.content); + const commentContent = JSON.parse(createCommentDto.content); - if (createCommentDto.selection) { - comment.selection = createCommentDto.selection.substring(0, 250); - } + const page = await this.pageService.findById(createCommentDto.pageId); + // const spaceId = null; // todo, get from page - const page = await this.pageService.findWithBasic(createCommentDto.pageId); if (!page) { throw new BadRequestException('Page not found'); } if (createCommentDto.parentCommentId) { - const parentComment = await this.commentRepository.findOne({ - where: { id: createCommentDto.parentCommentId }, - select: ['id', 'parentCommentId'], - }); + const parentComment = await this.commentRepo.findById( + createCommentDto.parentCommentId, + ); if (!parentComment) { throw new BadRequestException('Parent comment not found'); @@ -55,68 +47,51 @@ export class CommentService { } } - const savedComment = await this.commentRepository.save(comment); - return this.findWithCreator(savedComment.id); + const createdComment = await this.commentRepo.insertComment({ + pageId: createCommentDto.pageId, + content: commentContent, + selection: createCommentDto?.selection.substring(0, 250), + type: 'inline', // for now + parentCommentId: createCommentDto?.parentCommentId, + creatorId: userId, + workspaceId: workspaceId, + }); + // todo return created comment and creator relation + + return createdComment; } - async findByPageId(pageId: string, offset = 0, limit = 100) { - const comments = this.commentRepository.find({ - where: { - pageId: pageId, - }, - order: { - createdAt: 'asc', - }, - take: limit, - skip: offset, - relations: ['creator'], - }); - return comments; + async findByPageId( + pageId: string, + paginationOptions: PaginationOptions, + ): Promise> { + const { comments, count } = await this.commentRepo.findPageComments( + pageId, + paginationOptions, + ); + + const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); + return new PaginatedResult(comments, paginationMeta); } async update( commentId: string, updateCommentDto: UpdateCommentDto, ): Promise { - updateCommentDto.content = JSON.parse(updateCommentDto.content); + const commentContent = JSON.parse(updateCommentDto.content); - const result = await this.commentRepository.update(commentId, { - ...updateCommentDto, - editedAt: new Date(), - }); - if (result.affected === 0) { - throw new BadRequestException(`Comment not found`); - } - - return this.findWithCreator(commentId); - } - - async resolveComment( - userId: string, - resolveCommentDto: ResolveCommentDto, - ): Promise { - const resolvedAt = resolveCommentDto.resolved ? new Date() : null; - const resolvedById = resolveCommentDto.resolved ? userId : null; - - const result = await this.commentRepository.update( - resolveCommentDto.commentId, + await this.commentRepo.updateComment( { - resolvedAt, - resolvedById, + content: commentContent, + editedAt: new Date(), }, + commentId, ); - if (result.affected === 0) { - throw new BadRequestException(`Comment not found`); - } - - return this.findWithCreator(resolveCommentDto.commentId); + return this.commentRepo.findById(commentId); } async remove(id: string): Promise { - const result = await this.commentRepository.delete(id); - if (result.affected === 0) { - throw new BadRequestException(`Comment with ID ${id} not found.`); - } + await this.commentRepo.deleteComment(id); } } diff --git a/apps/server/src/core/comment/dto/resolve-comment.dto.ts b/apps/server/src/core/comment/dto/resolve-comment.dto.ts deleted file mode 100644 index 73bc9cd2..00000000 --- a/apps/server/src/core/comment/dto/resolve-comment.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsBoolean, IsUUID } from 'class-validator'; - -export class ResolveCommentDto { - @IsUUID() - commentId: string; - - @IsBoolean() - resolved: boolean; -} diff --git a/apps/server/src/core/comment/entities/comment.entity.ts b/apps/server/src/core/comment/entities/comment.entity.ts deleted file mode 100644 index 8069705e..00000000 --- a/apps/server/src/core/comment/entities/comment.entity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, - CreateDateColumn, - OneToMany, - DeleteDateColumn, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Page } from '../../page/entities/page.entity'; -import { Workspace } from '../../workspace/entities/workspace.entity'; - -@Entity('comments') -export class Comment { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'jsonb', nullable: true }) - content: any; - - @Column({ type: 'varchar', length: 255, nullable: true }) - selection: string; - - @Column({ type: 'varchar', length: 55, nullable: true }) - type: string; - - @Column() - creatorId: string; - - @ManyToOne(() => User, (user) => user.comments) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @Column() - pageId: string; - - @ManyToOne(() => Page, (page) => page.comments, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'pageId' }) - page: Page; - - @Column({ type: 'uuid', nullable: true }) - parentCommentId: string; - - @ManyToOne(() => Comment, (comment) => comment.replies, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'parentCommentId' }) - parentComment: Comment; - - @OneToMany(() => Comment, (comment) => comment.parentComment) - replies: Comment[]; - - @Column({ nullable: true }) - resolvedById: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'resolvedById' }) - resolvedBy: User; - - @Column({ type: 'timestamp', nullable: true }) - resolvedAt: Date; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, (workspace) => workspace.comments, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @CreateDateColumn() - createdAt: Date; - - @Column({ type: 'timestamp', nullable: true }) - editedAt: Date; - - @DeleteDateColumn({ nullable: true }) - deletedAt: Date; -} diff --git a/apps/server/src/core/comment/repositories/comment.repository.ts b/apps/server/src/core/comment/repositories/comment.repository.ts deleted file mode 100644 index 8a829dd1..00000000 --- a/apps/server/src/core/comment/repositories/comment.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; -import { Injectable } from '@nestjs/common'; -import { Comment } from '../entities/comment.entity'; - -@Injectable() -export class CommentRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Comment, dataSource.createEntityManager()); - } - - async findById(commentId: string) { - return this.findOneBy({ id: commentId }); - } -} diff --git a/apps/server/src/core/group/entities/group-user.entity.ts b/apps/server/src/core/group/entities/group-user.entity.ts deleted file mode 100644 index 6307008f..00000000 --- a/apps/server/src/core/group/entities/group-user.entity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - Unique, - UpdateDateColumn, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Group } from './group.entity'; - -@Entity('group_users') -@Unique(['groupId', 'userId']) -export class GroupUser { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - userId: string; - - @ManyToOne(() => User, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'userId' }) - user: User; - - @Column() - groupId: string; - - @ManyToOne(() => Group, (group) => group.groupUsers, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'groupId' }) - group: Group; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/group/entities/group.entity.ts b/apps/server/src/core/group/entities/group.entity.ts deleted file mode 100644 index 54ed32c1..00000000 --- a/apps/server/src/core/group/entities/group.entity.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { GroupUser } from './group-user.entity'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { User } from '../../user/entities/user.entity'; -import { Unique } from 'typeorm'; -import { SpaceMember } from '../../space/entities/space-member.entity'; - -@Entity('groups') -@Unique(['name', 'workspaceId']) -export class Group { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 255 }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ type: 'boolean', default: false }) - isDefault: boolean; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, (workspace) => workspace.groups, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @Column({ nullable: true }) - creatorId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - @OneToMany(() => GroupUser, (groupUser) => groupUser.group) - groupUsers: GroupUser[]; - - @OneToMany(() => SpaceMember, (spaceMembership) => spaceMembership.group) - spaces: SpaceMember[]; - - memberCount?: number; -} diff --git a/apps/server/src/core/group/group.controller.ts b/apps/server/src/core/group/group.controller.ts index 7a31bd97..d19b23e8 100644 --- a/apps/server/src/core/group/group.controller.ts +++ b/apps/server/src/core/group/group.controller.ts @@ -10,8 +10,6 @@ import { GroupService } from './services/group.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { User } from '../user/entities/user.entity'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { GroupUserService } from './services/group-user.service'; import { GroupIdDto } from './dto/group-id.dto'; import { PaginationOptions } from '../../helpers/pagination/pagination-options'; @@ -19,12 +17,11 @@ import { AddGroupUserDto } from './dto/add-group-user.dto'; import { RemoveGroupUserDto } from './dto/remove-group-user.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; import { Action } from '../casl/ability.action'; -import { Group } from './entities/group.entity'; -import { GroupUser } from './entities/group-user.entity'; import { PoliciesGuard } from '../casl/guards/policies.guard'; import { CheckPolicies } from '../casl/decorators/policies.decorator'; import { AppAbility } from '../casl/abilities/casl-ability.factory'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('groups') @@ -45,7 +42,7 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Group)) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group')) @HttpCode(HttpStatus.OK) @Post('/info') getGroup( @@ -57,7 +54,7 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group')) @HttpCode(HttpStatus.OK) @Post('create') createGroup( @@ -69,7 +66,7 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group')) @HttpCode(HttpStatus.OK) @Post('update') updateGroup( @@ -81,7 +78,7 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, GroupUser)) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'GroupUser')) @HttpCode(HttpStatus.OK) @Post('members') getGroupMembers( @@ -97,7 +94,9 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, GroupUser)) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, 'GroupUser'), + ) @HttpCode(HttpStatus.OK) @Post('members/add') addGroupMember( @@ -113,7 +112,9 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, GroupUser)) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, 'GroupUser'), + ) @HttpCode(HttpStatus.OK) @Post('members/remove') removeGroupMember( @@ -129,7 +130,7 @@ export class GroupController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Group)) + @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group')) @HttpCode(HttpStatus.OK) @Post('delete') deleteGroup( diff --git a/apps/server/src/core/group/group.module.ts b/apps/server/src/core/group/group.module.ts index a6dcb3e2..41d03a52 100644 --- a/apps/server/src/core/group/group.module.ts +++ b/apps/server/src/core/group/group.module.ts @@ -1,22 +1,11 @@ import { Module } from '@nestjs/common'; import { GroupService } from './services/group.service'; import { GroupController } from './group.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Group } from './entities/group.entity'; -import { GroupUser } from './entities/group-user.entity'; -import { GroupRepository } from './respositories/group.repository'; -import { GroupUserRepository } from './respositories/group-user.repository'; import { GroupUserService } from './services/group-user.service'; @Module({ - imports: [TypeOrmModule.forFeature([Group, GroupUser])], controllers: [GroupController], - providers: [ - GroupService, - GroupUserService, - GroupRepository, - GroupUserRepository, - ], + providers: [GroupService, GroupUserService], exports: [GroupService, GroupUserService], }) export class GroupModule {} diff --git a/apps/server/src/core/group/respositories/group-user.repository.ts b/apps/server/src/core/group/respositories/group-user.repository.ts deleted file mode 100644 index 63cbe0b9..00000000 --- a/apps/server/src/core/group/respositories/group-user.repository.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { GroupUser } from '../entities/group-user.entity'; - -@Injectable() -export class GroupUserRepository extends Repository { - constructor(private dataSource: DataSource) { - super(GroupUser, dataSource.createEntityManager()); - } -} diff --git a/apps/server/src/core/group/respositories/group.repository.ts b/apps/server/src/core/group/respositories/group.repository.ts deleted file mode 100644 index f585ec07..00000000 --- a/apps/server/src/core/group/respositories/group.repository.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { Group } from '../entities/group.entity'; - -@Injectable() -export class GroupRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Group, dataSource.createEntityManager()); - } -} diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index a906b393..95e532ee 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -1,48 +1,35 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; -import { GroupUserRepository } from '../respositories/group-user.repository'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; -import { transactionWrapper } from '../../../helpers/db.helper'; -import { User } from '../../user/entities/user.entity'; -import { GroupUser } from '../entities/group-user.entity'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; -import { Group } from '../entities/group.entity'; import { GroupService } from './group.service'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { InjectKysely } from 'nestjs-kysely'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; +import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; +import { User } from '@docmost/db/types/entity.types'; @Injectable() export class GroupUserService { constructor( - private groupUserRepository: GroupUserRepository, + private groupRepo: GroupRepo, + private groupUserRepo: GroupUserRepo, private groupService: GroupService, - private dataSource: DataSource, + @InjectKysely() private readonly db: KyselyDB, ) {} async getGroupUsers( - groupId, + groupId: string, workspaceId: string, paginationOptions: PaginationOptions, ): Promise> { await this.groupService.findAndValidateGroup(groupId, workspaceId); - const [groupUsers, count] = await this.groupUserRepository.findAndCount({ - relations: ['user'], - where: { - groupId: groupId, - group: { - workspaceId: workspaceId, - }, - }, - - take: paginationOptions.limit, - skip: paginationOptions.skip, - }); - - const users = groupUsers.map((groupUser: GroupUser) => groupUser.user); + const { users, count } = await this.groupUserRepo.getGroupUsersPaginated( + groupId, + paginationOptions, + ); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); @@ -52,23 +39,18 @@ export class GroupUserService { async addUserToDefaultGroup( userId: string, workspaceId: string, - manager?: EntityManager, + trx?: KyselyTransaction, ): Promise { - return await transactionWrapper( - async (manager) => { - const defaultGroup = await this.groupService.getDefaultGroup( + await executeTx( + this.db, + async (trx) => { + const defaultGroup = await this.groupRepo.getDefaultGroup( workspaceId, - manager, - ); - await this.addUserToGroup( - userId, - defaultGroup.id, - workspaceId, - manager, + trx, ); + await this.addUserToGroup(userId, defaultGroup.id, workspaceId, trx); }, - this.dataSource, - manager, + trx, ); } @@ -76,46 +58,33 @@ export class GroupUserService { userId: string, groupId: string, workspaceId: string, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager) => { - const group = await manager.findOneBy(Group, { - id: groupId, - workspaceId: workspaceId, - }); + trx?: KyselyTransaction, + ): Promise { + await executeTx( + this.db, + async (trx) => { + await this.groupService.findAndValidateGroup(groupId, workspaceId); + const groupUserExists = await this.groupUserRepo.getGroupUserById( + userId, + groupId, + trx, + ); - if (!group) { - throw new NotFoundException('Group not found'); - } - - const userExists = await manager.exists(User, { - where: { id: userId, workspaceId }, - }); - - if (!userExists) { - throw new NotFoundException('User not found'); - } - - const existingGroupUser = await manager.findOneBy(GroupUser, { - userId: userId, - groupId: groupId, - }); - - if (existingGroupUser) { + if (groupUserExists) { throw new BadRequestException( 'User is already a member of this group', ); } - const groupUser = new GroupUser(); - groupUser.userId = userId; - groupUser.groupId = groupId; - - return manager.save(groupUser); + await this.groupUserRepo.insertGroupUser( + { + userId, + groupId, + }, + trx, + ); }, - this.dataSource, - manager, + trx, ); } @@ -135,22 +104,15 @@ export class GroupUserService { ); } - const groupUser = await this.getGroupUser(userId, groupId); + const groupUser = await this.groupUserRepo.getGroupUserById( + userId, + groupId, + ); if (!groupUser) { throw new BadRequestException('Group member not found'); } - await this.groupUserRepository.delete({ - userId, - groupId, - }); - } - - async getGroupUser(userId: string, groupId: string): Promise { - return await this.groupUserRepository.findOneBy({ - userId, - groupId, - }); + await this.groupUserRepo.delete(userId, groupId); } } diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 100876a1..bf19ab06 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -4,87 +4,64 @@ import { NotFoundException, } from '@nestjs/common'; import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto'; -import { GroupRepository } from '../respositories/group.repository'; -import { Group } from '../entities/group.entity'; -import { plainToInstance } from 'class-transformer'; -import { User } from '../../user/entities/user.entity'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { UpdateGroupDto } from '../dto/update-group.dto'; -import { DataSource, EntityManager } from 'typeorm'; -import { transactionWrapper } from '../../../helpers/db.helper'; +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; +import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; @Injectable() export class GroupService { - constructor( - private groupRepository: GroupRepository, - private dataSource: DataSource, - ) {} + constructor(private groupRepo: GroupRepo) {} async createGroup( authUser: User, workspaceId: string, createGroupDto: CreateGroupDto, + trx?: KyselyTransaction, ): Promise { - const group = plainToInstance(Group, createGroupDto); - group.creatorId = authUser.id; - group.workspaceId = workspaceId; - - const groupExists = await this.findGroupByName( + const groupExists = await this.groupRepo.findByName( createGroupDto.name, workspaceId, ); if (groupExists) { throw new BadRequestException('Group name already exists'); } + const insertableGroup: InsertableGroup = { + name: createGroupDto.name, + description: createGroupDto.description, + isDefault: false, + creatorId: authUser.id, + workspaceId: workspaceId, + }; - return await this.groupRepository.save(group); + return await this.groupRepo.insertGroup(insertableGroup, trx); } async createDefaultGroup( workspaceId: string, userId?: string, - manager?: EntityManager, + trx?: KyselyTransaction, ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - const group = new Group(); - group.name = DefaultGroup.EVERYONE; - group.isDefault = true; - group.creatorId = userId ?? null; - group.workspaceId = workspaceId; - return await manager.save(group); - }, - this.dataSource, - manager, - ); - } - - async getDefaultGroup( - workspaceId: string, - manager: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - return await manager.findOneBy(Group, { - isDefault: true, - workspaceId, - }); - }, - this.dataSource, - manager, - ); + const insertableGroup: InsertableGroup = { + name: DefaultGroup.EVERYONE, + isDefault: true, + creatorId: userId ?? null, + workspaceId: workspaceId, + }; + return await this.groupRepo.insertGroup(insertableGroup, trx); } async updateGroup( workspaceId: string, updateGroupDto: UpdateGroupDto, ): Promise { - const group = await this.groupRepository.findOneBy({ - id: updateGroupDto.groupId, - workspaceId: workspaceId, - }); + const group = await this.groupRepo.findById( + updateGroupDto.groupId, + workspaceId, + ); if (!group) { throw new NotFoundException('Group not found'); @@ -94,7 +71,7 @@ export class GroupService { throw new BadRequestException('You cannot update a default group'); } - const groupExists = await this.findGroupByName( + const groupExists = await this.groupRepo.findByName( updateGroupDto.name, workspaceId, ); @@ -110,20 +87,21 @@ export class GroupService { group.description = updateGroupDto.description; } - return await this.groupRepository.save(group); + await this.groupRepo.update( + { + name: updateGroupDto.name, + description: updateGroupDto.description, + }, + group.id, + workspaceId, + ); + + return group; } async getGroupInfo(groupId: string, workspaceId: string): Promise { - const group = await this.groupRepository - .createQueryBuilder('group') - .where('group.id = :groupId', { groupId }) - .andWhere('group.workspaceId = :workspaceId', { workspaceId }) - .loadRelationCountAndMap( - 'group.memberCount', - 'group.groupUsers', - 'groupUsers', - ) - .getOne(); + // todo: add member count + const group = await this.groupRepo.findById(groupId, workspaceId); if (!group) { throw new NotFoundException('Group not found'); @@ -136,17 +114,10 @@ export class GroupService { workspaceId: string, paginationOptions: PaginationOptions, ): Promise> { - const [groups, count] = await this.groupRepository - .createQueryBuilder('group') - .where('group.workspaceId = :workspaceId', { workspaceId }) - .loadRelationCountAndMap( - 'group.memberCount', - 'group.groupUsers', - 'groupUsers', - ) - .take(paginationOptions.limit) - .skip(paginationOptions.skip) - .getManyAndCount(); + const { groups, count } = await this.groupRepo.getGroupsPaginated( + workspaceId, + paginationOptions, + ); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); @@ -158,34 +129,18 @@ export class GroupService { if (group.isDefault) { throw new BadRequestException('You cannot delete a default group'); } - await this.groupRepository.delete(groupId); + await this.groupRepo.delete(groupId, workspaceId); } async findAndValidateGroup( groupId: string, workspaceId: string, ): Promise { - const group = await this.groupRepository.findOne({ - where: { - id: groupId, - workspaceId: workspaceId, - }, - }); + const group = await this.groupRepo.findById(groupId, workspaceId); if (!group) { throw new NotFoundException('Group not found'); } return group; } - - async findGroupByName( - groupName: string, - workspaceId: string, - ): Promise { - return this.groupRepository - .createQueryBuilder('group') - .where('LOWER(group.name) = LOWER(:groupName)', { groupName }) - .andWhere('group.workspaceId = :workspaceId', { workspaceId }) - .getOne(); - } } diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index 64364e9d..e3383fbc 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -3,7 +3,7 @@ import { IsOptional, IsString, IsUUID } from 'class-validator'; export class CreatePageDto { @IsOptional() @IsUUID() - id?: string; + pageId?: string; @IsOptional() @IsString() diff --git a/apps/server/src/core/page/dto/delete-page.dto.ts b/apps/server/src/core/page/dto/delete-page.dto.ts index 0848851d..9b091f8b 100644 --- a/apps/server/src/core/page/dto/delete-page.dto.ts +++ b/apps/server/src/core/page/dto/delete-page.dto.ts @@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator'; export class DeletePageDto { @IsUUID() - id: string; + pageId: string; } diff --git a/apps/server/src/core/page/dto/history-details.dto.ts b/apps/server/src/core/page/dto/history-details.dto.ts index 730f9ebb..da54fcb0 100644 --- a/apps/server/src/core/page/dto/history-details.dto.ts +++ b/apps/server/src/core/page/dto/history-details.dto.ts @@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator'; export class HistoryDetailsDto { @IsUUID() - id: string; + historyId: string; } diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts index fc804596..d47c7642 100644 --- a/apps/server/src/core/page/dto/move-page.dto.ts +++ b/apps/server/src/core/page/dto/move-page.dto.ts @@ -2,7 +2,7 @@ import { IsString, IsOptional, IsUUID } from 'class-validator'; export class MovePageDto { @IsUUID() - id: string; + pageId: string; @IsOptional() @IsString() diff --git a/apps/server/src/core/page/dto/page-details.dto.ts b/apps/server/src/core/page/dto/page-details.dto.ts index 00cde29a..f612c3de 100644 --- a/apps/server/src/core/page/dto/page-details.dto.ts +++ b/apps/server/src/core/page/dto/page-details.dto.ts @@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator'; export class PageDetailsDto { @IsUUID() - id: string; + pageId: string; } diff --git a/apps/server/src/core/page/dto/page-with-ordering.dto.ts b/apps/server/src/core/page/dto/page-with-ordering.dto.ts index 2a5d344e..f88e04d8 100644 --- a/apps/server/src/core/page/dto/page-with-ordering.dto.ts +++ b/apps/server/src/core/page/dto/page-with-ordering.dto.ts @@ -1,5 +1,3 @@ -import { Page } from '../entities/page.entity'; +import { Page } from '@docmost/db/types/entity.types'; -export class PageWithOrderingDto extends Page { - childrenIds?: string[]; -} +export type PageWithOrderingDto = Page & { childrenIds?: string[] }; diff --git a/apps/server/src/core/page/dto/update-page.dto.ts b/apps/server/src/core/page/dto/update-page.dto.ts index e019a546..e0250312 100644 --- a/apps/server/src/core/page/dto/update-page.dto.ts +++ b/apps/server/src/core/page/dto/update-page.dto.ts @@ -4,5 +4,5 @@ import { IsUUID } from 'class-validator'; export class UpdatePageDto extends PartialType(CreatePageDto) { @IsUUID() - id: string; + pageId: string; } diff --git a/apps/server/src/core/page/entities/page-history.entity.ts b/apps/server/src/core/page/entities/page-history.entity.ts deleted file mode 100644 index 728a1aad..00000000 --- a/apps/server/src/core/page/entities/page-history.entity.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Page } from './page.entity'; -import { User } from '../../user/entities/user.entity'; -import { Space } from '../../space/entities/space.entity'; - -@Entity('page_history') -export class PageHistory { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'uuid' }) - pageId: string; - - @ManyToOne(() => Page, (page) => page.pageHistory, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'pageId' }) - page: Page; - - @Column({ length: 500, nullable: true }) - title: string; - - @Column({ type: 'jsonb', nullable: true }) - content: string; - - @Column({ nullable: true }) - slug: string; - - @Column({ nullable: true }) - icon: string; - - @Column({ nullable: true }) - coverPhoto: string; - - @Column({ type: 'int' }) - version: number; - - @Column({ type: 'uuid' }) - lastUpdatedById: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'lastUpdatedById' }) - lastUpdatedBy: User; - - @Column() - spaceId: string; - - @ManyToOne(() => Space, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'spaceId' }) - space: Space; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/page/entities/page-ordering.entity.ts b/apps/server/src/core/page/entities/page-ordering.entity.ts deleted file mode 100644 index c820b39c..00000000 --- a/apps/server/src/core/page/entities/page-ordering.entity.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, - Unique, - CreateDateColumn, - UpdateDateColumn, - DeleteDateColumn, -} from 'typeorm'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Space } from '../../space/entities/space.entity'; - -@Entity('page_ordering') -@Unique(['entityId', 'entityType']) -export class PageOrdering { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column('uuid') - entityId: string; - - @Column({ type: 'varchar', length: 50, nullable: false }) - entityType: string; - - @Column('uuid', { array: true, default: '{}' }) - childrenIds: string[]; - - @Column('uuid') - workspaceId: string; - - @ManyToOne(() => Workspace, (workspace) => workspace.id, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @Column('uuid') - spaceId: string; - - @ManyToOne(() => Space, (space) => space.id, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'spaceId' }) - space: Space; - - @DeleteDateColumn({ nullable: true }) - deletedAt: Date; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/page/entities/page.entity.ts b/apps/server/src/core/page/entities/page.entity.ts deleted file mode 100644 index ea732f43..00000000 --- a/apps/server/src/core/page/entities/page.entity.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - OneToMany, - DeleteDateColumn, - Index, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Comment } from '../../comment/entities/comment.entity'; -import { PageHistory } from './page-history.entity'; -import { Space } from '../../space/entities/space.entity'; - -@Entity('pages') -@Index(['tsv']) -export class Page { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 500, nullable: true }) - title: string; - - @Column({ nullable: true }) - icon: string; - - @Column({ type: 'jsonb', nullable: true }) - content: string; - - @Column({ type: 'text', nullable: true }) - html: string; - - @Column({ type: 'text', nullable: true }) - textContent: string; - - @Column({ - type: 'tsvector', - select: false, - nullable: true, - }) - tsv: string; - - @Column({ type: 'bytea', nullable: true }) - ydoc: any; - - @Column({ nullable: true }) - slug: string; - - @Column({ nullable: true }) - coverPhoto: string; - - @Column({ length: 255, nullable: true }) - editor: string; - - @Column({ length: 255, nullable: true }) - shareId: string; - - @Column({ type: 'uuid', nullable: true }) - parentPageId: string; - - @Column() - creatorId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @Column({ type: 'uuid', nullable: true }) - lastUpdatedById: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'lastUpdatedById' }) - lastUpdatedBy: User; - - @Column({ type: 'uuid', nullable: true }) - deletedById: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'deletedById' }) - deletedBy: User; - - @Column() - spaceId: string; - - @ManyToOne(() => Space, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'spaceId' }) - space: Space; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @Column({ type: 'boolean', default: false }) - isLocked: boolean; - - @Column({ length: 255, nullable: true }) - status: string; - - @Column({ type: 'date', nullable: true }) - publishedAt: Date; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - @DeleteDateColumn({ nullable: true }) - deletedAt: Date; - - @ManyToOne(() => Page, (page) => page.childPages) - @JoinColumn({ name: 'parentPageId' }) - parentPage: Page; - - @OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' }) - childPages: Page[]; - - @OneToMany(() => PageHistory, (pageHistory) => pageHistory.page) - pageHistory: PageHistory[]; - - @OneToMany(() => Comment, (comment) => comment.page) - comments: Comment[]; -} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 33e77731..1177a770 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -17,10 +17,10 @@ import { PageHistoryService } from './services/page-history.service'; import { HistoryDetailsDto } from './dto/history-details.dto'; import { PageHistoryDto } from './dto/page-history.dto'; import { AuthUser } from '../../decorators/auth-user.decorator'; -import { User } from '../user/entities/user.entity'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -34,7 +34,7 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('/info') async getPage(@Body() input: PageDetailsDto) { - return this.pageService.findOne(input.id); + return this.pageService.findById(input.pageId); } @HttpCode(HttpStatus.CREATED) @@ -50,19 +50,23 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('update') async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) { - return this.pageService.update(updatePageDto.id, updatePageDto, user.id); + return this.pageService.update( + updatePageDto.pageId, + updatePageDto, + user.id, + ); } @HttpCode(HttpStatus.OK) @Post('delete') async delete(@Body() deletePageDto: DeletePageDto) { - await this.pageService.delete(deletePageDto.id); + await this.pageService.forceDelete(deletePageDto.pageId); } @HttpCode(HttpStatus.OK) @Post('restore') async restore(@Body() deletePageDto: DeletePageDto) { - await this.pageService.restore(deletePageDto.id); + // await this.pageService.restore(deletePageDto.id); } @HttpCode(HttpStatus.OK) @@ -73,9 +77,11 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('recent') - async getRecentSpacePages(@Body() { spaceId }) { - console.log(spaceId); - return this.pageService.getRecentSpacePages(spaceId); + async getRecentSpacePages( + @Body() { spaceId }, + @Body() pagination: PaginationOptions, + ) { + return this.pageService.getRecentSpacePages(spaceId, pagination); } @HttpCode(HttpStatus.OK) @@ -96,15 +102,19 @@ export class PageController { return this.pageOrderService.convertToTree(spaceId); } + // TODO: scope to workspaces @HttpCode(HttpStatus.OK) @Post('/history') - async getPageHistory(@Body() dto: PageHistoryDto) { - return this.pageHistoryService.findHistoryByPageId(dto.pageId); + async getPageHistory( + @Body() dto: PageHistoryDto, + @Body() pagination: PaginationOptions, + ) { + return this.pageHistoryService.findHistoryByPageId(dto.pageId, pagination); } @HttpCode(HttpStatus.OK) @Post('/history/details') async get(@Body() dto: HistoryDetailsDto) { - return this.pageHistoryService.findOne(dto.id); + return this.pageHistoryService.findById(dto.historyId); } } diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 67923070..057b9992 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -1,34 +1,14 @@ import { Module } from '@nestjs/common'; import { PageService } from './services/page.service'; import { PageController } from './page.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Page } from './entities/page.entity'; -import { PageRepository } from './repositories/page.repository'; import { WorkspaceModule } from '../workspace/workspace.module'; import { PageOrderingService } from './services/page-ordering.service'; -import { PageOrdering } from './entities/page-ordering.entity'; import { PageHistoryService } from './services/page-history.service'; -import { PageHistory } from './entities/page-history.entity'; -import { PageHistoryRepository } from './repositories/page-history.repository'; @Module({ - imports: [ - TypeOrmModule.forFeature([Page, PageOrdering, PageHistory]), - WorkspaceModule, - ], + imports: [WorkspaceModule], controllers: [PageController], - providers: [ - PageService, - PageOrderingService, - PageHistoryService, - PageRepository, - PageHistoryRepository, - ], - exports: [ - PageService, - PageOrderingService, - PageHistoryService, - PageRepository, - ], + providers: [PageService, PageOrderingService, PageHistoryService], + exports: [PageService, PageOrderingService, PageHistoryService], }) export class PageModule {} diff --git a/apps/server/src/core/page/page.util.ts b/apps/server/src/core/page/page.util.ts index 1b925a25..f09d777f 100644 --- a/apps/server/src/core/page/page.util.ts +++ b/apps/server/src/core/page/page.util.ts @@ -1,10 +1,11 @@ +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { MovePageDto } from './dto/move-page.dto'; -import { EntityManager } from 'typeorm'; +import { PageOrdering } from '@docmost/db/types/entity.types'; export enum OrderingEntity { - workspace = 'SPACE', - space = 'SPACE', - page = 'PAGE', + WORKSPACE = 'WORKSPACE', + SPACE = 'SPACE', + PAGE = 'PAGE', } export type TreeNode = { @@ -15,7 +16,7 @@ export type TreeNode = { }; export function orderPageList(arr: string[], payload: MovePageDto): void { - const { id, after, before } = payload; + const { pageId: id, after, before } = payload; // Removing the item we are moving from the array first. const index = arr.indexOf(id); @@ -46,23 +47,27 @@ export function orderPageList(arr: string[], payload: MovePageDto): void { } /** - * Remove an item from an array and save the entity - * @param entity - The entity instance (Page or Workspace) + * Remove an item from an array and update the entity + * @param entity - The entity instance (Page or Space) * @param arrayField - The name of the field which is an array * @param itemToRemove - The item to remove from the array * @param manager - EntityManager instance */ -export async function removeFromArrayAndSave( - entity: T, +export async function removeFromArrayAndSave( + entity: PageOrdering, arrayField: string, itemToRemove: any, - manager: EntityManager, + trx: KyselyTransaction, ) { const array = entity[arrayField]; const index = array.indexOf(itemToRemove); if (index > -1) { array.splice(index, 1); - await manager.save(entity); + await trx + .updateTable('page_ordering') + .set(entity) + .where('id', '=', entity.id) + .execute(); } } @@ -70,11 +75,11 @@ export function transformPageResult(result: any[]): any[] { return result.map((row) => { const processedRow = {}; for (const key in row) { - const newKey = key.split('_').slice(1).join('_'); - if (newKey === 'childrenIds' && !row[key]) { - processedRow[newKey] = []; + //const newKey = key.split('_').slice(1).join('_'); + if (key === 'childrenIds' && !row[key]) { + processedRow[key] = []; } else { - processedRow[newKey] = row[key]; + processedRow[key] = row[key]; } } return processedRow; diff --git a/apps/server/src/core/page/repositories/page-history.repository.ts b/apps/server/src/core/page/repositories/page-history.repository.ts deleted file mode 100644 index 077984b8..00000000 --- a/apps/server/src/core/page/repositories/page-history.repository.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; -import { Injectable } from '@nestjs/common'; -import { PageHistory } from '../entities/page-history.entity'; - -@Injectable() -export class PageHistoryRepository extends Repository { - constructor(private dataSource: DataSource) { - super(PageHistory, dataSource.createEntityManager()); - } - - async findById(pageId: string) { - return this.findOne({ - where: { - id: pageId, - }, - relations: ['lastUpdatedBy'], - select: { - lastUpdatedBy: { - id: true, - name: true, - avatarUrl: true, - }, - }, - }); - } -} diff --git a/apps/server/src/core/page/repositories/page.repository.ts b/apps/server/src/core/page/repositories/page.repository.ts deleted file mode 100644 index 28c69772..00000000 --- a/apps/server/src/core/page/repositories/page.repository.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; -import { Injectable } from '@nestjs/common'; -import { Page } from '../entities/page.entity'; - -@Injectable() -export class PageRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Page, dataSource.createEntityManager()); - } - - public baseFields = [ - 'page.id', - 'page.title', - 'page.slug', - 'page.icon', - 'page.coverPhoto', - 'page.shareId', - 'page.parentPageId', - 'page.creatorId', - 'page.lastUpdatedById', - 'page.spaceId', - 'page.workspaceId', - 'page.isLocked', - 'page.status', - 'page.publishedAt', - 'page.createdAt', - 'page.updatedAt', - 'page.deletedAt', - ]; - - private async baseFind(pageId: string, selectFields: string[]) { - return this.dataSource - .createQueryBuilder(Page, 'page') - .where('page.id = :id', { id: pageId }) - .select(selectFields) - .getOne(); - } - - async findById(pageId: string) { - return this.baseFind(pageId, this.baseFields); - } - - async findWithYDoc(pageId: string) { - const extendedFields = [...this.baseFields, 'page.ydoc']; - return this.baseFind(pageId, extendedFields); - } - - async findWithContent(pageId: string) { - const extendedFields = [...this.baseFields, 'page.content']; - return this.baseFind(pageId, extendedFields); - } - - async findWithAllFields(pageId: string) { - const extendedFields = [...this.baseFields, 'page.content', 'page.ydoc']; - return this.baseFind(pageId, extendedFields); - } -} diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index bad9157f..b1569ca8 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -1,13 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { PageHistory } from '../entities/page-history.entity'; -import { Page } from '../entities/page.entity'; -import { PageHistoryRepository } from '../repositories/page-history.repository'; +import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; +import { Page, PageHistory } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { PaginatedResult } from 'src/helpers/pagination/paginated-result'; +import { PaginationMetaDto } from 'src/helpers/pagination/pagination-meta-dto'; @Injectable() export class PageHistoryService { - constructor(private pageHistoryRepo: PageHistoryRepository) {} + constructor(private pageHistoryRepo: PageHistoryRepo) {} - async findOne(historyId: string): Promise { + async findById(historyId: string): Promise { const history = await this.pageHistoryRepo.findById(historyId); if (!history) { throw new BadRequestException('History not found'); @@ -16,45 +18,31 @@ export class PageHistoryService { } async saveHistory(page: Page): Promise { - const pageHistory = new PageHistory(); - pageHistory.pageId = page.id; - pageHistory.title = page.title; - pageHistory.content = page.content; - pageHistory.slug = page.slug; - pageHistory.icon = page.icon; - pageHistory.version = 1; // TODO: make incremental - pageHistory.coverPhoto = page.coverPhoto; - pageHistory.lastUpdatedById = page.lastUpdatedById ?? page.creatorId; - pageHistory.workspaceId = page.workspaceId; - - await this.pageHistoryRepo.save(pageHistory); + await this.pageHistoryRepo.insertPageHistory({ + pageId: page.id, + title: page.title, + content: page.content, + slug: page.slug, + icon: page.icon, + version: 1, // TODO: make incremental + coverPhoto: page.coverPhoto, + lastUpdatedById: page.lastUpdatedById ?? page.creatorId, + spaceId: page.spaceId, + workspaceId: page.workspaceId, + }); } - async findHistoryByPageId(pageId: string, limit = 50, offset = 0) { - const history = await this.pageHistoryRepo - .createQueryBuilder('history') - .where('history.pageId = :pageId', { pageId }) - .leftJoinAndSelect('history.lastUpdatedBy', 'user') - .select([ - 'history.id', - 'history.pageId', - 'history.title', - 'history.slug', - 'history.icon', - 'history.coverPhoto', - 'history.version', - 'history.lastUpdatedById', - 'history.workspaceId', - 'history.createdAt', - 'history.updatedAt', - 'user.id', - 'user.name', - 'user.avatarUrl', - ]) - .orderBy('history.updatedAt', 'DESC') - .offset(offset) - .take(limit) - .getMany(); - return history; + async findHistoryByPageId( + pageId: string, + paginationOptions: PaginationOptions, + ) { + const { pageHistory, count } = + await this.pageHistoryRepo.findPageHistoryByPageId( + pageId, + paginationOptions, + ); + + const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); + return new PaginatedResult(pageHistory, paginationMeta); } } diff --git a/apps/server/src/core/page/services/page-ordering.service.ts b/apps/server/src/core/page/services/page-ordering.service.ts index 6ca8704b..99be176e 100644 --- a/apps/server/src/core/page/services/page-ordering.service.ts +++ b/apps/server/src/core/page/services/page-ordering.service.ts @@ -1,11 +1,9 @@ import { - BadRequestException, forwardRef, Inject, Injectable, + NotFoundException, } from '@nestjs/common'; -import { PageRepository } from '../repositories/page.repository'; -import { Page } from '../entities/page.entity'; import { MovePageDto } from '../dto/move-page.dto'; import { OrderingEntity, @@ -13,141 +11,185 @@ import { removeFromArrayAndSave, TreeNode, } from '../page.util'; -import { DataSource, EntityManager } from 'typeorm'; import { PageService } from './page.service'; -import { PageOrdering } from '../entities/page-ordering.entity'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { Page, PageOrdering } from '@docmost/db/types/entity.types'; import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; @Injectable() export class PageOrderingService { constructor( - private pageRepository: PageRepository, - private dataSource: DataSource, @Inject(forwardRef(() => PageService)) private pageService: PageService, + @InjectKysely() private readonly db: KyselyDB, ) {} - async movePage(dto: MovePageDto): Promise { - await this.dataSource.transaction(async (manager: EntityManager) => { - const movedPageId = dto.id; + // TODO: scope to workspace and space - const movedPage = await manager - .createQueryBuilder(Page, 'page') - .where('page.id = :movedPageId', { movedPageId }) - .select(['page.id', 'page.spaceId', 'page.parentPageId']) - .getOne(); + async movePage(dto: MovePageDto, trx?: KyselyTransaction): Promise { + await executeTx( + this.db, + async (trx) => { + const movedPageId = dto.pageId; - if (!movedPage) throw new BadRequestException('Moved page not found'); + const movedPage = await trx + .selectFrom('pages as page') + .select(['page.id', 'page.spaceId', 'page.parentPageId']) + .where('page.id', '=', movedPageId) + .executeTakeFirst(); - if (!dto.parentId) { - if (movedPage.parentPageId) { - await this.removeFromParent(movedPage.parentPageId, dto.id, manager); - } - const spaceOrdering = await this.getEntityOrdering( - movedPage.spaceId, - OrderingEntity.space, - manager, - ); + if (!movedPage) throw new NotFoundException('Moved page not found'); - orderPageList(spaceOrdering.childrenIds, dto); + // if no parentId, it means the page is a root page or now a root page + if (!dto.parentId) { + // if it had a parent before being moved, we detach it from the previous parent + if (movedPage.parentPageId) { + await this.removeFromParent( + movedPage.parentPageId, + dto.pageId, + trx, + ); + } + const spaceOrdering = await this.getEntityOrdering( + movedPage.spaceId, + OrderingEntity.SPACE, + trx, + ); - await manager.save(spaceOrdering); - } else { - const parentPageId = dto.parentId; + orderPageList(spaceOrdering.childrenIds, dto); + // it should save or update right? + // await manager.save(spaceOrdering); //TODO: to update or create new record? pretty confusing + await trx + .updateTable('page_ordering') + .set(spaceOrdering) + .where('id', '=', spaceOrdering.id) + .execute(); + } else { + const parentPageId = dto.parentId; - let parentPageOrdering = await this.getEntityOrdering( - parentPageId, - OrderingEntity.page, - manager, - ); - - if (!parentPageOrdering) { - parentPageOrdering = await this.createPageOrdering( + let parentPageOrdering = await this.getEntityOrdering( parentPageId, - OrderingEntity.page, - movedPage.spaceId, - manager, + OrderingEntity.PAGE, + trx, ); + + if (!parentPageOrdering) { + parentPageOrdering = await this.createPageOrdering( + parentPageId, + OrderingEntity.PAGE, + movedPage.spaceId, + trx, + ); + } + + // Check if the parent was changed + if ( + movedPage.parentPageId && + movedPage.parentPageId !== parentPageId + ) { + //if yes, remove moved page from old parent's children + await this.removeFromParent( + movedPage.parentPageId, + dto.pageId, + trx, + ); + } + + // If movedPage didn't have a parent initially (was at root level), update the root level + if (!movedPage.parentPageId) { + await this.removeFromSpacePageOrder( + movedPage.spaceId, + dto.pageId, + trx, + ); + } + + // Modify the children list of the new parentPage and save + orderPageList(parentPageOrdering.childrenIds, dto); + await trx + .updateTable('page_ordering') + .set(parentPageOrdering) + .where('id', '=', parentPageOrdering.id) + .execute(); } - // Check if the parent was changed - if (movedPage.parentPageId && movedPage.parentPageId !== parentPageId) { - //if yes, remove moved page from old parent's children - await this.removeFromParent(movedPage.parentPageId, dto.id, manager); - } - - // If movedPage didn't have a parent initially (was at root level), update the root level - if (!movedPage.parentPageId) { - await this.removeFromSpacePageOrder( - movedPage.spaceId, - dto.id, - manager, - ); - } - - // Modify the children list of the new parentPage and save - orderPageList(parentPageOrdering.childrenIds, dto); - await manager.save(parentPageOrdering); - } - - movedPage.parentPageId = dto.parentId || null; - await manager.save(movedPage); - }); + // update the parent Id of the moved page + await trx + .updateTable('pages') + .set({ + parentPageId: movedPage.parentPageId || null, + }) + .where('id', '=', movedPage.id) + .execute(); + }, + trx, + ); } - async addPageToOrder(spaceId: string, pageId: string, parentPageId?: string) { - await this.dataSource.transaction(async (manager: EntityManager) => { - if (parentPageId) { - await this.upsertOrdering( - parentPageId, - OrderingEntity.page, - pageId, - spaceId, - manager, - ); - } else { - await this.addToSpacePageOrder(spaceId, pageId, manager); - } - }); + async addPageToOrder( + spaceId: string, + pageId: string, + parentPageId?: string, + trx?: KyselyTransaction, + ) { + await executeTx( + this.db, + async (trx: KyselyTransaction) => { + if (parentPageId) { + await this.upsertOrdering( + parentPageId, + OrderingEntity.PAGE, + pageId, + spaceId, + trx, + ); + } else { + await this.addToSpacePageOrder(spaceId, pageId, trx); + } + }, + trx, + ); } async addToSpacePageOrder( spaceId: string, pageId: string, - manager: EntityManager, + trx: KyselyTransaction, ) { await this.upsertOrdering( spaceId, - OrderingEntity.space, + OrderingEntity.SPACE, pageId, spaceId, - manager, + trx, ); } async removeFromParent( parentId: string, childId: string, - manager: EntityManager, + trx: KyselyTransaction, ): Promise { await this.removeChildFromOrdering( parentId, - OrderingEntity.page, + OrderingEntity.PAGE, childId, - manager, + trx, ); } async removeFromSpacePageOrder( spaceId: string, pageId: string, - manager: EntityManager, + trx: KyselyTransaction, ) { await this.removeChildFromOrdering( spaceId, - OrderingEntity.space, + OrderingEntity.SPACE, pageId, - manager, + trx, ); } @@ -155,27 +197,23 @@ export class PageOrderingService { entityId: string, entityType: string, childId: string, - manager: EntityManager, + trx: KyselyTransaction, ): Promise { - const ordering = await this.getEntityOrdering( - entityId, - entityType, - manager, - ); + const ordering = await this.getEntityOrdering(entityId, entityType, trx); if (ordering && ordering.childrenIds.includes(childId)) { - await removeFromArrayAndSave(ordering, 'childrenIds', childId, manager); + await removeFromArrayAndSave(ordering, 'childrenIds', childId, trx); } } async removePageFromHierarchy( page: Page, - manager: EntityManager, + trx: KyselyTransaction, ): Promise { if (page.parentPageId) { - await this.removeFromParent(page.parentPageId, page.id, manager); + await this.removeFromParent(page.parentPageId, page.id, trx); } else { - await this.removeFromSpacePageOrder(page.spaceId, page.id, manager); + await this.removeFromSpacePageOrder(page.spaceId, page.id, trx); } } @@ -184,65 +222,74 @@ export class PageOrderingService { entityType: string, childId: string, spaceId: string, - manager: EntityManager, + trx: KyselyTransaction, ) { - let ordering = await this.getEntityOrdering(entityId, entityType, manager); + let ordering = await this.getEntityOrdering(entityId, entityType, trx); if (!ordering) { ordering = await this.createPageOrdering( entityId, entityType, spaceId, - manager, + trx, ); } if (!ordering.childrenIds.includes(childId)) { ordering.childrenIds.unshift(childId); - await manager.save(PageOrdering, ordering); + await trx + .updateTable('page_ordering') + .set(ordering) + .where('id', '=', ordering.id) + .execute(); + //await manager.save(PageOrdering, ordering); } } async getEntityOrdering( entityId: string, entityType: string, - manager, + trx: KyselyTransaction, ): Promise { - return manager - .createQueryBuilder(PageOrdering, 'ordering') - .setLock('pessimistic_write') - .where('ordering.entityId = :entityId', { entityId }) - .andWhere('ordering.entityType = :entityType', { - entityType, - }) - .getOne(); + return trx + .selectFrom('page_ordering') + .selectAll() + .where('entityId', '=', entityId) + .where('entityType', '=', entityType) + .forUpdate() + .executeTakeFirst(); } async createPageOrdering( entityId: string, entityType: string, spaceId: string, - manager: EntityManager, + trx: KyselyTransaction, ): Promise { - await manager.query( - `INSERT INTO page_ordering ("entityId", "entityType", "spaceId", "childrenIds") - VALUES ($1, $2, $3, '{}') - ON CONFLICT ("entityId", "entityType") DO NOTHING`, - [entityId, entityType, spaceId], - ); + await trx + .insertInto('page_ordering') + .values({ + entityId, + entityType, + spaceId, + childrenIds: [], + }) + .onConflict((oc) => oc.columns(['entityId', 'entityType']).doNothing()) + .execute(); - return await this.getEntityOrdering(entityId, entityType, manager); + // Todo: maybe use returning above + return await this.getEntityOrdering(entityId, entityType, trx); } - async getSpacePageOrder(spaceId: string): Promise { - return await this.dataSource - .createQueryBuilder(PageOrdering, 'ordering') - .select(['ordering.id', 'ordering.childrenIds', 'ordering.spaceId']) - .where('ordering.entityId = :spaceId', { spaceId }) - .andWhere('ordering.entityType = :entityType', { - entityType: OrderingEntity.space, - }) - .getOne(); + async getSpacePageOrder( + spaceId: string, + ): Promise<{ id: string; childrenIds: string[]; spaceId: string }> { + return await this.db + .selectFrom('page_ordering') + .select(['id', 'childrenIds', 'spaceId']) + .where('entityId', '=', spaceId) + .where('entityType', '=', OrderingEntity.SPACE) + .executeTakeFirst(); } async convertToTree(spaceId: string): Promise { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index e58d2e5d..1838a5d8 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1,59 +1,34 @@ import { - BadRequestException, forwardRef, Inject, Injectable, NotFoundException, } from '@nestjs/common'; -import { PageRepository } from '../repositories/page.repository'; import { CreatePageDto } from '../dto/create-page.dto'; -import { Page } from '../entities/page.entity'; import { UpdatePageDto } from '../dto/update-page.dto'; -import { plainToInstance } from 'class-transformer'; -import { DataSource, EntityManager } from 'typeorm'; import { PageOrderingService } from './page-ordering.service'; import { PageWithOrderingDto } from '../dto/page-with-ordering.dto'; -import { OrderingEntity, transformPageResult } from '../page.util'; +import { transformPageResult } from '../page.util'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { Page } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { PaginationMetaDto } from 'src/helpers/pagination/pagination-meta-dto'; +import { PaginatedResult } from 'src/helpers/pagination/paginated-result'; @Injectable() export class PageService { constructor( - private pageRepository: PageRepository, - private dataSource: DataSource, + private pageRepo: PageRepo, @Inject(forwardRef(() => PageOrderingService)) private pageOrderingService: PageOrderingService, ) {} - async findWithBasic(pageId: string) { - return this.pageRepository.findOne({ - where: { id: pageId }, - select: ['id', 'title'], - }); - } - - async findById(pageId: string) { - return this.pageRepository.findById(pageId); - } - - async findWithContent(pageId: string) { - return this.pageRepository.findWithContent(pageId); - } - - async findWithYdoc(pageId: string) { - return this.pageRepository.findWithYDoc(pageId); - } - - async findWithAllFields(pageId: string) { - return this.pageRepository.findWithAllFields(pageId); - } - - async findOne(pageId: string): Promise { - const page = await this.findById(pageId); - if (!page) { - throw new BadRequestException('Page not found'); - } - - return page; + async findById( + pageId: string, + includeContent?: boolean, + includeYdoc?: boolean, + ): Promise { + return this.pageRepo.findById(pageId, includeContent, includeYdoc); } async create( @@ -61,26 +36,26 @@ export class PageService { workspaceId: string, createPageDto: CreatePageDto, ): Promise { - const page = plainToInstance(Page, createPageDto); - page.creatorId = userId; - page.workspaceId = workspaceId; - page.lastUpdatedById = userId; - + // check if parent page exists if (createPageDto.parentPageId) { // TODO: make sure parent page belongs to same space and user has permissions - const parentPage = await this.pageRepository.findOne({ - where: { id: createPageDto.parentPageId }, - select: ['id'], - }); - - if (!parentPage) throw new BadRequestException('Parent page not found'); + const parentPage = await this.pageRepo.findById( + createPageDto.parentPageId, + ); + if (!parentPage) throw new NotFoundException('Parent page not found'); } - const createdPage = await this.pageRepository.save(page); + //TODO: should be in a transaction + const createdPage = await this.pageRepo.insertPage({ + ...createPageDto, + creatorId: userId, + workspaceId: workspaceId, + lastUpdatedById: userId, + }); await this.pageOrderingService.addPageToOrder( createPageDto.spaceId, - createPageDto.id, + createPageDto.pageId, createPageDto.parentPageId, ); @@ -91,18 +66,16 @@ export class PageService { pageId: string, updatePageDto: UpdatePageDto, userId: string, - ): Promise { - const updateData = { - ...updatePageDto, - lastUpdatedById: userId, - }; + ): Promise { + await this.pageRepo.updatePage( + { + ...updatePageDto, + lastUpdatedById: userId, + }, + pageId, + ); - const result = await this.pageRepository.update(pageId, updateData); - if (result.affected === 0) { - throw new BadRequestException(`Page not found`); - } - - return await this.pageRepository.findById(pageId); + //return await this.pageRepo.findById(pageId); } async updateState( @@ -112,14 +85,19 @@ export class PageService { ydoc: any, userId?: string, // TODO: fix this ): Promise { - await this.pageRepository.update(pageId, { - content: content, - textContent: textContent, - ydoc: ydoc, - ...(userId && { lastUpdatedById: userId }), - }); + await this.pageRepo.updatePage( + { + content: content, + textContent: textContent, + ydoc: ydoc, + ...(userId && { lastUpdatedById: userId }), + }, + pageId, + ); } + /* + // TODO: page deletion and restoration async delete(pageId: string): Promise { await this.dataSource.transaction(async (manager: EntityManager) => { const page = await manager @@ -207,59 +185,30 @@ export class PageService { await manager.recover(Page, { id: child.id }); } } - +*/ async forceDelete(pageId: string): Promise { - await this.pageRepository.delete(pageId); - } - - async lockOrUnlockPage(pageId: string, lock: boolean): Promise { - await this.pageRepository.update(pageId, { isLocked: lock }); - return await this.pageRepository.findById(pageId); + await this.pageRepo.deletePage(pageId); } async getSidebarPagesBySpaceId( spaceId: string, limit = 200, ): Promise { - const pages = await this.pageRepository - .createQueryBuilder('page') - .leftJoin( - 'page_ordering', - 'ordering', - 'ordering.entityId = page.id AND ordering.entityType = :entityType', - { entityType: OrderingEntity.page }, - ) - .where('page.spaceId = :spaceId', { spaceId }) - .select([ - 'page.id', - 'page.title', - 'page.icon', - 'page.parentPageId', - 'page.spaceId', - 'ordering.childrenIds', - 'page.creatorId', - 'page.createdAt', - ]) - .orderBy('page.createdAt', 'DESC') - .take(limit) - .getRawMany(); - + const pages = await this.pageRepo.getSpaceSidebarPages(spaceId, limit); return transformPageResult(pages); } async getRecentSpacePages( spaceId: string, - limit = 20, - offset = 0, - ): Promise { - const pages = await this.pageRepository - .createQueryBuilder('page') - .where('page.spaceId = :spaceId', { spaceId }) - .select(this.pageRepository.baseFields) - .orderBy('page.updatedAt', 'DESC') - .offset(offset) - .take(limit) - .getMany(); - return pages; + paginationOptions: PaginationOptions, + ): Promise> { + const { pages, count } = await this.pageRepo.getRecentPagesInSpace( + spaceId, + paginationOptions, + ); + + const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); + + return new PaginatedResult(pages, paginationMeta); } } diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts index ef65ae4b..bf8db9d1 100644 --- a/apps/server/src/core/search/dto/search-response.dto.ts +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -4,6 +4,8 @@ export class SearchResponseDto { icon: string; parentPageId: string; creatorId: string; - rank: string; + rank: number; highlight: string; + createdAt: Date; + updatedAt: Date; } diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index 60eeefca..d478ab68 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -10,8 +10,8 @@ import { import { SearchService } from './search.service'; import { SearchDTO } from './dto/search.dto'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('search') diff --git a/apps/server/src/core/search/search.module.ts b/apps/server/src/core/search/search.module.ts index c6ef9ab5..238d0e85 100644 --- a/apps/server/src/core/search/search.module.ts +++ b/apps/server/src/core/search/search.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; -import { PageModule } from '../page/page.module'; @Module({ - imports: [PageModule], controllers: [SearchController], providers: [SearchService], }) diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 81aeb4ff..f6f521b9 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { PageRepository } from '../page/repositories/page.repository'; import { SearchDTO } from './dto/search.dto'; import { SearchResponseDto } from './dto/search-response.dto'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { sql } from 'kysely'; // eslint-disable-next-line @typescript-eslint/no-var-requires const tsquery = require('pg-tsquery')(); @Injectable() export class SearchService { - constructor(private pageRepository: PageRepository) {} + constructor(@InjectKysely() private readonly db: KyselyDB) {} async searchPage( query: string, @@ -19,46 +21,32 @@ export class SearchService { } const searchQuery = tsquery(query.trim() + '*'); - const selectColumns = [ - 'page.id as id', - 'page.title as title', - 'page.icon as icon', - 'page.parentPageId as "parentPageId"', - 'page.creatorId as "creatorId"', - 'page.createdAt as "createdAt"', - 'page.updatedAt as "updatedAt"', - ]; - - const searchQueryBuilder = await this.pageRepository - .createQueryBuilder('page') - .select(selectColumns); - - searchQueryBuilder.andWhere('page.workspaceId = :workspaceId', { - workspaceId, - }); - - searchQueryBuilder - .addSelect('ts_rank(page.tsv, to_tsquery(:searchQuery))', 'rank') - .addSelect( - `ts_headline('english', page.textContent, to_tsquery(:searchQuery), 'MinWords=9, MaxWords=10, MaxFragments=10')`, - 'highlight', + const queryResults = await this.db + .selectFrom('pages') + .select([ + 'id', + 'title', + 'icon', + 'parentPageId', + 'creatorId', + 'createdAt', + 'updatedAt', + sql`ts_rank(tsv, to_ts_query(${searchQuery}))`.as('rank'), + sql`ts_headline('english', page.textContent, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as( + 'highlight', + ), + ]) + .where('workspaceId', '=', workspaceId) + .where('tsv', '@@', sql`to_tsquery(${searchQuery})`) + .$if(Boolean(searchParams.creatorId), (qb) => + qb.where('creatorId', '=', searchParams.creatorId), ) - .andWhere('page.tsv @@ to_tsquery(:searchQuery)', { searchQuery }) - .orderBy('rank', 'DESC'); + .orderBy('rank', 'desc') + .limit(searchParams.limit | 20) + .offset(searchParams.offset || 0) + .execute(); - if (searchParams?.creatorId) { - searchQueryBuilder.andWhere('page.creatorId = :creatorId', { - creatorId: searchParams.creatorId, - }); - } - - searchQueryBuilder - .take(searchParams.limit || 20) - .offset(searchParams.offset || 0); - - const results = await searchQueryBuilder.getRawMany(); - - const searchResults = results.map((result) => { + const searchResults = queryResults.map((result) => { if (result.highlight) { result.highlight = result.highlight .replace(/\r\n|\r|\n/g, ' ') diff --git a/apps/server/src/core/space/entities/space-member.entity.ts b/apps/server/src/core/space/entities/space-member.entity.ts deleted file mode 100644 index 55b2344e..00000000 --- a/apps/server/src/core/space/entities/space-member.entity.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Unique, - Check, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Space } from './space.entity'; -import { Group } from '../../group/entities/group.entity'; - -@Entity('space_members') -// allow either userId or groupId -@Check( - 'CHK_allow_userId_or_groupId', - `("userId" IS NOT NULL AND "groupId" IS NULL) OR ("userId" IS NULL AND "groupId" IS NOT NULL)`, -) -@Unique(['spaceId', 'userId']) -@Unique(['spaceId', 'groupId']) -export class SpaceMember { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ nullable: true }) - userId: string; - - @ManyToOne(() => User, (user) => user.spaces, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'userId' }) - user: User; - - @Column({ nullable: true }) - groupId: string; - - @ManyToOne(() => Group, (group) => group.spaces, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'groupId' }) - group: Group; - - @Column() - spaceId: string; - - @ManyToOne(() => Space, (space) => space.spaceMembers, { - onDelete: 'CASCADE', - }) - space: Space; - - @Column({ length: 100 }) - role: string; - - @Column({ nullable: true }) - creatorId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/space/entities/space.entity.ts b/apps/server/src/core/space/entities/space.entity.ts deleted file mode 100644 index 8cedeb72..00000000 --- a/apps/server/src/core/space/entities/space.entity.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - Unique, - UpdateDateColumn, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Page } from '../../page/entities/page.entity'; -import { SpaceVisibility, SpaceRole } from '../../../helpers/types/permission'; -import { SpaceMember } from './space-member.entity'; - -@Entity('spaces') -@Unique(['slug', 'workspaceId']) -export class Space { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 255, nullable: true }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ nullable: true }) - slug: string; - - @Column({ length: 255, nullable: true }) - icon: string; - - @Column({ length: 100, default: SpaceVisibility.OPEN }) - visibility: string; - - @Column({ length: 100, default: SpaceRole.WRITER }) - defaultRole: string; - - @Column({ nullable: true }) - creatorId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, (workspace) => workspace.spaces, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @OneToMany(() => SpaceMember, (spaceMember) => spaceMember.space) - spaceMembers: SpaceMember[]; - - @OneToMany(() => Page, (page) => page.space) - pages: Page[]; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/space/repositories/space-member.repository.ts b/apps/server/src/core/space/repositories/space-member.repository.ts deleted file mode 100644 index 34841a60..00000000 --- a/apps/server/src/core/space/repositories/space-member.repository.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { SpaceMember } from '../entities/space-member.entity'; - -@Injectable() -export class SpaceMemberRepository extends Repository { - constructor(private dataSource: DataSource) { - super(SpaceMember, dataSource.createEntityManager()); - } -} diff --git a/apps/server/src/core/space/repositories/space.repository.ts b/apps/server/src/core/space/repositories/space.repository.ts deleted file mode 100644 index c805f461..00000000 --- a/apps/server/src/core/space/repositories/space.repository.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { Space } from '../entities/space.entity'; - -@Injectable() -export class SpaceRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Space, dataSource.createEntityManager()); - } - - async findById(spaceId: string, workspaceId: string): Promise { - const queryBuilder = this.dataSource.createQueryBuilder(Space, 'space'); - return await queryBuilder - .where('space.id = :id', { id: spaceId }) - .andWhere('space.workspaceId = :workspaceId', { workspaceId }) - .getOne(); - } -} diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 068cc353..109e80ee 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -1,65 +1,62 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { SpaceRepository } from '../repositories/space.repository'; -import { transactionWrapper } from '../../../helpers/db.helper'; -import { DataSource, EntityManager, IsNull, Not } from 'typeorm'; -import { User } from '../../user/entities/user.entity'; +import { Injectable } from '@nestjs/common'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; -import { Group } from '../../group/entities/group.entity'; -import { SpaceMemberRepository } from '../repositories/space-member.repository'; -import { SpaceMember } from '../entities/space-member.entity'; +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { SpaceMember } from '@docmost/db/types/entity.types'; @Injectable() export class SpaceMemberService { - constructor( - private spaceRepository: SpaceRepository, - private spaceMemberRepository: SpaceMemberRepository, - private dataSource: DataSource, - ) {} + constructor(private spaceMemberRepo: SpaceMemberRepo) {} async addUserToSpace( userId: string, spaceId: string, role: string, - workspaceId, - manager?: EntityManager, + workspaceId: string, + trx?: KyselyTransaction, ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - const userExists = await manager.exists(User, { - where: { id: userId, workspaceId }, - }); - if (!userExists) { - throw new NotFoundException('User not found'); - } - - const existingSpaceUser = await manager.findOneBy(SpaceMember, { - userId: userId, - spaceId: spaceId, - }); - - if (existingSpaceUser) { - throw new BadRequestException('User already added to this space'); - } - - const spaceMember = new SpaceMember(); - spaceMember.userId = userId; - spaceMember.spaceId = spaceId; - spaceMember.role = role; - await manager.save(spaceMember); - - return spaceMember; + //if (existingSpaceUser) { + // throw new BadRequestException('User already added to this space'); + // } + return await this.spaceMemberRepo.insertSpaceMember( + { + userId: userId, + spaceId: spaceId, + role: role, }, - this.dataSource, - manager, + trx, ); } + async addGroupToSpace( + groupId: string, + spaceId: string, + role: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + //const existingSpaceUser = await manager.findOneBy(SpaceMember, { + // userId: userId, + // spaceId: spaceId, + // }); + // validations? + return await this.spaceMemberRepo.insertSpaceMember( + { + groupId: groupId, + spaceId: spaceId, + role: role, + }, + trx, + ); + } + + /* + * get spaces a user is a member of + * either by direct membership or via groups + */ + /* async getUserSpaces( userId: string, workspaceId: string, @@ -79,152 +76,31 @@ export class SpaceMemberService { .skip(paginationOptions.skip) .getManyAndCount(); - /* - const getUserSpacesViaGroup = this.spaceRepository - .createQueryBuilder('space') - .leftJoin('space.spaceGroups', 'spaceGroup') - .leftJoin('spaceGroup.group', 'group') - .leftJoin('group.groupUsers', 'groupUser') - .where('groupUser.userId = :userId', { userId }) - .andWhere('space.workspaceId = :workspaceId', { workspaceId }) - .getManyAndCount(); - - console.log(await getUserSpacesViaGroup); -*/ - const spaces = userSpaces.map((userSpace) => userSpace.space); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); return new PaginatedResult(spaces, paginationMeta); } +*/ + /* + * get members of a space. + * can be a group or user + */ async getSpaceMembers( spaceId: string, workspaceId: string, paginationOptions: PaginationOptions, ) { - const [spaceMembers, count] = await this.spaceMemberRepository.findAndCount( - { - relations: ['user', 'group'], - where: { - space: { - id: spaceId, - workspaceId, - }, - }, - order: { - createdAt: 'ASC', - }, - take: paginationOptions.limit, - skip: paginationOptions.skip, - }, - ); - - const members = await Promise.all( - spaceMembers.map(async (member) => { - let memberInfo = {}; - - if (member.user) { - memberInfo = { - id: member.user.id, - name: member.user.name, - email: member.user.email, - avatarUrl: member.user.avatarUrl, - type: 'user', - }; - } else if (member.group) { - const memberCount = await this.dataSource.getRepository(Group).count({ - where: { - id: member.groupId, - workspaceId, - }, - }); - - memberInfo = { - id: member.group.id, - name: member.group.name, - isDefault: member.group.isDefault, - memberCount: memberCount, - type: 'group', - }; - } - - return { - ...memberInfo, - role: member.role, - }; - }), - ); + //todo: validate the space is inside the workspace + const { members, count } = + await this.spaceMemberRepo.getSpaceMembersPaginated( + spaceId, + paginationOptions, + ); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); return new PaginatedResult(members, paginationMeta); } - - async addGroupToSpace( - groupId: string, - spaceId: string, - role: string, - workspaceId, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - const groupExists = await manager.exists(Group, { - where: { id: groupId, workspaceId }, - }); - if (!groupExists) { - throw new NotFoundException('Group not found'); - } - - const existingSpaceGroup = await manager.findOneBy(SpaceMember, { - groupId: groupId, - spaceId: spaceId, - }); - - if (existingSpaceGroup) { - throw new BadRequestException('Group already added to this space'); - } - - const spaceMember = new SpaceMember(); - spaceMember.groupId = groupId; - spaceMember.spaceId = spaceId; - spaceMember.role = role; - await manager.save(spaceMember); - - return spaceMember; - }, - this.dataSource, - manager, - ); - } - - async getSpaceGroup( - spaceId: string, - workspaceId: string, - paginationOptions: PaginationOptions, - ) { - const [spaceGroups, count] = await this.spaceMemberRepository.findAndCount({ - relations: ['group'], - where: { - groupId: Not(IsNull()), - space: { - id: spaceId, - workspaceId, - }, - }, - take: paginationOptions.limit, - skip: paginationOptions.skip, - }); - - // TODO: add group memberCount - const groups = spaceGroups.map((spaceGroup) => { - return { - ...spaceGroup.group, - spaceRole: spaceGroup.role, - }; - }); - - const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); - return new PaginatedResult(groups, paginationMeta); - } } +// 231 lines diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 151baf5c..4f7d5162 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -1,59 +1,46 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CreateSpaceDto } from '../dto/create-space.dto'; -import { Space } from '../entities/space.entity'; -import { SpaceRepository } from '../repositories/space.repository'; -import { transactionWrapper } from '../../../helpers/db.helper'; -import { DataSource, EntityManager } from 'typeorm'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; -import { SpaceMemberRepository } from '../repositories/space-member.repository'; import slugify from 'slugify'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { getRandomInt } from '../../../helpers/utils'; +import { Space } from '@docmost/db/types/entity.types'; @Injectable() export class SpaceService { - constructor( - private spaceRepository: SpaceRepository, - private spaceMemberRepository: SpaceMemberRepository, - private dataSource: DataSource, - ) {} + constructor(private spaceRepo: SpaceRepo) {} async create( userId: string, workspaceId: string, - createSpaceDto?: CreateSpaceDto, - manager?: EntityManager, + createSpaceDto: CreateSpaceDto, + trx?: KyselyTransaction, ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - const space = new Space(); - space.name = createSpaceDto.name ?? 'untitled space '; - space.description = createSpaceDto.description ?? ''; - space.creatorId = userId; - space.workspaceId = workspaceId; + // until we allow slug in dto + let slug = slugify(createSpaceDto.name.toLowerCase()); + const slugExists = await this.spaceRepo.slugExists(slug, workspaceId); + if (slugExists) { + slug = `${slug}-${getRandomInt()}`; + } - space.slug = slugify(space.name.toLowerCase()); // TODO: check for duplicate - - await manager.save(space); - return space; + return await this.spaceRepo.insertSpace( + { + name: createSpaceDto.name ?? 'untitled space', + description: createSpaceDto.description ?? '', + creatorId: userId, + workspaceId: workspaceId, + slug: slug, }, - this.dataSource, - manager, + trx, ); } async getSpaceInfo(spaceId: string, workspaceId: string): Promise { - const space = await this.spaceRepository - .createQueryBuilder('space') - .where('space.id = :spaceId', { spaceId }) - .andWhere('space.workspaceId = :workspaceId', { workspaceId }) - .loadRelationCountAndMap( - 'space.memberCount', - 'space.spaceMembers', - 'spaceMembers', - ) // TODO: add groups to memberCount - .getOne(); - + // TODO: add memberCount + const space = await this.spaceRepo.findById(spaceId, workspaceId); if (!space) { throw new NotFoundException('Space not found'); } @@ -65,17 +52,10 @@ export class SpaceService { workspaceId: string, paginationOptions: PaginationOptions, ): Promise> { - const [spaces, count] = await this.spaceRepository - .createQueryBuilder('space') - .where('space.workspaceId = :workspaceId', { workspaceId }) - .loadRelationCountAndMap( - 'space.memberCount', - 'space.spaceMembers', - 'spaceMembers', - ) // TODO: add groups to memberCount - .take(paginationOptions.limit) - .skip(paginationOptions.skip) - .getManyAndCount(); + const { spaces, count } = await this.spaceRepo.getSpacesInWorkspace( + workspaceId, + paginationOptions, + ); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts index 60e49b40..ea33a878 100644 --- a/apps/server/src/core/space/space.controller.ts +++ b/apps/server/src/core/space/space.controller.ts @@ -8,13 +8,12 @@ import { } from '@nestjs/common'; import { SpaceService } from './services/space.service'; import { AuthUser } from '../../decorators/auth-user.decorator'; -import { User } from '../user/entities/user.entity'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; -import { Workspace } from '../workspace/entities/workspace.entity'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { SpaceIdDto } from './dto/space-id.dto'; import { PaginationOptions } from '../../helpers/pagination/pagination-options'; import { SpaceMemberService } from './services/space-member.service'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('spaces') @@ -37,6 +36,7 @@ export class SpaceController { } // get all spaces user is a member of + /* @HttpCode(HttpStatus.OK) @Post('user') async getUserSpaces( @@ -50,7 +50,7 @@ export class SpaceController { workspace.id, pagination, ); - } + }*/ @HttpCode(HttpStatus.OK) @Post('info') diff --git a/apps/server/src/core/space/space.module.ts b/apps/server/src/core/space/space.module.ts index 3a502d61..a34fe5b7 100644 --- a/apps/server/src/core/space/space.module.ts +++ b/apps/server/src/core/space/space.module.ts @@ -1,22 +1,11 @@ import { Module } from '@nestjs/common'; import { SpaceService } from './services/space.service'; import { SpaceController } from './space.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Space } from './entities/space.entity'; -import { SpaceRepository } from './repositories/space.repository'; -import { SpaceMember } from './entities/space-member.entity'; -import { SpaceMemberRepository } from './repositories/space-member.repository'; import { SpaceMemberService } from './services/space-member.service'; @Module({ - imports: [TypeOrmModule.forFeature([Space, SpaceMember])], controllers: [SpaceController], - providers: [ - SpaceService, - SpaceMemberService, - SpaceRepository, - SpaceMemberRepository, - ], + providers: [SpaceService, SpaceMemberService], exports: [SpaceService, SpaceMemberService], }) export class SpaceModule {} diff --git a/apps/server/src/core/user/entities/user.entity.ts b/apps/server/src/core/user/entities/user.entity.ts deleted file mode 100644 index 803b8ab9..00000000 --- a/apps/server/src/core/user/entities/user.entity.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - BeforeInsert, - Column, - CreateDateColumn, - Entity, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - Unique, - UpdateDateColumn, -} from 'typeorm'; -import * as bcrypt from 'bcrypt'; -import { Workspace } from '../../workspace/entities/workspace.entity'; -import { Page } from '../../page/entities/page.entity'; -import { Comment } from '../../comment/entities/comment.entity'; -import { Space } from '../../space/entities/space.entity'; -import { SpaceMember } from '../../space/entities/space-member.entity'; - -@Entity('users') -@Unique(['email', 'workspaceId']) -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 255, nullable: true }) - name: string; - - @Column({ length: 255 }) - email: string; - - @Column({ nullable: true }) - emailVerifiedAt: Date; - - @Column() - password: string; - - @Column({ nullable: true }) - avatarUrl: string; - - @Column({ nullable: true, length: 100 }) - role: string; - - @Column({ nullable: true }) - workspaceId: string; - - @ManyToOne(() => Workspace, (workspace) => workspace.users, { - onDelete: 'CASCADE', - }) - workspace: Workspace; - - @Column({ length: 100, nullable: true }) - locale: string; - - @Column({ length: 300, nullable: true }) - timezone: string; - - @Column({ type: 'jsonb', nullable: true }) - settings: any; - - @Column({ nullable: true }) - lastLoginAt: Date; - - @Column({ length: 100, nullable: true }) - lastLoginIp: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - @OneToMany(() => Page, (page) => page.creator) - createdPages: Page[]; - - @OneToMany(() => Comment, (comment) => comment.creator) - comments: Comment[]; - - @OneToMany(() => Space, (space) => space.creator) - createdSpaces: Space[]; - - @OneToMany(() => SpaceMember, (spaceMembership) => spaceMembership.user) - spaces: SpaceMember[]; - - toJSON() { - delete this.password; - return this; - } - - @BeforeInsert() - async hashPassword() { - const saltRounds = 12; - this.password = await bcrypt.hash(this.password, saltRounds); - } -} diff --git a/apps/server/src/core/user/repositories/user.repository.ts b/apps/server/src/core/user/repositories/user.repository.ts deleted file mode 100644 index 41ddbf76..00000000 --- a/apps/server/src/core/user/repositories/user.repository.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DataSource, Repository } from 'typeorm'; -import { User } from '../entities/user.entity'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class UserRepository extends Repository { - constructor(private dataSource: DataSource) { - super(User, dataSource.createEntityManager()); - } - async findByEmail(email: string): Promise { - const queryBuilder = this.dataSource.createQueryBuilder(User, 'user'); - return await queryBuilder.where('user.email = :email', { email }).getOne(); - } - - async findById(userId: string): Promise { - const queryBuilder = this.dataSource.createQueryBuilder(User, 'user'); - return await queryBuilder.where('user.id = :id', { id: userId }).getOne(); - } - - async findOneByEmail(email: string, workspaceId: string): Promise { - const queryBuilder = this.dataSource.createQueryBuilder(User, 'user'); - return await queryBuilder - .where('user.email = :email', { email }) - .andWhere('user.workspaceId = :workspaceId', { workspaceId }) - .getOne(); - } - - async findOneByIdx(userId: string, workspaceId: string): Promise { - const queryBuilder = this.dataSource.createQueryBuilder(User, 'user'); - return await queryBuilder - .where('user.id = :id', { id: userId }) - .andWhere('user.workspaceId = :workspaceId', { workspaceId }) - .getOne(); - } -} diff --git a/apps/server/src/core/user/user.controller.ts b/apps/server/src/core/user/user.controller.ts index 5358d326..7deaef51 100644 --- a/apps/server/src/core/user/user.controller.ts +++ b/apps/server/src/core/user/user.controller.ts @@ -8,20 +8,28 @@ import { UseGuards, } from '@nestjs/common'; import { UserService } from './user.service'; -import { User } from './entities/user.entity'; import { UpdateUserDto } from './dto/update-user.dto'; import { AuthUser } from '../../decorators/auth-user.decorator'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('users') export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private userRepo: UserRepo, + ) {} @HttpCode(HttpStatus.OK) @Post('me') - async getUser(@AuthUser() authUser: User) { - const user: User = await this.userService.findById(authUser.id); + async getUser( + @AuthUser() authUser: User, + @AuthWorkspace() workspace: Workspace, + ) { + const user = await this.userRepo.findById(authUser.id, workspace.id); if (!user) { throw new UnauthorizedException('Invalid user'); @@ -35,7 +43,8 @@ export class UserController { async updateUser( @Body() updateUserDto: UpdateUserDto, @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, ) { - return this.userService.update(user.id, updateUserDto); + return this.userService.update(updateUserDto, user.id, workspace.id); } } diff --git a/apps/server/src/core/user/user.module.ts b/apps/server/src/core/user/user.module.ts index 4dfc3a3a..47d38afe 100644 --- a/apps/server/src/core/user/user.module.ts +++ b/apps/server/src/core/user/user.module.ts @@ -1,14 +1,11 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './entities/user.entity'; -import { UserRepository } from './repositories/user.repository'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; @Module({ - imports: [TypeOrmModule.forFeature([User])], controllers: [UserController], - providers: [UserService, UserRepository], - exports: [UserService, UserRepository], + providers: [UserService, UserRepo], + exports: [UserService, UserRepo], }) export class UserModule {} diff --git a/apps/server/src/core/user/user.service.spec.ts b/apps/server/src/core/user/user.service.spec.ts deleted file mode 100644 index d5d33bc0..00000000 --- a/apps/server/src/core/user/user.service.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserService } from './user.service'; -import { UserRepository } from './repositories/user.repository'; -import { User } from './entities/user.entity'; -import { BadRequestException } from '@nestjs/common'; -import { CreateUserDto } from '../auth/dto/create-user.dto'; - -describe('UserService', () => { - let userService: UserService; - let userRepository: any; - - const mockUserRepository = () => ({ - findByEmail: jest.fn(), - save: jest.fn(), - }); - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UserService, - { - provide: UserRepository, - useFactory: mockUserRepository, - }, - ], - }).compile(); - - userService = module.get(UserService); - userRepository = module.get(UserRepository); - }); - - it('should be defined', () => { - expect(userService).toBeDefined(); - expect(userRepository).toBeDefined(); - }); - - describe('create', () => { - const createUserDto: CreateUserDto = { - name: 'John Doe', - email: 'test@test.com', - password: 'password', - }; - - it('should throw an error if a user with this email already exists', async () => { - userRepository.findByEmail.mockResolvedValue(new User()); - await expect(userService.create(createUserDto)).rejects.toThrow( - BadRequestException, - ); - }); - - it('should create the user if it does not already exist', async () => { - const savedUser = { - ...createUserDto, - id: expect.any(String), - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - lastLoginAt: expect.any(Date), - locale: 'en', - emailVerifiedAt: null, - avatar_url: null, - timezone: null, - settings: null, - lastLoginIp: null, - }; - - //userRepository.findByEmail.mockResolvedValue(undefined); - userRepository.save.mockResolvedValue(savedUser); - - const result = await userService.create(createUserDto); - expect(result).toMatchObject(savedUser); - - expect(userRepository.save).toHaveBeenCalledWith( - expect.objectContaining(createUserDto), - ); - }); - }); -}); diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index d5e3f479..e521db82 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -4,19 +4,23 @@ import { NotFoundException, } from '@nestjs/common'; import { UpdateUserDto } from './dto/update-user.dto'; -import { User } from './entities/user.entity'; -import { UserRepository } from './repositories/user.repository'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { hashPassword } from '../../helpers/utils'; @Injectable() export class UserService { - constructor(private userRepository: UserRepository) {} + constructor(private userRepo: UserRepo) {} - async findById(userId: string) { - return this.userRepository.findById(userId); + async findById(userId: string, workspaceId: string) { + return this.userRepo.findById(userId, workspaceId); } - async update(userId: string, updateUserDto: UpdateUserDto) { - const user = await this.userRepository.findById(userId); + async update( + updateUserDto: UpdateUserDto, + userId: string, + workspaceId: string, + ) { + const user = await this.userRepo.findById(userId, workspaceId); if (!user) { throw new NotFoundException('User not found'); } @@ -27,7 +31,7 @@ export class UserService { // todo need workspace scoping if (updateUserDto.email && user.email != updateUserDto.email) { - if (await this.userRepository.findByEmail(updateUserDto.email)) { + if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) { throw new BadRequestException('A user with this email already exists'); } user.email = updateUserDto.email; @@ -37,6 +41,11 @@ export class UserService { user.avatarUrl = updateUserDto.avatarUrl; } - return this.userRepository.save(user); + if (updateUserDto.password) { + updateUserDto.password = await hashPassword(updateUserDto.password); + } + + await this.userRepo.updateUser(updateUserDto, userId, workspaceId); + return user; } } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 969c8a02..3ddd290b 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -11,9 +11,7 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { AuthUser } from '../../../decorators/auth-user.decorator'; -import { User } from '../../user/entities/user.entity'; import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator'; -import { Workspace } from '../entities/workspace.entity'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { WorkspaceInvitationService } from '../services/workspace-invitation.service'; import { Public } from '../../../decorators/public.decorator'; @@ -23,12 +21,12 @@ import { RevokeInviteDto, } from '../dto/invitation.dto'; import { Action } from '../../casl/ability.action'; -import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; import { CheckPolicies } from '../../casl/decorators/policies.decorator'; import { AppAbility } from '../../casl/abilities/casl-ability.factory'; import { PoliciesGuard } from '../../casl/guards/policies.guard'; import { WorkspaceUserService } from '../services/workspace-user.service'; import { JwtAuthGuard } from '../../../guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; @UseGuards(JwtAuthGuard) @Controller('workspace') @@ -49,7 +47,9 @@ export class WorkspaceController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Workspace)) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, 'Workspace'), + ) @HttpCode(HttpStatus.OK) @Post('update') async updateWorkspace( @@ -60,7 +60,9 @@ export class WorkspaceController { } @UseGuards(PoliciesGuard) - @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, Workspace)) + @CheckPolicies((ability: AppAbility) => + ability.can(Action.Manage, 'Workspace'), + ) @HttpCode(HttpStatus.OK) @Post('delete') async deleteWorkspace(@Body() deleteWorkspaceDto: DeleteWorkspaceDto) { @@ -69,7 +71,7 @@ export class WorkspaceController { @UseGuards(PoliciesGuard) @CheckPolicies((ability: AppAbility) => - ability.can(Action.Read, 'workspaceUser'), + ability.can(Action.Read, 'WorkspaceUser'), ) @HttpCode(HttpStatus.OK) @Post('members') @@ -96,7 +98,7 @@ export class WorkspaceController { @UseGuards(PoliciesGuard) @CheckPolicies((ability: AppAbility) => - ability.can(Action.Manage, 'workspaceUser'), + ability.can(Action.Manage, 'WorkspaceUser'), ) @HttpCode(HttpStatus.OK) @Post('members/role') @@ -114,7 +116,7 @@ export class WorkspaceController { @UseGuards(PoliciesGuard) @CheckPolicies((ability: AppAbility) => - ability.can(Action.Manage, WorkspaceInvitation), + ability.can(Action.Manage, 'WorkspaceInvitation'), ) @HttpCode(HttpStatus.OK) @Post('invite') @@ -123,11 +125,11 @@ export class WorkspaceController { @AuthUser() authUser: User, @AuthWorkspace() workspace: Workspace, ) { - return this.workspaceInvitationService.createInvitation( + /* return this.workspaceInvitationService.createInvitation( authUser, workspace.id, inviteUserDto, - ); + );*/ } @Public() @@ -143,8 +145,8 @@ export class WorkspaceController { @HttpCode(HttpStatus.OK) @Post('invite/revoke') async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) { - return this.workspaceInvitationService.revokeInvitation( - revokeInviteDto.invitationId, - ); + // return this.workspaceInvitationService.revokeInvitation( + // revokeInviteDto.invitationId, + // ); } } diff --git a/apps/server/src/core/workspace/entities/workspace-invitation.entity.ts b/apps/server/src/core/workspace/entities/workspace-invitation.entity.ts deleted file mode 100644 index 77e64a98..00000000 --- a/apps/server/src/core/workspace/entities/workspace-invitation.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { Workspace } from './workspace.entity'; -import { User } from '../../user/entities/user.entity'; - -@Entity('workspace_invitations') -export class WorkspaceInvitation { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - workspaceId: string; - - @ManyToOne(() => Workspace, { - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'workspaceId' }) - workspace: Workspace; - - @Column() - invitedById: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'invitedById' }) - invitedBy: User; - - @Column({ length: 255 }) - email: string; - - @Column({ length: 100, nullable: true }) - role: string; - - @Column({ length: 100, nullable: true }) - status: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/apps/server/src/core/workspace/entities/workspace.entity.ts b/apps/server/src/core/workspace/entities/workspace.entity.ts deleted file mode 100644 index 192973c5..00000000 --- a/apps/server/src/core/workspace/entities/workspace.entity.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - JoinColumn, - OneToOne, - DeleteDateColumn, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Page } from '../../page/entities/page.entity'; -import { WorkspaceInvitation } from './workspace-invitation.entity'; -import { Comment } from '../../comment/entities/comment.entity'; -import { Space } from '../../space/entities/space.entity'; -import { Group } from '../../group/entities/group.entity'; -import { UserRole } from '../../../helpers/types/permission'; - -@Entity('workspaces') -export class Workspace { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ length: 255, nullable: true }) - name: string; - - @Column({ type: 'text', nullable: true }) - description: string; - - @Column({ length: 255, nullable: true }) - logo: string; - - @Column({ length: 255, nullable: true, unique: true }) - hostname: string; - - @Column({ length: 255, nullable: true }) - customDomain: string; - - @Column({ type: 'boolean', default: true }) - enableInvite: boolean; - - @Column({ length: 255, unique: true, nullable: true }) - inviteCode: string; - - @Column({ type: 'jsonb', nullable: true }) - settings: any; - - @Column({ default: UserRole.MEMBER }) - defaultRole: string; - - @Column({ nullable: true, type: 'uuid' }) - creatorId: string; - - @OneToOne(() => User) - @JoinColumn({ name: 'creatorId' }) - creator: User; - - @Column({ nullable: true }) - defaultSpaceId: string; - - @OneToOne(() => Space, { onDelete: 'SET NULL' }) - @JoinColumn({ name: 'defaultSpaceId' }) - defaultSpace: Space; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - @DeleteDateColumn() - deletedAt: Date; - - @OneToMany(() => User, (user) => user.workspace) - users: []; - - @OneToMany( - () => WorkspaceInvitation, - (workspaceInvitation) => workspaceInvitation.workspace, - ) - workspaceInvitations: WorkspaceInvitation[]; - - @OneToMany(() => Page, (page) => page.workspace) - pages: Page[]; - - @OneToMany(() => Comment, (comment) => comment.workspace) - comments: Comment[]; - - @OneToMany(() => Space, (space) => space.workspace) - spaces: []; - - @OneToMany(() => Group, (group) => group.workspace) - groups: []; -} diff --git a/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts b/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts deleted file mode 100644 index bca570ae..00000000 --- a/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; - -@Injectable() -export class WorkspaceInvitationRepository extends Repository { - constructor(private dataSource: DataSource) { - super(WorkspaceInvitation, dataSource.createEntityManager()); - } -} diff --git a/apps/server/src/core/workspace/repositories/workspace.repository.ts b/apps/server/src/core/workspace/repositories/workspace.repository.ts deleted file mode 100644 index d9538032..00000000 --- a/apps/server/src/core/workspace/repositories/workspace.repository.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { Workspace } from '../entities/workspace.entity'; - -@Injectable() -export class WorkspaceRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Workspace, dataSource.createEntityManager()); - } - - async findById(workspaceId: string): Promise { - // see: https://github.com/typeorm/typeorm/issues/9316 - const queryBuilder = this.dataSource.createQueryBuilder( - Workspace, - 'workspace', - ); - return await queryBuilder - .where('workspace.id = :id', { id: workspaceId }) - .getOne(); - } - - async findFirst(): Promise { - const createdWorkspace = await this.find({ - order: { - createdAt: 'ASC', - }, - take: 1, - }); - return createdWorkspace[0]; - } -} diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index a3b0f034..e6f148bb 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -1,24 +1,17 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { WorkspaceInvitationRepository } from '../repositories/workspace-invitation.repository'; -import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; -import { User } from '../../user/entities/user.entity'; +import { Injectable } from '@nestjs/common'; import { WorkspaceService } from './workspace.service'; import { UserService } from '../../user/user.service'; -import { InviteUserDto } from '../dto/invitation.dto'; import { WorkspaceUserService } from './workspace-user.service'; -import { UserRole } from '../../../helpers/types/permission'; -import { UserRepository } from '../../user/repositories/user.repository'; +// need reworking @Injectable() export class WorkspaceInvitationService { constructor( - private workspaceInvitationRepository: WorkspaceInvitationRepository, private workspaceService: WorkspaceService, private workspaceUserService: WorkspaceUserService, private userService: UserService, - private userRepository: UserRepository, ) {} - + /* async findInvitedUserByEmail( email, workspaceId, @@ -108,4 +101,6 @@ export class WorkspaceInvitationService { await this.workspaceInvitationRepository.delete(invitationId); } + + */ } diff --git a/apps/server/src/core/workspace/services/workspace-user.service.ts b/apps/server/src/core/workspace/services/workspace-user.service.ts index 86ee60fa..3ab9797d 100644 --- a/apps/server/src/core/workspace/services/workspace-user.service.ts +++ b/apps/server/src/core/workspace/services/workspace-user.service.ts @@ -3,97 +3,67 @@ import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dt import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; -import { User } from '../../user/entities/user.entity'; -import { WorkspaceRepository } from '../repositories/workspace.repository'; -import { UserRepository } from '../../user/repositories/user.repository'; import { UserRole } from '../../../helpers/types/permission'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { User } from '@docmost/db/types/entity.types'; @Injectable() export class WorkspaceUserService { constructor( - private workspaceRepository: WorkspaceRepository, - private userRepository: UserRepository, + private workspaceRepo: WorkspaceRepo, + private userRepo: UserRepo, ) {} async getWorkspaceUsers( workspaceId: string, paginationOptions: PaginationOptions, - ): Promise> { - const [workspaceUsers, count] = await this.userRepository.findAndCount({ - where: { - workspaceId, - }, - take: paginationOptions.limit, - skip: paginationOptions.skip, - }); + ): Promise> { + const { users, count } = await this.userRepo.getUsersPaginated( + workspaceId, + paginationOptions, + ); const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); - return new PaginatedResult(workspaceUsers, paginationMeta); + return new PaginatedResult(users, paginationMeta); } async updateWorkspaceUserRole( authUser: User, - workspaceUserRoleDto: UpdateWorkspaceUserRoleDto, + userRoleDto: UpdateWorkspaceUserRoleDto, workspaceId: string, ) { - const workspaceUser = await this.findAndValidateWorkspaceUser( - workspaceUserRoleDto.userId, - workspaceId, - ); - - if (workspaceUser.role === workspaceUserRoleDto.role) { - return workspaceUser; - } - - const workspaceOwnerCount = await this.userRepository.count({ - where: { - role: UserRole.OWNER, - workspaceId, - }, - }); - - if (workspaceUser.role === UserRole.OWNER && workspaceOwnerCount === 1) { - throw new BadRequestException( - 'There must be at least one workspace owner', - ); - } - - workspaceUser.role = workspaceUserRoleDto.role; - - return this.userRepository.save(workspaceUser); - } - - async deactivateUser(): Promise { - return 'todo'; - } - - async findWorkspaceUser(userId: string, workspaceId: string): Promise { - return await this.userRepository.findOneBy({ - id: userId, - workspaceId, - }); - } - - async findWorkspaceUserByEmail( - email: string, - workspaceId: string, - ): Promise { - return await this.userRepository.findOneBy({ - email: email, - workspaceId, - }); - } - - async findAndValidateWorkspaceUser( - userId: string, - workspaceId: string, - ): Promise { - const user = await this.findWorkspaceUser(userId, workspaceId); + const user = await this.userRepo.findById(userRoleDto.userId, workspaceId); if (!user) { throw new BadRequestException('Workspace member not found'); } - return user; + if (user.role === userRoleDto.role) { + return user; + } + + const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId( + UserRole.OWNER, + workspaceId, + ); + + if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) { + throw new BadRequestException( + 'There must be at least one workspace owner', + ); + } + + await this.userRepo.updateUser( + { + role: userRoleDto.role, + }, + user.id, + workspaceId, + ); + } + + async deactivateUser(): Promise { + return 'todo'; } } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 7fcc49e7..98370b54 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -4,93 +4,85 @@ import { NotFoundException, } from '@nestjs/common'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; -import { WorkspaceRepository } from '../repositories/workspace.repository'; -import { Workspace } from '../entities/workspace.entity'; -import { v4 as uuidv4 } from 'uuid'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; -import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; import { SpaceService } from '../../space/services/space.service'; -import { DataSource, EntityManager } from 'typeorm'; -import { transactionWrapper } from '../../../helpers/db.helper'; import { CreateSpaceDto } from '../../space/dto/create-space.dto'; -import { UserRepository } from '../../user/repositories/user.repository'; import { SpaceRole, UserRole } from '../../../helpers/types/permission'; -import { User } from '../../user/entities/user.entity'; -import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { GroupService } from '../../group/services/group.service'; import { GroupUserService } from '../../group/services/group-user.service'; import { SpaceMemberService } from '../../space/services/space-member.service'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { InjectKysely } from 'nestjs-kysely'; +import { User } from '@docmost/db/types/entity.types'; @Injectable() export class WorkspaceService { constructor( - private workspaceRepository: WorkspaceRepository, - private userRepository: UserRepository, + private workspaceRepo: WorkspaceRepo, private spaceService: SpaceService, private spaceMemberService: SpaceMemberService, private groupService: GroupService, private groupUserService: GroupUserService, - private environmentService: EnvironmentService, - - private dataSource: DataSource, + @InjectKysely() private readonly db: KyselyDB, ) {} - async findById(workspaceId: string): Promise { - return this.workspaceRepository.findById(workspaceId); + async findById(workspaceId: string) { + return this.workspaceRepo.findById(workspaceId); } - async getWorkspaceInfo(workspaceId: string): Promise { - const space = await this.workspaceRepository - .createQueryBuilder('workspace') - .where('workspace.id = :workspaceId', { workspaceId }) - .loadRelationCountAndMap( - 'workspace.memberCount', - 'workspace.users', - 'workspaceUsers', - ) - .getOne(); - - if (!space) { + async getWorkspaceInfo(workspaceId: string) { + // todo: add member count + const workspace = this.workspaceRepo.findById(workspaceId); + if (!workspace) { throw new NotFoundException('Workspace not found'); } - return space; + return workspace; } async create( user: User, createWorkspaceDto: CreateWorkspaceDto, - manager?: EntityManager, - ): Promise { - return await transactionWrapper( - async (manager) => { - let workspace = new Workspace(); - - workspace.name = createWorkspaceDto.name; - workspace.hostname = createWorkspaceDto?.hostname; - workspace.description = createWorkspaceDto.description; - workspace.inviteCode = uuidv4(); - workspace.creatorId = user.id; - workspace = await manager.save(workspace); + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + // create workspace + const workspace = await this.workspaceRepo.insertWorkspace( + { + name: createWorkspaceDto.name, + hostname: createWorkspaceDto.hostname, + description: createWorkspaceDto.description, + creatorId: user.id, + }, + trx, + ); // create default group const group = await this.groupService.createDefaultGroup( workspace.id, user.id, - manager, + trx, ); - // attach user to workspace - user.workspaceId = workspace.id; - user.role = UserRole.OWNER; - await manager.save(user); + // add user to workspace + await trx + .updateTable('users') + .set({ + workspaceId: workspace.id, + role: UserRole.OWNER, + }) + .execute(); // add user to default group await this.groupUserService.addUserToGroup( user.id, group.id, workspace.id, - manager, + trx, ); // create default space @@ -98,12 +90,11 @@ export class WorkspaceService { name: 'General', }; - // create default space const createdSpace = await this.spaceService.create( user.id, workspace.id, spaceInfo, - manager, + trx, ); // and add user to space as owner @@ -112,7 +103,7 @@ export class WorkspaceService { createdSpace.id, SpaceRole.OWNER, workspace.id, - manager, + trx, ); // add default group to space as writer @@ -121,50 +112,58 @@ export class WorkspaceService { createdSpace.id, SpaceRole.WRITER, workspace.id, - manager, + trx, ); + // update default spaceId workspace.defaultSpaceId = createdSpace.id; - await manager.save(workspace); + await this.workspaceRepo.updateWorkspace( + { + defaultSpaceId: createdSpace.id, + }, + workspace.id, + trx, + ); return workspace; }, - this.dataSource, - manager, + trx, ); } async addUserToWorkspace( - user: User, - workspaceId, + userId: string, + workspaceId: string, assignedRole?: UserRole, - manager?: EntityManager, + trx?: KyselyTransaction, ): Promise { - return await transactionWrapper( - async (manager: EntityManager) => { - const workspace = await manager.findOneBy(Workspace, { - id: workspaceId, - }); + return await executeTx( + this.db, + async (trx) => { + const workspace = await trx + .selectFrom('workspaces') + .select(['id', 'defaultRole']) + .where('workspaces.id', '=', workspaceId) + .executeTakeFirst(); if (!workspace) { - throw new BadRequestException('Workspace does not exist'); + throw new BadRequestException('Workspace not found'); } - user.role = assignedRole ?? workspace.defaultRole; - user.workspaceId = workspace.id; - await manager.save(user); - - // User is now added to the default space via the default group + await trx + .updateTable('users') + .set({ + role: assignedRole ?? workspace.defaultRole, + workspaceId: workspace.id, + }) + .where('id', '=', userId) + .execute(); }, - this.dataSource, - manager, + trx, ); } - async update( - workspaceId: string, - updateWorkspaceDto: UpdateWorkspaceDto, - ): Promise { - const workspace = await this.workspaceRepository.findById(workspaceId); + async update(workspaceId: string, updateWorkspaceDto: UpdateWorkspaceDto) { + const workspace = await this.workspaceRepo.findById(workspaceId); if (!workspace) { throw new NotFoundException('Workspace not found'); } @@ -177,16 +176,15 @@ export class WorkspaceService { workspace.logo = updateWorkspaceDto.logo; } - return this.workspaceRepository.save(workspace); + await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); + return workspace; } - async delete(deleteWorkspaceDto: DeleteWorkspaceDto): Promise { - const workspace = await this.workspaceRepository.findById( - deleteWorkspaceDto.workspaceId, - ); + async delete(workspaceId: string): Promise { + const workspace = await this.workspaceRepo.findById(workspaceId); if (!workspace) { throw new NotFoundException('Workspace not found'); } - // delete + //delete } } diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index 5a24164e..79796207 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -1,32 +1,20 @@ import { Module } from '@nestjs/common'; import { WorkspaceService } from './services/workspace.service'; import { WorkspaceController } from './controllers/workspace.controller'; -import { WorkspaceRepository } from './repositories/workspace.repository'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Workspace } from './entities/workspace.entity'; -import { WorkspaceInvitation } from './entities/workspace-invitation.entity'; import { SpaceModule } from '../space/space.module'; import { WorkspaceInvitationService } from './services/workspace-invitation.service'; -import { WorkspaceInvitationRepository } from './repositories/workspace-invitation.repository'; import { WorkspaceUserService } from './services/workspace-user.service'; import { UserModule } from '../user/user.module'; import { GroupModule } from '../group/group.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Workspace, WorkspaceInvitation]), - SpaceModule, - UserModule, - GroupModule, - ], + imports: [SpaceModule, UserModule, GroupModule], controllers: [WorkspaceController], providers: [ WorkspaceService, WorkspaceUserService, WorkspaceInvitationService, - WorkspaceRepository, - WorkspaceInvitationRepository, ], - exports: [WorkspaceService, WorkspaceRepository], + exports: [WorkspaceService], }) export class WorkspaceModule {} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts deleted file mode 100644 index 65515555..00000000 --- a/apps/server/src/database/database.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AppDataSource } from './typeorm.config'; - -@Module({ - imports: [ - TypeOrmModule.forRoot({ - ...AppDataSource.options, - entities: ['dist/src/**/*.entity.{ts,js}'], - migrations: ['dist/src/**/migrations/*.{ts,js}'], - autoLoadEntities: true, - }), - ], -}) -export class DatabaseModule {} diff --git a/apps/server/src/database/migrations/1711150216801-init.ts b/apps/server/src/database/migrations/1711150216801-init.ts deleted file mode 100644 index e6d7fdf4..00000000 --- a/apps/server/src/database/migrations/1711150216801-init.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class Init1711150216801 implements MigrationInterface { - name = 'Init1711150216801'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE "comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "content" jsonb, "selection" character varying(255), "type" character varying(55), "creatorId" uuid NOT NULL, "pageId" uuid NOT NULL, "parentCommentId" uuid, "resolvedById" uuid, "resolvedAt" TIMESTAMP, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "editedAt" TIMESTAMP, "deletedAt" TIMESTAMP, CONSTRAINT "PK_comments" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "group_users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "groupId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_group_users_groupId_userId" UNIQUE ("groupId", "userId"), CONSTRAINT "PK_group_users" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "groups" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "description" text, "isDefault" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, "creatorId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_groups_name_workspaceId" UNIQUE ("name", "workspaceId"), CONSTRAINT "PK_groups" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "space_members" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid, "groupId" uuid, "spaceId" uuid NOT NULL, "role" character varying(100) NOT NULL, "creatorId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_space_members_spaceId_groupId" UNIQUE ("spaceId", "groupId"), CONSTRAINT "UQ_space_members_spaceId_userId" UNIQUE ("spaceId", "userId"), CONSTRAINT "CHK_allow_userId_or_groupId" CHECK (("userId" IS NOT NULL AND "groupId" IS NULL) OR ("userId" IS NULL AND "groupId" IS NOT NULL)), CONSTRAINT "PK_space_members" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "spaces" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255), "description" text, "slug" character varying, "icon" character varying(255), "visibility" character varying(100) NOT NULL DEFAULT 'open', "defaultRole" character varying(100) NOT NULL DEFAULT 'writer', "creatorId" uuid, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_spaces_slug_workspaceId" UNIQUE ("slug", "workspaceId"), CONSTRAINT "PK_spaces" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "page_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "pageId" uuid NOT NULL, "title" character varying(500), "content" jsonb, "slug" character varying, "icon" character varying, "coverPhoto" character varying, "version" integer NOT NULL, "lastUpdatedById" uuid NOT NULL, "spaceId" uuid NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_page_history" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "pages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(500), "icon" character varying, "content" jsonb, "html" text, "textContent" text, "tsv" tsvector, "ydoc" bytea, "slug" character varying, "coverPhoto" character varying, "editor" character varying(255), "shareId" character varying(255), "parentPageId" uuid, "creatorId" uuid NOT NULL, "lastUpdatedById" uuid, "deletedById" uuid, "spaceId" uuid NOT NULL, "workspaceId" uuid NOT NULL, "isLocked" boolean NOT NULL DEFAULT false, "status" character varying(255), "publishedAt" date, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "PK_pages" PRIMARY KEY ("id"))`, - ); - await queryRunner.query(`CREATE INDEX "IDX_pages_tsv" ON "pages" ("id") `); - await queryRunner.query( - `CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255), "email" character varying(255) NOT NULL, "emailVerifiedAt" TIMESTAMP, "password" character varying NOT NULL, "avatarUrl" character varying, "role" character varying(100), "workspaceId" uuid, "locale" character varying(100), "timezone" character varying(300), "settings" jsonb, "lastLoginAt" TIMESTAMP, "lastLoginIp" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_users_email_workspaceId" UNIQUE ("email", "workspaceId"), CONSTRAINT "PK_users" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "workspaces" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255), "description" text, "logo" character varying(255), "hostname" character varying(255), "customDomain" character varying(255), "enableInvite" boolean NOT NULL DEFAULT true, "inviteCode" character varying(255), "settings" jsonb, "defaultRole" character varying NOT NULL DEFAULT 'member', "creatorId" uuid, "defaultSpaceId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "UQ_workspaces_hostname" UNIQUE ("hostname"), CONSTRAINT "UQ_workspaces_inviteCode" UNIQUE ("inviteCode"), CONSTRAINT "REL_workspaces_creatorId" UNIQUE ("creatorId"), CONSTRAINT "REL_workspaces_defaultSpaceId" UNIQUE ("defaultSpaceId"), CONSTRAINT "PK_workspaces" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "workspace_invitations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "workspaceId" uuid NOT NULL, "invitedById" uuid NOT NULL, "email" character varying(255) NOT NULL, "role" character varying(100), "status" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_workspace_invitations" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "page_ordering" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "entityId" uuid NOT NULL, "entityType" character varying(50) NOT NULL, "childrenIds" uuid array NOT NULL DEFAULT '{}', "workspaceId" uuid NOT NULL, "spaceId" uuid NOT NULL, "deletedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_page_ordering_entityId_entityType" UNIQUE ("entityId", "entityType"), CONSTRAINT "PK_page_ordering" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE TABLE "attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fileName" character varying(255) NOT NULL, "filePath" character varying NOT NULL, "fileSize" bigint NOT NULL, "fileExt" character varying(55) NOT NULL, "mimeType" character varying(255) NOT NULL, "type" character varying(55) NOT NULL, "creatorId" uuid NOT NULL, "pageId" uuid, "workspaceId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "PK_attachments" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `ALTER TABLE "comments" ADD CONSTRAINT "FK_comments_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "comments" ADD CONSTRAINT "FK_comments_pages_pageId" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "comments" ADD CONSTRAINT "FK_comments_comments_parentCommentId" FOREIGN KEY ("parentCommentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "comments" ADD CONSTRAINT "FK_comments_users_resolvedById" FOREIGN KEY ("resolvedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "comments" ADD CONSTRAINT "FK_comments_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "group_users" ADD CONSTRAINT "FK_group_users_users_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "group_users" ADD CONSTRAINT "FK_group_users_groups_groupId" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "groups" ADD CONSTRAINT "FK_groups_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "groups" ADD CONSTRAINT "FK_groups_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" ADD CONSTRAINT "FK_space_members_users_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" ADD CONSTRAINT "FK_space_members_groups_groupId" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" ADD CONSTRAINT "FK_space_members_spaces_spaceId" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" ADD CONSTRAINT "FK_space_members_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "spaces" ADD CONSTRAINT "FK_spaces_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "spaces" ADD CONSTRAINT "FK_spaces_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" ADD CONSTRAINT "FK_page_history_pages_pageId" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" ADD CONSTRAINT "FK_page_history_users_lastUpdatedById" FOREIGN KEY ("lastUpdatedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" ADD CONSTRAINT "FK_page_history_spaces_spaceId" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" ADD CONSTRAINT "FK_page_history_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_users_lastUpdatedById" FOREIGN KEY ("lastUpdatedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_users_deletedById" FOREIGN KEY ("deletedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_spaces_spaceId" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "pages" ADD CONSTRAINT "FK_pages_pages_parentPageId" FOREIGN KEY ("parentPageId") REFERENCES "pages"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "users" ADD CONSTRAINT "FK_users_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "workspaces" ADD CONSTRAINT "FK_workspaces_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "workspaces" ADD CONSTRAINT "FK_workspaces_spaces_defaultSpaceId" FOREIGN KEY ("defaultSpaceId") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "workspace_invitations" ADD CONSTRAINT "FK_workspace_invitations_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "workspace_invitations" ADD CONSTRAINT "FK_workspace_invitations_users_invitedById" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_ordering" ADD CONSTRAINT "FK_page_ordering_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "page_ordering" ADD CONSTRAINT "FK_page_ordering_spaces_spaceId" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "attachments" ADD CONSTRAINT "FK_attachments_users_creatorId" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "attachments" ADD CONSTRAINT "FK_attachments_pages_pageId" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "attachments" ADD CONSTRAINT "FK_attachments_workspaces_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "attachments" DROP CONSTRAINT "FK_attachments_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "attachments" DROP CONSTRAINT "FK_attachments_pages_pageId"`, - ); - await queryRunner.query( - `ALTER TABLE "attachments" DROP CONSTRAINT "FK_attachments_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "page_ordering" DROP CONSTRAINT "FK_page_ordering_spaces_spaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "page_ordering" DROP CONSTRAINT "FK_page_ordering_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "workspace_invitations" DROP CONSTRAINT "FK_workspace_invitations_users_invitedById"`, - ); - await queryRunner.query( - `ALTER TABLE "workspace_invitations" DROP CONSTRAINT "FK_workspace_invitations_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "workspaces" DROP CONSTRAINT "FK_workspaces_spaces_defaultSpaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "workspaces" DROP CONSTRAINT "FK_workspaces_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "users" DROP CONSTRAINT "FK_users_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_pages_parentPageId"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_spaces_spaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_users_deletedById"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_users_lastUpdatedById"`, - ); - await queryRunner.query( - `ALTER TABLE "pages" DROP CONSTRAINT "FK_pages_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" DROP CONSTRAINT "FK_page_history_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" DROP CONSTRAINT "FK_page_history_spaces_spaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" DROP CONSTRAINT "FK_page_history_users_lastUpdatedById"`, - ); - await queryRunner.query( - `ALTER TABLE "page_history" DROP CONSTRAINT "FK_page_history_pages_pageId"`, - ); - await queryRunner.query( - `ALTER TABLE "spaces" DROP CONSTRAINT "FK_spaces_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "spaces" DROP CONSTRAINT "FK_spaces_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" DROP CONSTRAINT "FK_space_members_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" DROP CONSTRAINT "FK_space_members_spaces_spaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" DROP CONSTRAINT "FK_space_members_groups_groupId"`, - ); - await queryRunner.query( - `ALTER TABLE "space_members" DROP CONSTRAINT "FK_space_members_users_userId"`, - ); - await queryRunner.query( - `ALTER TABLE "groups" DROP CONSTRAINT "FK_groups_users_creatorId"`, - ); - await queryRunner.query( - `ALTER TABLE "groups" DROP CONSTRAINT "FK_groups_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "group_users" DROP CONSTRAINT "FK_group_users_groups_groupId"`, - ); - await queryRunner.query( - `ALTER TABLE "group_users" DROP CONSTRAINT "FK_group_users_users_userId"`, - ); - await queryRunner.query( - `ALTER TABLE "comments" DROP CONSTRAINT "FK_comments_workspaces_workspaceId"`, - ); - await queryRunner.query( - `ALTER TABLE "comments" DROP CONSTRAINT "FK_comments_users_resolvedById"`, - ); - await queryRunner.query( - `ALTER TABLE "comments" DROP CONSTRAINT "FK_comments_comments_parentCommentId"`, - ); - await queryRunner.query( - `ALTER TABLE "comments" DROP CONSTRAINT "FK_comments_pages_pageId"`, - ); - await queryRunner.query( - `ALTER TABLE "comments" DROP CONSTRAINT "FK_comments_users_creatorId"`, - ); - await queryRunner.query(`DROP TABLE "attachments"`); - await queryRunner.query(`DROP TABLE "page_ordering"`); - await queryRunner.query(`DROP TABLE "workspace_invitations"`); - await queryRunner.query(`DROP TABLE "workspaces"`); - await queryRunner.query(`DROP TABLE "users"`); - await queryRunner.query(`DROP TABLE "pages"`); - await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_pages_id"`); - await queryRunner.query(`DROP TABLE "page_history"`); - await queryRunner.query(`DROP TABLE "spaces"`); - await queryRunner.query(`DROP TABLE "space_members"`); - await queryRunner.query(`DROP TABLE "groups"`); - await queryRunner.query(`DROP TABLE "group_users"`); - await queryRunner.query(`DROP TABLE "comments"`); - } -} diff --git a/apps/server/src/database/migrations/1711150345785-AddTSVColumnIndex.ts b/apps/server/src/database/migrations/1711150345785-AddTSVColumnIndex.ts deleted file mode 100644 index 73154bcb..00000000 --- a/apps/server/src/database/migrations/1711150345785-AddTSVColumnIndex.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddTSVColumnIndex1711150345785 implements MigrationInterface { - name = 'AddTSVColumnIndex1711150345785'; - - public async up(queryRunner: QueryRunner): Promise { - // TypeORM entity does not support custom index type - // if we don't set the index on the entity, - // TypeORM will always generate the index here in new migrations - // dropping previous index to recreate using GIN - await queryRunner.query(`DROP INDEX IF EXISTS "IDX_pages_tsv";`); - await queryRunner.query( - `CREATE INDEX "IDX_pages_tsv" ON pages USING GIN ("tsv");`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX IF EXISTS "IDX_pages_tsv";`); - } -} diff --git a/apps/server/src/database/migrations/1711152548283-AddTsvectorTrigger.ts b/apps/server/src/database/migrations/1711152548283-AddTsvectorTrigger.ts deleted file mode 100644 index 2ba8133e..00000000 --- a/apps/server/src/database/migrations/1711152548283-AddTsvectorTrigger.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddTsvectorTrigger1711152548283 implements MigrationInterface { - name = 'AddTsvectorTrigger1711152548283'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$ - begin - new.tsv := - setweight(to_tsvector('english', coalesce(new.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(new.\"textContent\", '')), 'B'); - return new; - end; - $$ LANGUAGE plpgsql; - `); - - await queryRunner.query(` - CREATE TRIGGER pages_tsvector_update BEFORE INSERT OR UPDATE - ON pages FOR EACH ROW EXECUTE FUNCTION pages_tsvector_trigger(); - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TRIGGER pages_tsvector_update ON Pages`); - await queryRunner.query(`DROP FUNCTION pages_tsvector_trigger`); - } -} diff --git a/apps/server/src/database/naming-strategy.ts b/apps/server/src/database/naming-strategy.ts deleted file mode 100644 index 1ea0214f..00000000 --- a/apps/server/src/database/naming-strategy.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { DefaultNamingStrategy, Table } from 'typeorm'; - -export class NamingStrategy extends DefaultNamingStrategy { - primaryKeyName(tableOrName: Table | string, columnNames: string[]): string { - const tableName = this.normalizeTableName(tableOrName); - return `PK_${tableName}`; - } - - indexName( - tableOrName: Table | string, - columnNames: string[], - where?: string, - ): string { - const tableName = this.normalizeTableName(tableOrName); - - let name = `${tableName}_${columnNames.join('_')}`; - if (where) name += '_' + where; - - return `IDX_${name}`; - } - - uniqueConstraintName( - tableOrName: Table | string, - columnNames: string[], - ): string { - const tableName = this.normalizeTableName(tableOrName); - - return `UQ_${tableName}_${columnNames.join('_')}`; - } - - foreignKeyName( - tableOrName: Table | string, - columnNames: string[], - _referencedTablePath?: string, - _referencedColumnNames?: string[], - ): string { - const tableName = this.normalizeTableName(tableOrName); - const targetTable = this.normalizeTableName(_referencedTablePath); - - const name = `${tableName}_${targetTable}_${columnNames.join('_')}`; - return `FK_${name}`; - } - - relationConstraintName( - tableOrName: Table | string, - columnNames: string[], - where?: string, - ): string { - const tableName = this.normalizeTableName(tableOrName); - - let name = `${tableName}_${columnNames.join('_')}`; - if (where) name += '_' + where; - - return `REL_${name}`; - } - - normalizeTableName(tableOrName: Table | string): string { - const tableName = this.getTableName(tableOrName); - return tableName.replace('.', '_'); - } -} diff --git a/apps/server/src/database/typeorm.config.ts b/apps/server/src/database/typeorm.config.ts deleted file mode 100644 index 4cf36053..00000000 --- a/apps/server/src/database/typeorm.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { DataSource } from 'typeorm'; -import * as dotenv from 'dotenv'; -import { NamingStrategy } from './naming-strategy'; -import { envPath } from '../helpers/utils'; - -dotenv.config({ path: envPath }); - -export const AppDataSource: DataSource = new DataSource({ - type: 'postgres', - url: process.env.DATABASE_URL, - entities: ['src/**/*.entity.{ts,js}'], - migrations: ['src/**/migrations/*.{ts,js}'], - subscribers: [], - synchronize: false, - namingStrategy: new NamingStrategy(), - logging: process.env.NODE_ENV === 'development', -}); diff --git a/apps/server/src/helpers/db.helper.ts b/apps/server/src/helpers/db.helper.ts deleted file mode 100644 index 850aa358..00000000 --- a/apps/server/src/helpers/db.helper.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { DataSource, EntityManager } from 'typeorm'; - -export async function transactionWrapper( - operation: (...args) => any, - datasource: DataSource, - entityManager: EntityManager, -): Promise { - if (entityManager) { - return await operation(entityManager); - } else { - return await datasource.manager.transaction( - async (manager: EntityManager) => { - return await operation(manager); - }, - ); - } -} diff --git a/apps/server/src/helpers/utils.ts b/apps/server/src/helpers/utils.ts index 180d4967..9cb22406 100644 --- a/apps/server/src/helpers/utils.ts +++ b/apps/server/src/helpers/utils.ts @@ -1,4 +1,7 @@ -import { join } from 'path'; +import * as path from 'path'; +import * as bcrypt from 'bcrypt'; + +export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); export function generateHostname(name: string): string { let hostname = name.replace(/[^a-z0-9]/gi, '').toLowerCase(); @@ -6,4 +9,18 @@ export function generateHostname(name: string): string { return hostname; } -export const envPath = join(__dirname, '..', '..', '..', '.env'); +export async function hashPassword(password: string) { + const saltRounds = 12; + return bcrypt.hash(password, saltRounds); +} + +export async function comparePasswordHash( + plainPassword: string, + passwordHash: string, +): Promise { + return bcrypt.compare(plainPassword, passwordHash); +} + +export function getRandomInt(min = 4, max = 5) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index 3b6cd731..f2553733 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -37,7 +37,9 @@ export class LocalDriver implements StorageDriver { try { return await fs.pathExists(this._fullPath(filePath)); } catch (err) { - throw new Error(`Failed to check file existence: ${(err as Error).message}`); + throw new Error( + `Failed to check file existence: ${(err as Error).message}`, + ); } } diff --git a/apps/server/src/kysely/kysely-db.module.ts b/apps/server/src/kysely/kysely-db.module.ts new file mode 100644 index 00000000..52ce9153 --- /dev/null +++ b/apps/server/src/kysely/kysely-db.module.ts @@ -0,0 +1,70 @@ +import { Global, Module } from '@nestjs/common'; +import { KyselyModule } from 'nestjs-kysely'; +import { EnvironmentService } from '../integrations/environment/environment.service'; +import { LogEvent, PostgresDialect } from 'kysely'; +import { Pool } from 'pg'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { PageRepo } from './repos/page/page.repo'; +import { CommentRepo } from './repos/comment/comment.repo'; +import { PageHistoryRepo } from './repos/page/page-history.repo'; +import { PageOrderingRepo } from './repos/page/page-ordering.repo'; +import { AttachmentRepo } from './repos/attachment/attachment.repo'; + +@Global() +@Module({ + imports: [ + KyselyModule.forRootAsync({ + imports: [], + inject: [EnvironmentService], + useFactory: (environmentService: EnvironmentService) => ({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: environmentService.getDatabaseURL(), + }) as any, + }), + log: (event: LogEvent) => { + if (environmentService.getEnv() !== 'development') return; + if (event.level === 'query') { + console.log(event.query.sql); + if (event.query.parameters.length > 0) { + console.log('parameters: ' + event.query.parameters); + } + console.log('time: ' + event.queryDurationMillis); + } + }, + }), + }), + ], + providers: [ + WorkspaceRepo, + UserRepo, + GroupRepo, + GroupUserRepo, + SpaceRepo, + SpaceMemberRepo, + PageRepo, + PageHistoryRepo, + PageOrderingRepo, + CommentRepo, + AttachmentRepo, + ], + exports: [ + WorkspaceRepo, + UserRepo, + GroupRepo, + GroupUserRepo, + SpaceRepo, + SpaceMemberRepo, + PageRepo, + PageHistoryRepo, + PageOrderingRepo, + CommentRepo, + AttachmentRepo, + ], +}) +export class KyselyDbModule {} diff --git a/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts b/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts index 439d49c2..5033c032 100644 --- a/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts +++ b/apps/server/src/kysely/migrations/20240324T085500-workspaces.ts @@ -12,7 +12,9 @@ export async function up(db: Kysely): Promise { .addColumn('logo', 'varchar', (col) => col) .addColumn('hostname', 'varchar', (col) => col) .addColumn('customDomain', 'varchar', (col) => col) - .addColumn('enableInvite', 'boolean', (col) => col.notNull()) + .addColumn('enableInvite', 'boolean', (col) => + col.defaultTo(true).notNull(), + ) .addColumn('inviteCode', 'varchar', (col) => col) .addColumn('settings', 'jsonb', (col) => col) .addColumn('defaultRole', 'varchar', (col) => @@ -27,13 +29,9 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .addColumn('deletedAt', 'timestamp', (col) => col) - .addUniqueConstraint('UQ_workspaces_hostname', ['hostname']) - .addUniqueConstraint('UQ_workspaces_inviteCode', ['inviteCode']) - .addUniqueConstraint('UQ_workspaces_inviteCode', ['inviteCode']) + .addUniqueConstraint('workspaces_hostname_unique', ['hostname']) + .addUniqueConstraint('workspaces_inviteCode_unique', ['inviteCode']) .execute(); - - // CONSTRAINT "REL_workspaces_creatorId" UNIQUE ("creatorId"), - // CONSTRAINT "REL_workspaces_defaultSpaceId" UNIQUE ("defaultSpaceId"), } export async function down(db: Kysely): Promise { diff --git a/apps/server/src/kysely/migrations/20240324T085600-users.ts b/apps/server/src/kysely/migrations/20240324T085600-users.ts index 457332bf..381de30d 100644 --- a/apps/server/src/kysely/migrations/20240324T085600-users.ts +++ b/apps/server/src/kysely/migrations/20240324T085600-users.ts @@ -12,10 +12,14 @@ export async function up(db: Kysely): Promise { .addColumn('password', 'varchar', (col) => col.notNull()) .addColumn('avatarUrl', 'varchar', (col) => col) .addColumn('role', 'varchar', (col) => col) - .addColumn('workspaceId', 'uuid', (col) => col) + .addColumn('status', 'varchar', (col) => col) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade'), + ) .addColumn('locale', 'varchar', (col) => col) .addColumn('timezone', 'varchar', (col) => col) .addColumn('settings', 'jsonb', (col) => col) + .addColumn('lastActiveAt', 'timestamp', (col) => col) .addColumn('lastLoginAt', 'timestamp', (col) => col) .addColumn('lastLoginIp', 'varchar', (col) => col) .addColumn('createdAt', 'timestamp', (col) => @@ -24,26 +28,17 @@ export async function up(db: Kysely): Promise { .addColumn('updatedAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('UQ_users_email_workspaceId', ['email', 'workspaceId']) - .execute(); - - // foreign key relations - await db.schema - .alterTable('users') - .addForeignKeyConstraint( - 'FK_users_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') + .addUniqueConstraint('users_email_workspaceId_unique', [ + 'email', + 'workspaceId', + ]) .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('users') - .dropConstraint('FK_users_workspaces_workspaceId') + .dropConstraint('users_workspaceId_fkey') .execute(); await db.schema.dropTable('users').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T085700-groups.ts b/apps/server/src/kysely/migrations/20240324T085700-groups.ts index a761c38c..4a6a6b51 100644 --- a/apps/server/src/kysely/migrations/20240324T085700-groups.ts +++ b/apps/server/src/kysely/migrations/20240324T085700-groups.ts @@ -9,50 +9,33 @@ export async function up(db: Kysely): Promise { .addColumn('name', 'varchar', (col) => col.notNull()) .addColumn('description', 'text', (col) => col) .addColumn('isDefault', 'boolean', (col) => col.notNull()) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) - .addColumn('creatorId', 'uuid', (col) => col) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('creatorId', 'uuid', (col) => col.references('users.id')) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updatedAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('UQ_groups_name_workspaceId', ['name', 'workspaceId']) + .addUniqueConstraint('groups_name_workspaceId_unique', [ + 'name', + 'workspaceId', + ]) .execute(); - - // foreign key relations - await db.schema - .alterTable('groups') - .addForeignKeyConstraint( - 'FK_groups_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('groups') - .addForeignKeyConstraint( - 'FK_groups_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('groups') - .dropConstraint('FK_groups_workspaces_workspaceId') + .dropConstraint('groups_workspaceId_fkey') .execute(); await db.schema .alterTable('groups') - .dropConstraint('FK_groups_users_creatorId') + .dropConstraint('groups_creatorId_fkey') .execute(); await db.schema.dropTable('groups').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T085800-group_users.ts b/apps/server/src/kysely/migrations/20240324T085800-group_users.ts index fc0dc802..402a7c4f 100644 --- a/apps/server/src/kysely/migrations/20240324T085800-group_users.ts +++ b/apps/server/src/kysely/migrations/20240324T085800-group_users.ts @@ -6,50 +6,34 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) - .addColumn('userId', 'uuid', (col) => col.notNull()) - .addColumn('groupId', 'uuid', (col) => col.notNull()) + .addColumn('userId', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('groupId', 'uuid', (col) => + col.references('groups.id').onDelete('cascade').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updatedAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('UQ_group_users_groupId_userId', ['groupId', 'userId']) - .execute(); - - // foreign key relations - await db.schema - .alterTable('group_users') - .addForeignKeyConstraint( - 'FK_group_users_users_userId', - ['userId'], - 'users', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('group_users') - .addForeignKeyConstraint( - 'FK_group_users_groups_groupId', - ['groupId'], - 'groups', - ['id'], - ) - .onDelete('cascade') + .addUniqueConstraint('group_users_groupId_userId_unique', [ + 'groupId', + 'userId', + ]) .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('group_users') - .dropConstraint('FK_group_users_users_userId') + .dropConstraint('group_users_userId_fkey') .execute(); await db.schema .alterTable('group_users') - .dropConstraint('FK_group_users_groups_groupId') + .dropConstraint('group_users_groupId_fkey') .execute(); await db.schema.dropTable('group_users').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T085900-spaces.ts b/apps/server/src/kysely/migrations/20240324T085900-spaces.ts index 548695d2..ec0a64b1 100644 --- a/apps/server/src/kysely/migrations/20240324T085900-spaces.ts +++ b/apps/server/src/kysely/migrations/20240324T085900-spaces.ts @@ -17,49 +17,32 @@ export async function up(db: Kysely): Promise { .addColumn('defaultRole', 'varchar', (col) => col.defaultTo(SpaceRole.WRITER).notNull(), ) - .addColumn('creatorId', 'uuid', (col) => col) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('creatorId', 'uuid', (col) => col.references('users.id')) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updatedAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('UQ_spaces_slug_workspaceId', ['slug', 'workspaceId']) - .execute(); - - // foreign key relations - await db.schema - .alterTable('spaces') - .addForeignKeyConstraint( - 'FK_spaces_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('spaces') - .addForeignKeyConstraint( - 'FK_spaces_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') + .addUniqueConstraint('spaces_slug_workspaceId_unique', [ + 'slug', + 'workspaceId', + ]) .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('spaces') - .dropConstraint('FK_spaces_users_creatorId') + .dropConstraint('spaces_creatorId_fkey') .execute(); await db.schema .alterTable('spaces') - .dropConstraint('FK_spaces_workspaces_workspaceId') + .dropConstraint('spaces_workspaceId_fkey') .execute(); await db.schema.dropTable('spaces').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T086000-space_members.ts b/apps/server/src/kysely/migrations/20240324T086000-space_members.ts index ef5dc621..d8bcd8a4 100644 --- a/apps/server/src/kysely/migrations/20240324T086000-space_members.ts +++ b/apps/server/src/kysely/migrations/20240324T086000-space_members.ts @@ -6,95 +6,57 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) - .addColumn('userId', 'uuid', (col) => col) - .addColumn('groupId', 'uuid', (col) => col) - .addColumn('spaceId', 'uuid', (col) => col.notNull()) + .addColumn('userId', 'uuid', (col) => + col.references('users.id').onDelete('cascade'), + ) + .addColumn('groupId', 'uuid', (col) => + col.references('groups.id').onDelete('cascade'), + ) + .addColumn('spaceId', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) .addColumn('role', 'varchar', (col) => col.notNull()) - .addColumn('creatorId', 'uuid', (col) => col) + .addColumn('creatorId', 'uuid', (col) => col.references('users.id')) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('updatedAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) - .addUniqueConstraint('UQ_space_members_spaceId_userId', [ + .addUniqueConstraint('space_members_spaceId_userId_unique', [ 'spaceId', 'userId', ]) - .addUniqueConstraint('UQ_space_members_spaceId_groupId', [ + .addUniqueConstraint('space_members_spaceId_groupId_unique', [ 'spaceId', 'groupId', ]) .addCheckConstraint( - 'CHK_allow_userId_or_groupId', + 'allow_either_userId_or_groupId_check', sql`(("userId" IS NOT NULL AND "groupId" IS NULL) OR ("userId" IS NULL AND "groupId" IS NOT NULL))`, ) .execute(); - - // foreign key relations - await db.schema - .alterTable('space_members') - .addForeignKeyConstraint( - 'FK_space_members_users_userId', - ['userId'], - 'users', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('space_members') - .addForeignKeyConstraint( - 'FK_space_members_groups_groupId', - ['groupId'], - 'groups', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('space_members') - .addForeignKeyConstraint( - 'FK_space_members_spaces_spaceId', - ['spaceId'], - 'spaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('space_members') - .addForeignKeyConstraint( - 'FK_space_members_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('space_members') - .dropConstraint('FK_space_members_users_userId') + .dropConstraint('space_members_userId_fkey') .execute(); await db.schema .alterTable('space_members') - .dropConstraint('FK_space_members_groups_groupId') + .dropConstraint('space_members_groupId_fkey') .execute(); await db.schema .alterTable('space_members') - .dropConstraint('FK_space_members_spaces_spaceId') + .dropConstraint('space_members_spaceId_fkey') .execute(); await db.schema .alterTable('space_members') - .dropConstraint('FK_space_members_users_creatorId') + .dropConstraint('space_members_creatorId_fkey') .execute(); await db.schema.dropTable('space_members').execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts b/apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts index aef2b46c..f935c137 100644 --- a/apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts +++ b/apps/server/src/kysely/migrations/20240324T086100-add-workspace-fk.ts @@ -4,7 +4,7 @@ export async function up(db: Kysely): Promise { await db.schema .alterTable('workspaces') .addForeignKeyConstraint( - 'FK_workspaces_users_creatorId', + 'workspaces_creatorId_fkey', ['creatorId'], 'users', ['id'], @@ -14,7 +14,7 @@ export async function up(db: Kysely): Promise { await db.schema .alterTable('workspaces') .addForeignKeyConstraint( - 'FK_workspaces_spaces_defaultSpaceId', + 'workspaces_defaultSpaceId_fkey', ['defaultSpaceId'], 'spaces', ['id'], @@ -26,11 +26,11 @@ export async function up(db: Kysely): Promise { export async function down(db: Kysely): Promise { await db.schema .alterTable('workspaces') - .dropConstraint('FK_workspaces_users_creatorId') + .dropConstraint('workspaces_creatorId_fkey') .execute(); await db.schema .alterTable('workspaces') - .dropConstraint('FK_workspaces_spaces_defaultSpaceId') + .dropConstraint('workspaces_defaultSpaceId_fkey') .execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts b/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts index dd212904..ebd52555 100644 --- a/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts +++ b/apps/server/src/kysely/migrations/20240324T086200-workspace_invitations.ts @@ -6,8 +6,10 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) - .addColumn('invitedById', 'uuid', (col) => col.notNull()) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('invitedById', 'uuid', (col) => col.references('users.id')) .addColumn('email', 'varchar', (col) => col.notNull()) .addColumn('role', 'varchar', (col) => col.notNull()) .addColumn('status', 'varchar', (col) => col) @@ -18,39 +20,17 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .execute(); - - // foreign key relations - await db.schema - .alterTable('workspace_invitations') - .addForeignKeyConstraint( - 'FK_workspace_invitations_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('workspace_invitations') - .addForeignKeyConstraint( - 'FK_workspace_invitations_users_invitedById', - ['invitedById'], - 'users', - ['id'], - ) - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('workspace_invitations') - .dropConstraint('FK_workspace_invitations_workspaces_workspaceId') + .dropConstraint('workspace_invitations_workspaceId_fkey') .execute(); await db.schema .alterTable('workspace_invitations') - .dropConstraint('FK_workspace_invitations_users_invitedById') + .dropConstraint('workspace_invitations_invitedById_fkey') .execute(); await db.schema.dropTable('workspace_invitations').execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086300-pages.ts b/apps/server/src/kysely/migrations/20240324T086300-pages.ts index f32356b7..a5f51515 100644 --- a/apps/server/src/kysely/migrations/20240324T086300-pages.ts +++ b/apps/server/src/kysely/migrations/20240324T086300-pages.ts @@ -17,12 +17,18 @@ export async function up(db: Kysely): Promise { .addColumn('coverPhoto', 'varchar', (col) => col) .addColumn('editor', 'varchar', (col) => col) .addColumn('shareId', 'varchar', (col) => col) - .addColumn('parentPageId', 'uuid', (col) => col) - .addColumn('creatorId', 'uuid', (col) => col.notNull()) - .addColumn('lastUpdatedById', 'uuid', (col) => col) - .addColumn('deletedById', 'uuid', (col) => col) - .addColumn('spaceId', 'uuid', (col) => col.notNull()) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('parentPageId', 'uuid', (col) => + col.references('pages.id').onDelete('cascade'), + ) + .addColumn('creatorId', 'uuid', (col) => col.references('users.id')) + .addColumn('lastUpdatedById', 'uuid', (col) => col.references('users.id')) + .addColumn('deletedById', 'uuid', (col) => col.references('users.id')) + .addColumn('spaceId', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('isLocked', 'boolean', (col) => col.defaultTo(false).notNull()) .addColumn('status', 'varchar', (col) => col) .addColumn('publishedAt', 'date', (col) => col) @@ -36,7 +42,7 @@ export async function up(db: Kysely): Promise { .execute(); await db.schema - .createIndex('IDX_pages_tsv') + .createIndex('pages_tsv_idx') .on('pages') .using('GIN') .column('tsv') @@ -44,5 +50,35 @@ export async function up(db: Kysely): Promise { } export async function down(db: Kysely): Promise { + await db.schema + .alterTable('pages') + .dropConstraint('pages_creatorId_fkey') + .execute(); + + await db.schema + .alterTable('pages') + .dropConstraint('pages_lastUpdatedById_fkey') + .execute(); + + await db.schema + .alterTable('pages') + .dropConstraint('pages_deletedById_fkey') + .execute(); + + await db.schema + .alterTable('pages') + .dropConstraint('pages_spaceId_fkey') + .execute(); + + await db.schema + .alterTable('pages') + .dropConstraint('pages_workspaceId_fkey') + .execute(); + + await db.schema + .alterTable('pages') + .dropConstraint('pages_parentPageId_fkey') + .execute(); + await db.schema.dropTable('pages').execute(); } diff --git a/apps/server/src/kysely/migrations/20240324T086350-pages-fk.ts b/apps/server/src/kysely/migrations/20240324T086350-pages-fk.ts deleted file mode 100644 index 75a3dfe5..00000000 --- a/apps/server/src/kysely/migrations/20240324T086350-pages-fk.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Kysely } from 'kysely'; - -export async function up(db: Kysely): Promise { - await db.schema - .alterTable('pages') - .addForeignKeyConstraint( - 'FK_pages_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('pages') - .addForeignKeyConstraint( - 'FK_pages_users_lastUpdatedById', - ['lastUpdatedById'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('pages') - .addForeignKeyConstraint( - 'FK_pages_users_deletedById', - ['deletedById'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('pages') - .addForeignKeyConstraint('FK_pages_spaces_spaceId', ['spaceId'], 'spaces', [ - 'id', - ]) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('pages') - .addForeignKeyConstraint( - 'FK_pages_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('pages') - .addForeignKeyConstraint( - 'FK_pages_pages_parentPageId', - ['parentPageId'], - 'pages', - ['id'], - ) - .execute(); -} - -export async function down(db: Kysely): Promise { - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_users_creatorId') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_users_lastUpdatedById') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_users_deletedById') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_spaces_spaceId') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_workspaces_workspaceId') - .execute(); - - await db.schema - .alterTable('pages') - .dropConstraint('FK_pages_pages_parentPageId') - .execute(); -} diff --git a/apps/server/src/kysely/migrations/20240324T086400-page_history.ts b/apps/server/src/kysely/migrations/20240324T086400-page_history.ts index 5016b8fe..ae76c0d8 100644 --- a/apps/server/src/kysely/migrations/20240324T086400-page_history.ts +++ b/apps/server/src/kysely/migrations/20240324T086400-page_history.ts @@ -6,16 +6,22 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`), ) - .addColumn('pageId', 'uuid', (col) => col.notNull()) + .addColumn('pageId', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) .addColumn('title', 'varchar', (col) => col) .addColumn('content', 'jsonb', (col) => col) .addColumn('slug', 'varchar', (col) => col) .addColumn('icon', 'varchar', (col) => col) .addColumn('coverPhoto', 'varchar', (col) => col) .addColumn('version', 'int4', (col) => col.notNull()) - .addColumn('lastUpdatedById', 'uuid', (col) => col.notNull()) - .addColumn('spaceId', 'uuid', (col) => col.notNull()) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('lastUpdatedById', 'uuid', (col) => col.references('users.id')) + .addColumn('spaceId', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) @@ -23,72 +29,27 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .execute(); - - // foreign key relations - await db.schema - .alterTable('page_history') - .addForeignKeyConstraint( - 'FK_page_history_pages_pageId', - ['pageId'], - 'pages', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('page_history') - .addForeignKeyConstraint( - 'FK_page_history_users_lastUpdatedById', - ['lastUpdatedById'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('page_history') - .addForeignKeyConstraint( - 'FK_page_history_spaces_spaceId', - ['spaceId'], - 'spaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('page_history') - .addForeignKeyConstraint( - 'FK_page_history_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .onUpdate('no action') - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('page_history') - .dropConstraint('FK_page_history_pages_pageId') + .dropConstraint('page_history_pageId_fkey') .execute(); await db.schema .alterTable('page_history') - .dropConstraint('FK_page_history_users_lastUpdatedById') + .dropConstraint('page_history_lastUpdatedById_fkey') .execute(); await db.schema .alterTable('page_history') - .dropConstraint('FK_page_history_spaces_spaceId') + .dropConstraint('page_history_spaceId_fkey') .execute(); await db.schema .alterTable('page_history') - .dropConstraint('FK_page_history_workspaces_workspaceId') + .dropConstraint('page_history_workspaceId_fkey') .execute(); await db.schema.dropTable('page_history').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T086500-page_ordering.ts b/apps/server/src/kysely/migrations/20240324T086500-page_ordering.ts index c03cf9f1..333fe784 100644 --- a/apps/server/src/kysely/migrations/20240324T086500-page_ordering.ts +++ b/apps/server/src/kysely/migrations/20240324T086500-page_ordering.ts @@ -9,8 +9,12 @@ export async function up(db: Kysely): Promise { .addColumn('entityId', 'uuid', (col) => col.notNull()) .addColumn('entityType', 'varchar', (col) => col.notNull()) .addColumn('childrenIds', sql`uuid[]`, (col) => col.notNull()) - .addColumn('spaceId', 'uuid', (col) => col.notNull()) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('spaceId', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) @@ -18,45 +22,22 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .addColumn('deletedAt', 'timestamp', (col) => col) - .addUniqueConstraint('UQ_page_ordering_entityId_entityType', [ + .addUniqueConstraint('page_ordering_entityId_entityType_unique', [ 'entityId', 'entityType', ]) .execute(); - - // foreign key relations - await db.schema - .alterTable('page_ordering') - .addForeignKeyConstraint( - 'FK_page_ordering_spaces_spaceId', - ['spaceId'], - 'spaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('page_ordering') - .addForeignKeyConstraint( - 'FK_page_ordering_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('page_ordering') - .dropConstraint('FK_page_ordering_spaces_spaceId') + .dropConstraint('page_ordering_spaceId_fkey') .execute(); await db.schema .alterTable('page_ordering') - .dropConstraint('FK_page_ordering_workspaces_workspaceId') + .dropConstraint('page_ordering_workspaceId_fkey') .execute(); await db.schema.dropTable('page_ordering').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T086600-comments.ts b/apps/server/src/kysely/migrations/20240324T086600-comments.ts index 28257b5a..7f6605ea 100644 --- a/apps/server/src/kysely/migrations/20240324T086600-comments.ts +++ b/apps/server/src/kysely/migrations/20240324T086600-comments.ts @@ -9,112 +9,43 @@ export async function up(db: Kysely): Promise { .addColumn('content', 'jsonb', (col) => col) .addColumn('selection', 'varchar', (col) => col) .addColumn('type', 'varchar', (col) => col) - .addColumn('creatorId', 'uuid', (col) => col.notNull()) - .addColumn('pageId', 'uuid', (col) => col.notNull()) - .addColumn('parentCommentId', 'uuid', (col) => col) - .addColumn('resolvedById', 'uuid', (col) => col) - .addColumn('resolvedAt', 'timestamp', (col) => col) - .addColumn('spaceId', 'uuid', (col) => col.notNull()) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('creatorId', 'uuid', (col) => col.references('users.id')) + .addColumn('pageId', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('parentCommentId', 'uuid', (col) => + col.references('comments.id').onDelete('cascade'), + ) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) .addColumn('editedAt', 'timestamp', (col) => col) .addColumn('deletedAt', 'timestamp', (col) => col) .execute(); - - // foreign key relations - await db.schema - .alterTable('comments') - .addForeignKeyConstraint( - 'FK_comments_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('comments') - .addForeignKeyConstraint('FK_comments_pages_pageId', ['pageId'], 'pages', [ - 'id', - ]) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('comments') - .addForeignKeyConstraint( - 'FK_comments_comments_parentCommentId', - ['parentCommentId'], - 'comments', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('comments') - .addForeignKeyConstraint( - 'FK_comments_users_resolvedById', - ['resolvedById'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('comments') - .addForeignKeyConstraint( - 'FK_comments_spaces_spaceId', - ['spaceId'], - 'spaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('comments') - .addForeignKeyConstraint( - 'FK_comments_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('comments') - .dropConstraint('FK_comments_users_creatorId') + .dropConstraint('comments_creatorId_fkey') .execute(); await db.schema .alterTable('comments') - .dropConstraint('FK_comments_pages_pageId') + .dropConstraint('comments_pageId_fkey') .execute(); await db.schema .alterTable('comments') - .dropConstraint('FK_comments_comments_parentCommentId') + .dropConstraint('comments_parentCommentId_fkey') .execute(); await db.schema .alterTable('comments') - .dropConstraint('FK_comments_users_resolvedById') - .execute(); - - await db.schema - .alterTable('comments') - .dropConstraint('FK_comments_spaces_spaceId') - .execute(); - - await db.schema - .alterTable('comments') - .dropConstraint('FK_comments_workspaces_workspaceId') + .dropConstraint('comments_workspaceId_fkey') .execute(); await db.schema.dropTable('comments').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T086700-attachments.ts b/apps/server/src/kysely/migrations/20240324T086700-attachments.ts index 2ad1d254..bceed21c 100644 --- a/apps/server/src/kysely/migrations/20240324T086700-attachments.ts +++ b/apps/server/src/kysely/migrations/20240324T086700-attachments.ts @@ -12,10 +12,14 @@ export async function up(db: Kysely): Promise { .addColumn('fileExt', 'varchar', (col) => col.notNull()) .addColumn('mimeType', 'varchar', (col) => col) .addColumn('type', 'varchar', (col) => col) - .addColumn('creatorId', 'uuid', (col) => col.notNull()) - .addColumn('pageId', 'uuid', (col) => col) - .addColumn('spaceId', 'uuid', (col) => col) - .addColumn('workspaceId', 'uuid', (col) => col.notNull()) + .addColumn('creatorId', 'uuid', (col) => + col.references('users.id').notNull(), + ) + .addColumn('pageId', 'uuid', (col) => col.references('pages.id')) + .addColumn('spaceId', 'uuid', (col) => col.references('spaces.id')) + .addColumn('workspaceId', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('createdAt', 'timestamp', (col) => col.notNull().defaultTo(sql`now()`), ) @@ -24,70 +28,27 @@ export async function up(db: Kysely): Promise { ) .addColumn('deletedAt', 'timestamp', (col) => col) .execute(); - - // foreign key relations - await db.schema - .alterTable('attachments') - .addForeignKeyConstraint( - 'FK_attachments_users_creatorId', - ['creatorId'], - 'users', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('attachments') - .addForeignKeyConstraint( - 'FK_attachments_pages_pageId', - ['pageId'], - 'pages', - ['id'], - ) - .execute(); - - await db.schema - .alterTable('attachments') - .addForeignKeyConstraint( - 'FK_attachments_spaces_spaceId', - ['spaceId'], - 'spaces', - ['id'], - ) - .onDelete('cascade') - .execute(); - - await db.schema - .alterTable('attachments') - .addForeignKeyConstraint( - 'FK_attachments_workspaces_workspaceId', - ['workspaceId'], - 'workspaces', - ['id'], - ) - .onDelete('cascade') - .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('attachments') - .dropConstraint('FK_attachments_users_creatorId') + .dropConstraint('attachments_creatorId_fkey') .execute(); await db.schema .alterTable('attachments') - .dropConstraint('FK_attachments_pages_pageId') + .dropConstraint('attachments_pageId_fkey') .execute(); await db.schema .alterTable('attachments') - .dropConstraint('FK_attachments_spaces_spaceId') + .dropConstraint('attachments_spaceId_fkey') .execute(); await db.schema .alterTable('attachments') - .dropConstraint('FK_attachments_workspaces_workspaceId') + .dropConstraint('attachments_workspaceId_fkey') .execute(); await db.schema.dropTable('attachments').execute(); diff --git a/apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts b/apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts index 9dcc4dc1..38719d69 100644 --- a/apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts +++ b/apps/server/src/kysely/migrations/20240324T086800-pages-tsvector-trigger.ts @@ -1,7 +1,7 @@ import { type Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { - await sql`CREATE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$ + await sql`CREATE OR REPLACE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$ begin new.tsv := setweight(to_tsvector('english', coalesce(new.title, '')), 'A') || @@ -10,7 +10,7 @@ export async function up(db: Kysely): Promise { end; $$ LANGUAGE plpgsql;`.execute(db); - await sql`CREATE TRIGGER pages_tsvector_update BEFORE INSERT OR UPDATE + await sql`CREATE OR REPLACE TRIGGER pages_tsvector_update BEFORE INSERT OR UPDATE ON pages FOR EACH ROW EXECUTE FUNCTION pages_tsvector_trigger();`.execute( db, ); diff --git a/apps/server/src/kysely/repos/attachment/attachment.repo.ts b/apps/server/src/kysely/repos/attachment/attachment.repo.ts new file mode 100644 index 00000000..d09e3f6a --- /dev/null +++ b/apps/server/src/kysely/repos/attachment/attachment.repo.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { + Attachment, + InsertableAttachment, + UpdatableAttachment, +} from '@docmost/db/types/entity.types'; + +@Injectable() +export class AttachmentRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + attachmentId: string, + workspaceId: string, + ): Promise { + return this.db + .selectFrom('attachments') + .selectAll() + .where('id', '=', attachmentId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async insertAttachment( + insertableAttachment: InsertableAttachment, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('attachments') + .values(insertableAttachment) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async updateAttachment( + updatableAttachment: UpdatableAttachment, + attachmentId: string, + ): Promise { + await this.db + .updateTable('attachments') + .set(updatableAttachment) + .where('id', '=', attachmentId) + .returningAll() + .executeTakeFirst(); + } +} diff --git a/apps/server/src/kysely/repos/comment/comment.repo.ts b/apps/server/src/kysely/repos/comment/comment.repo.ts new file mode 100644 index 00000000..04ea6d6b --- /dev/null +++ b/apps/server/src/kysely/repos/comment/comment.repo.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { executeTx } from '../../utils'; +import { + Comment, + InsertableComment, + UpdatableComment, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; + +@Injectable() +export class CommentRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + // todo, add workspaceId + async findById(commentId: string): Promise { + return await this.db + .selectFrom('comments') + .selectAll() + .where('id', '=', commentId) + .executeTakeFirst(); + } + + async findPageComments(pageId: string, paginationOptions: PaginationOptions) { + return executeTx(this.db, async (trx) => { + const comments = await trx + .selectFrom('comments') + .selectAll() + .where('pageId', '=', pageId) + .orderBy('createdAt', 'asc') + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('comments') + .select((eb) => eb.fn.count('id').as('count')) + .where('pageId', '=', pageId) + .executeTakeFirst(); + + count = count as number; + return { comments, count }; + }); + } + + async updateComment( + updatableComment: UpdatableComment, + commentId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .updateTable('comments') + .set(updatableComment) + .where('id', '=', commentId) + .execute(); + }, + trx, + ); + } + + async insertComment( + insertableComment: InsertableComment, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('comments') + .values(insertableComment) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async deleteComment(commentId: string): Promise { + await this.db.deleteFrom('comments').where('id', '=', commentId).execute(); + } +} diff --git a/apps/server/src/kysely/repos/group/group-user.repo.ts b/apps/server/src/kysely/repos/group/group-user.repo.ts new file mode 100644 index 00000000..234b5760 --- /dev/null +++ b/apps/server/src/kysely/repos/group/group-user.repo.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { + GroupUser, + InsertableGroupUser, + User, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; +import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; + +@Injectable() +export class GroupUserRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async getGroupUserById( + userId: string, + groupId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .selectFrom('group_users') + .selectAll() + .where('userId', '=', userId) + .where('groupId', '=', groupId) + .executeTakeFirst(); + }, + trx, + ); + } + + async insertGroupUser( + insertableGroupUser: InsertableGroupUser, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('group_users') + .values(insertableGroupUser) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async getGroupUsersPaginated( + groupId: string, + paginationOptions: PaginationOptions, + ): Promise<{ users: User[]; count: number }> { + // todo add group member count + return executeTx(this.db, async (trx) => { + const groupUsers = (await trx + .selectFrom('group_users') + .innerJoin('users', 'users.id', 'group_users.userId') + .select(sql`users.*` as any) + .where('groupId', '=', groupId) + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute()) as User[]; + + const users: User[] = groupUsers.map((user: User) => { + delete user.password; + return user; + }); + + let { count } = await trx + .selectFrom('group_users') + .select((eb) => eb.fn.count('id').as('count')) + .where('groupId', '=', groupId) + .executeTakeFirst(); + + count = count as number; + + return { users, count }; + }); + } + + async delete(userId: string, groupId: string): Promise { + await this.db + .deleteFrom('group_users') + .where('userId', '=', userId) + .where('groupId', '=', groupId) + .execute(); + } +} diff --git a/apps/server/src/kysely/repos/group/group.repo.ts b/apps/server/src/kysely/repos/group/group.repo.ts new file mode 100644 index 00000000..30575efb --- /dev/null +++ b/apps/server/src/kysely/repos/group/group.repo.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { + Group, + InsertableGroup, + UpdatableGroup, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; +import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; + +@Injectable() +export class GroupRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById(groupId: string, workspaceId: string): Promise { + return await this.db + .selectFrom('groups') + .selectAll() + .where('id', '=', groupId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async findByName(groupName: string, workspaceId: string): Promise { + return await this.db + .selectFrom('groups') + .selectAll() + .where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async update( + updatableGroup: UpdatableGroup, + groupId: string, + workspaceId: string, + ): Promise { + await this.db + .updateTable('groups') + .set(updatableGroup) + .where('id', '=', groupId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + async insertGroup( + insertableGroup: InsertableGroup, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('groups') + .values(insertableGroup) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async getDefaultGroup( + workspaceId: string, + trx: KyselyTransaction, + ): Promise { + return executeTx( + this.db, + async (trx) => { + return await trx + .selectFrom('groups') + .selectAll() + .where('isDefault', '=', true) + .where('workspaceId', '=', workspaceId) + + .executeTakeFirst(); + }, + trx, + ); + } + + async getGroupsPaginated( + workspaceId: string, + paginationOptions: PaginationOptions, + ) { + // todo add group member count + return executeTx(this.db, async (trx) => { + const groups = await trx + .selectFrom('groups') + .selectAll() + .where('workspaceId', '=', workspaceId) + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('groups') + .select((eb) => eb.fn.count('id').as('count')) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + + count = count as number; + return { groups, count }; + }); + } + + async delete(groupId: string, workspaceId: string): Promise { + await this.db + .deleteFrom('groups') + .where('id', '=', groupId) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/kysely/repos/page/page-history.repo.ts b/apps/server/src/kysely/repos/page/page-history.repo.ts new file mode 100644 index 00000000..a87cb830 --- /dev/null +++ b/apps/server/src/kysely/repos/page/page-history.repo.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { executeTx } from '../../utils'; +import { + InsertablePageHistory, + PageHistory, + UpdatablePageHistory, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; + +@Injectable() +export class PageHistoryRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById(pageHistoryId: string): Promise { + return await this.db + .selectFrom('page_history') + .selectAll() + .where('id', '=', pageHistoryId) + .executeTakeFirst(); + } + + async updatePageHistory( + updatablePageHistory: UpdatablePageHistory, + pageHistoryId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .updateTable('page_history') + .set(updatablePageHistory) + .where('id', '=', pageHistoryId) + .execute(); + }, + trx, + ); + } + + async insertPageHistory( + insertablePageHistory: InsertablePageHistory, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('page_history') + .values(insertablePageHistory) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async findPageHistoryByPageId( + pageId: string, + paginationOptions: PaginationOptions, + ) { + return executeTx(this.db, async (trx) => { + const pageHistory = await trx + .selectFrom('page_history as history') + .innerJoin('users as user', 'user.id', 'history.lastUpdatedById') + .select([ + 'history.id', + 'history.pageId', + 'history.title', + 'history.slug', + 'history.icon', + 'history.coverPhoto', + 'history.version', + 'history.lastUpdatedById', + 'history.workspaceId', + 'history.createdAt', + 'history.updatedAt', + 'user.id', + 'user.name', + 'user.avatarUrl', + ]) + .where('pageId', '=', pageId) + .orderBy('createdAt', 'desc') + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('page_history') + .select((eb) => eb.fn.count('id').as('count')) + .where('pageId', '=', pageId) + .executeTakeFirst(); + + count = count as number; + return { pageHistory, count }; + }); + } +} diff --git a/apps/server/src/kysely/repos/page/page-ordering.repo.ts b/apps/server/src/kysely/repos/page/page-ordering.repo.ts new file mode 100644 index 00000000..cabbc6ef --- /dev/null +++ b/apps/server/src/kysely/repos/page/page-ordering.repo.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { executeTx } from '../../utils'; +import { + InsertablePage, + Page, + UpdatablePage, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; + +@Injectable() +export class PageOrderingRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById(pageId: string): Promise { + return await this.db + .selectFrom('pages') + .selectAll() + .where('id', '=', pageId) + .executeTakeFirst(); + } + + async slug(slug: string): Promise { + return await this.db + .selectFrom('pages') + .selectAll() + .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) + .executeTakeFirst(); + } + + async updatePage( + updatablePage: UpdatablePage, + pageId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .updateTable('pages') + .set(updatablePage) + .where('id', '=', pageId) + .execute(); + }, + trx, + ); + } + + async insertPage( + insertablePage: InsertablePage, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('pages') + .values(insertablePage) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } +} diff --git a/apps/server/src/kysely/repos/page/page.repo.ts b/apps/server/src/kysely/repos/page/page.repo.ts new file mode 100644 index 00000000..fc8d442e --- /dev/null +++ b/apps/server/src/kysely/repos/page/page.repo.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { executeTx } from '../../utils'; +import { + InsertablePage, + Page, + UpdatablePage, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; +import { PaginationOptions } from 'src/helpers/pagination/pagination-options'; +import { OrderingEntity } from 'src/core/page/page.util'; +import { PageWithOrderingDto } from 'src/core/page/dto/page-with-ordering.dto'; + +// TODO: scope to space/workspace +@Injectable() +export class PageRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + private baseFields: Array = [ + 'id', + 'title', + 'slug', + 'icon', + 'coverPhoto', + 'shareId', + 'parentPageId', + 'creatorId', + 'lastUpdatedById', + 'spaceId', + 'workspaceId', + 'isLocked', + 'status', + 'publishedAt', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + + async findById( + pageId: string, + withJsonContent?: boolean, + withYdoc?: boolean, + ): Promise { + return await this.db + .selectFrom('pages') + .select(this.baseFields) + .where('id', '=', pageId) + .$if(withJsonContent, (qb) => qb.select('content')) + .$if(withYdoc, (qb) => qb.select('ydoc')) + .executeTakeFirst(); + } + + async slug(slug: string): Promise { + return await this.db + .selectFrom('pages') + .selectAll() + .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) + .executeTakeFirst(); + } + + async updatePage( + updatablePage: UpdatablePage, + pageId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .updateTable('pages') + .set(updatablePage) + .where('id', '=', pageId) + .execute(); + }, + trx, + ); + } + + async insertPage( + insertablePage: InsertablePage, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('pages') + .values(insertablePage) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async deletePage(pageId: string): Promise { + await this.db.deleteFrom('pages').where('id', '=', pageId).execute(); + } + + async getRecentPagesInSpace( + spaceId: string, + paginationOptions: PaginationOptions, + ) { + return executeTx(this.db, async (trx) => { + const pages = await trx + .selectFrom('pages') + .select(this.baseFields) + .where('spaceId', '=', spaceId) + .orderBy('updatedAt', 'desc') + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('pages') + .select((eb) => eb.fn.count('id').as('count')) + .where('spaceId', '=', spaceId) + .executeTakeFirst(); + + count = count as number; + return { pages, count }; + }); + } + + async getSpaceSidebarPages(spaceId: string, limit: number) { + const pages = await this.db + .selectFrom('pages as page') + .innerJoin('page_ordering as ordering', 'ordering.entityId', 'page.id') + .where('ordering.entityType', '=', OrderingEntity.PAGE) + .where('page.spaceId', '=', spaceId) + .select([ + 'page.id', + 'page.title', + 'page.icon', + 'page.parentPageId', + 'page.spaceId', + 'ordering.childrenIds', + 'page.creatorId', + 'page.createdAt', + ]) + .orderBy('page.createdAt', 'desc') + .orderBy('updatedAt', 'desc') + .limit(limit) + .execute(); + + return pages; + } +} diff --git a/apps/server/src/kysely/repos/space/space-member.repo.ts b/apps/server/src/kysely/repos/space/space-member.repo.ts new file mode 100644 index 00000000..3d258510 --- /dev/null +++ b/apps/server/src/kysely/repos/space/space-member.repo.ts @@ -0,0 +1,303 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { + InsertableSpaceMember, + SpaceMember, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; +import { MemberInfo } from './types'; +import { sql } from 'kysely'; + +@Injectable() +export class SpaceMemberRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async insertSpaceMember( + insertableSpaceMember: InsertableSpaceMember, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('space_members') + .values(insertableSpaceMember) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async getSpaceMembersPaginated( + spaceId: string, + paginationOptions: PaginationOptions, + ) { + return executeTx(this.db, async (trx) => { + const spaceMembers = await trx + .selectFrom('space_members') + .leftJoin('users', 'users.id', 'space_members.userId') + .leftJoin('groups', 'groups.id', 'space_members.groupId') + .select([ + 'groups.id as group_id', + 'groups.name as group_name', + 'groups.isDefault as group_isDefault', + 'groups.id as groups_id', + 'groups.id as groups_id', + 'groups.id as groups_id', + 'users.id as user_id', + 'users.name as user_name', + 'users.avatarUrl as user_avatarUrl', + 'users.email as user_email', + 'space_members.role', + ]) + .where('spaceId', '=', spaceId) + .orderBy('space_members.createdAt', 'asc') + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let memberInfo: MemberInfo; + + const members = spaceMembers.map((member) => { + if (member.user_id) { + memberInfo = { + id: member.user_id, + name: member.user_name, + email: member.user_email, + avatarUrl: member.user_avatarUrl, + type: 'user', + }; + } else if (member.group_id) { + memberInfo = { + id: member.group_id, + name: member.group_name, + isDefault: member.group_isDefault, + type: 'group', + }; + // todo: member count + } + + return { + ...memberInfo, + role: member.role, + }; + }); + + let { count } = await trx + .selectFrom('space_members') + .select((eb) => eb.fn.count('id').as('count')) + .where('spaceId', '=', spaceId) + .executeTakeFirst(); + count = count as number; + + return { members, count }; + }); + } + + /* + * we want to get all the spaces a user belongs either directly or via a group + * we will pass the user id and workspace id as parameters + * if the user is a member of the space via multiple groups + * we will return the one with the highest role permission + * it should return an array + * Todo: needs more work. this is a draft + */ + async getUserSpaces(userId: string, workspaceId: string) { + const rolePriority = sql`CASE "space_members"."role" + WHEN 'owner' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + END`.as('role_priority'); + + const subquery = this.db + .selectFrom('spaces') + .innerJoin('space_members', 'spaces.id', 'space_members.spaceId') + .select([ + 'spaces.id', + 'spaces.name', + 'spaces.slug', + 'spaces.icon', + 'space_members.role', + rolePriority, + ]) + .where('space_members.userId', '=', userId) + .where('spaces.workspaceId', '=', workspaceId) + .unionAll( + this.db + .selectFrom('spaces') + .innerJoin('space_members', 'spaces.id', 'space_members.spaceId') + .innerJoin( + 'group_users', + 'space_members.groupId', + 'group_users.groupId', + ) + .select([ + 'spaces.id', + 'spaces.name', + 'spaces.slug', + 'spaces.icon', + 'space_members.role', + rolePriority, + ]) + .where('group_users.userId', '=', userId), + ) + .as('membership'); + + const results = await this.db + .selectFrom(subquery) + .select([ + 'membership.id as space_id', + 'membership.name as space_name', + 'membership.slug as space_slug', + sql`MAX('role_priority')`.as('max_role_priority'), + sql`CASE MAX("role_priority") + WHEN 3 THEN 'owner' + WHEN 2 THEN 'writer' + WHEN 1 THEN 'reader' + END`.as('highest_role'), + ]) + .groupBy('membership.id') + .groupBy('membership.name') + .groupBy('membership.slug') + .execute(); + + let membership = {}; + + const spaces = results.map((result) => { + membership = { + id: result.space_id, + name: result.space_name, + role: result.highest_role, + }; + + return membership; + }); + + return spaces; + } + + /* + * we want to get a user's role in a space. + * they user can be a member either directly or via a group + * we will pass the user id and space id and workspaceId to return the user's role + * if the user is a member of the space via multiple groups + * we will return the one with the highest role permission + * It returns the space id, space name, user role + * and how the role was derived 'via' + * if the user has no space permission (not a member) it returns undefined + */ + async getUserRoleInSpace( + userId: string, + spaceId: string, + workspaceId: string, + ) { + const rolePriority = sql`CASE "space_members"."role" + WHEN 'owner' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + END`.as('role_priority'); + + const subquery = this.db + .selectFrom('spaces') + .innerJoin('space_members', 'spaces.id', 'space_members.spaceId') + .select([ + 'spaces.id', + 'spaces.name', + 'space_members.role', + 'space_members.userId', + rolePriority, + ]) + .where('space_members.userId', '=', userId) + .where('spaces.id', '=', spaceId) + .where('spaces.workspaceId', '=', workspaceId) + .unionAll( + this.db + .selectFrom('spaces') + .innerJoin('space_members', 'spaces.id', 'space_members.spaceId') + .innerJoin( + 'group_users', + 'space_members.groupId', + 'group_users.groupId', + ) + .select([ + 'spaces.id', + 'spaces.name', + 'space_members.role', + 'space_members.userId', + rolePriority, + ]) + .where('spaces.id', '=', spaceId) + .where('spaces.workspaceId', '=', workspaceId) + .where('group_users.userId', '=', userId), + ) + .as('membership'); + + const result = await this.db + .selectFrom(subquery) + .select([ + 'membership.id as space_id', + 'membership.name as space_name', + 'membership.userId as user_id', + sql`MAX('role_priority')`.as('max_role_priority'), + sql`CASE MAX("role_priority") + WHEN 3 THEN 'owner' + WHEN 2 THEN 'writer' + WHEN 1 THEN 'reader' + END`.as('highest_role'), + ]) + .groupBy('membership.id') + .groupBy('membership.name') + .groupBy('membership.userId') + .executeTakeFirst(); + + let membership = {}; + if (result) { + membership = { + id: result.space_id, + name: result.space_name, + role: result.highest_role, + via: result.user_id ? 'user' : 'group', // user_id is empty then role was derived via a group + }; + return membership; + } + return undefined; + } + + async getSpaceMemberById( + userId: string, + groupId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .selectFrom('space_members') + .selectAll() + .where('userId', '=', userId) + .where('groupId', '=', groupId) + .executeTakeFirst(); + }, + trx, + ); + } + + async removeUser(userId: string, spaceId: string): Promise { + await this.db + .deleteFrom('space_members') + .where('userId', '=', userId) + .where('spaceId', '=', spaceId) + .execute(); + } + + async removeGroup(groupId: string, spaceId: string): Promise { + await this.db + .deleteFrom('space_members') + .where('userId', '=', groupId) + .where('spaceId', '=', spaceId) + .execute(); + } +} diff --git a/apps/server/src/kysely/repos/space/space.repo.ts b/apps/server/src/kysely/repos/space/space.repo.ts new file mode 100644 index 00000000..f7eb2981 --- /dev/null +++ b/apps/server/src/kysely/repos/space/space.repo.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { + InsertableSpace, + Space, + UpdatableSpace, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; +import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; + +@Injectable() +export class SpaceRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById(spaceId: string, workspaceId: string): Promise { + return await this.db + .selectFrom('spaces') + .selectAll() + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async findBySlug(slug: string, workspaceId: string): Promise { + return await this.db + .selectFrom('spaces') + .selectAll() + .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async slugExists(slug: string, workspaceId: string): Promise { + let { count } = await this.db + .selectFrom('spaces') + .select((eb) => eb.fn.count('id').as('count')) + .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + count = count as number; + + return !!count; + } + + async updateSpace( + updatableSpace: UpdatableSpace, + spaceId: string, + workspaceId: string, + ) { + return await this.db + .updateTable('spaces') + .set(updatableSpace) + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + async insertSpace( + insertableSpace: InsertableSpace, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('spaces') + .values(insertableSpace) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async getSpacesInWorkspace( + workspaceId: string, + paginationOptions: PaginationOptions, + ) { + //todo: add member count + // to: show spaces user have access based on visibility and membership + + return executeTx(this.db, async (trx) => { + const spaces = await trx + .selectFrom('spaces') + .selectAll() + .where('workspaceId', '=', workspaceId) + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('spaces') + .select((eb) => eb.fn.count('id').as('count')) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + + count = count as number; + return { spaces, count }; + }); + } + + async deleteSpace(spaceId: string, workspaceId: string): Promise { + await this.db + .deleteFrom('spaces') + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/kysely/repos/space/types.ts b/apps/server/src/kysely/repos/space/types.ts new file mode 100644 index 00000000..c3f7ec09 --- /dev/null +++ b/apps/server/src/kysely/repos/space/types.ts @@ -0,0 +1,17 @@ +interface SpaceUserInfo { + id: string; + name: string; + email: string; + avatarUrl: string; + type: 'user'; +} + +interface SpaceGroupInfo { + id: string; + name: string; + isDefault: boolean; + memberCount?: number; + type: 'group'; +} + +export type MemberInfo = SpaceUserInfo | SpaceGroupInfo; diff --git a/apps/server/src/kysely/repos/user/user.repo.ts b/apps/server/src/kysely/repos/user/user.repo.ts new file mode 100644 index 00000000..0397d935 --- /dev/null +++ b/apps/server/src/kysely/repos/user/user.repo.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { Users } from '@docmost/db/types/db'; +import { hashPassword } from '../../../helpers/utils'; +import { executeTx } from '@docmost/db/utils'; +import { + InsertableUser, + UpdatableUser, + User, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; + +@Injectable() +export class UserRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + private baseFields: Array = [ + 'id', + 'email', + 'name', + 'emailVerifiedAt', + 'avatarUrl', + 'role', + 'workspaceId', + 'locale', + 'timezone', + 'settings', + 'lastLoginAt', + 'createdAt', + 'updatedAt', + ]; + + async findById( + userId: string, + workspaceId: string, + includePassword?: boolean, + ): Promise { + return this.db + .selectFrom('users') + .select(this.baseFields) + .$if(includePassword, (qb) => qb.select('password')) + .where('id', '=', userId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async findByEmail( + email: string, + workspaceId: string, + includePassword?: boolean, + ): Promise { + return this.db + .selectFrom('users') + .select(this.baseFields) + .$if(includePassword, (qb) => qb.select('password')) + .where('email', '=', email) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async updateUser( + updatableUser: UpdatableUser, + userId: string, + workspaceId: string, + ) { + return await this.db + .updateTable('users') + .set(updatableUser) + .where('id', '=', userId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + async updateLastLogin(userId: string, workspaceId: string) { + return await this.db + .updateTable('users') + .set({ + lastLoginAt: new Date(), + }) + .where('id', '=', userId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + async insertUser( + insertableUser: InsertableUser, + trx?: KyselyTransaction, + ): Promise { + const user: InsertableUser = { + name: insertableUser.name || insertableUser.email.split('@')[0], + email: insertableUser.email.toLowerCase(), + password: await hashPassword(insertableUser.password), + locale: 'en', + lastLoginAt: new Date(), + }; + + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('users') + .values(user) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async roleCountByWorkspaceId( + role: string, + workspaceId: string, + ): Promise { + const { count } = await this.db + .selectFrom('users') + .select((eb) => eb.fn.count('role').as('count')) + .where('role', '=', role) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + + return count as number; + } + + async getUsersPaginated( + workspaceId: string, + paginationOptions: PaginationOptions, + ) { + return executeTx(this.db, async (trx) => { + const users = await trx + .selectFrom('users') + .select(this.baseFields) + .where('workspaceId', '=', workspaceId) + .orderBy('createdAt asc') + .limit(paginationOptions.limit) + .offset(paginationOptions.offset) + .execute(); + + let { count } = await trx + .selectFrom('users') + .select((eb) => eb.fn.countAll().as('count')) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + + count = count as number; + return { users, count }; + }); + } +} diff --git a/apps/server/src/kysely/repos/workspace/workspace.repo.ts b/apps/server/src/kysely/repos/workspace/workspace.repo.ts new file mode 100644 index 00000000..39e371c1 --- /dev/null +++ b/apps/server/src/kysely/repos/workspace/workspace.repo.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { executeTx } from '../../utils'; +import { + InsertableWorkspace, + UpdatableWorkspace, + Workspace, +} from '@docmost/db/types/entity.types'; +import { sql } from 'kysely'; + +@Injectable() +export class WorkspaceRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById(workspaceId: string): Promise { + return await this.db + .selectFrom('workspaces') + .selectAll() + .where('id', '=', workspaceId) + .executeTakeFirst(); + } + + async findFirst(): Promise { + return await this.db + .selectFrom('workspaces') + .selectAll() + .orderBy('createdAt asc') + .limit(1) + .executeTakeFirst(); + } + + async findByHostname(hostname: string): Promise { + return await this.db + .selectFrom('workspaces') + .selectAll() + .where(sql`LOWER(hostname)`, '=', sql`LOWER(${hostname})`) + .executeTakeFirst(); + } + + async updateWorkspace( + updatableWorkspace: UpdatableWorkspace, + workspaceId: string, + trx?: KyselyTransaction, + ) { + return await executeTx( + this.db, + async (trx) => { + return await trx + .updateTable('workspaces') + .set(updatableWorkspace) + .where('id', '=', workspaceId) + .execute(); + }, + trx, + ); + } + + async insertWorkspace( + insertableWorkspace: InsertableWorkspace, + trx?: KyselyTransaction, + ): Promise { + return await executeTx( + this.db, + async (trx) => { + return await trx + .insertInto('workspaces') + .values(insertableWorkspace) + .returningAll() + .executeTakeFirst(); + }, + trx, + ); + } + + async count(): Promise { + const { count } = await this.db + .selectFrom('workspaces') + .select((eb) => eb.fn.count('id').as('count')) + .executeTakeFirst(); + return count as number; + } +} diff --git a/apps/server/src/kysely/types/db.d.ts b/apps/server/src/kysely/types/db.d.ts index 7e3a1a7a..ab2347f2 100644 --- a/apps/server/src/kysely/types/db.d.ts +++ b/apps/server/src/kysely/types/db.d.ts @@ -45,16 +45,13 @@ export interface Attachments { export interface Comments { content: Json | null; createdAt: Generated; - creatorId: string; + creatorId: string | null; deletedAt: Timestamp | null; editedAt: Timestamp | null; id: Generated; pageId: string; parentCommentId: string | null; - resolvedAt: Timestamp | null; - resolvedById: string | null; selection: string | null; - spaceId: string; type: string | null; workspaceId: string; } @@ -84,7 +81,7 @@ export interface PageHistory { createdAt: Generated; icon: string | null; id: Generated; - lastUpdatedById: string; + lastUpdatedById: string | null; pageId: string; slug: string | null; spaceId: string; @@ -110,14 +107,14 @@ export interface Pages { content: Json | null; coverPhoto: string | null; createdAt: Generated; - creatorId: string; + creatorId: string | null; deletedAt: Timestamp | null; deletedById: string | null; editor: string | null; html: string | null; icon: string | null; id: Generated; - isLocked: boolean; + isLocked: Generated; lastUpdatedById: string | null; parentPageId: string | null; publishedAt: Timestamp | null; @@ -164,6 +161,7 @@ export interface Users { email: string; emailVerifiedAt: Timestamp | null; id: Generated; + lastActiveAt: Timestamp | null; lastLoginAt: Timestamp | null; lastLoginIp: string | null; locale: string | null; @@ -171,6 +169,7 @@ export interface Users { password: string; role: string | null; settings: Json | null; + status: string | null; timezone: string | null; updatedAt: Generated; workspaceId: string | null; @@ -180,7 +179,7 @@ export interface WorkspaceInvitations { createdAt: Generated; email: string; id: Generated; - invitedById: string; + invitedById: string | null; role: string; status: string | null; updatedAt: Generated; @@ -195,7 +194,7 @@ export interface Workspaces { defaultSpaceId: string | null; deletedAt: Timestamp | null; description: string | null; - enableInvite: boolean; + enableInvite: Generated; hostname: string | null; id: Generated; inviteCode: string | null; diff --git a/apps/server/src/kysely/types/entity.types.ts b/apps/server/src/kysely/types/entity.types.ts new file mode 100644 index 00000000..069b8dba --- /dev/null +++ b/apps/server/src/kysely/types/entity.types.ts @@ -0,0 +1,77 @@ +import { Insertable, Selectable, Updateable } from 'kysely'; +import { + Attachments, + Comments, + Groups, + Pages, + Spaces, + Users, + Workspaces, + PageHistory as History, + PageOrdering as Ordering, + GroupUsers, + SpaceMembers, + WorkspaceInvitations, +} from './db'; + +// Workspace +export type Workspace = Selectable; +export type InsertableWorkspace = Insertable; +export type UpdatableWorkspace = Updateable>; + +// WorkspaceInvitation +export type WorkspaceInvitation = Selectable; +export type InsertableWorkspaceInvitation = Insertable; +export type UpdatableWorkspaceInvitation = Updateable< + Omit +>; + +// User +export type User = Selectable; +export type InsertableUser = Insertable; +export type UpdatableUser = Updateable>; + +// Space +export type Space = Selectable; +export type InsertableSpace = Insertable; +export type UpdatableSpace = Updateable>; + +// SpaceMember +export type SpaceMember = Selectable; +export type InsertableSpaceMember = Insertable; +export type UpdatableSpaceMember = Updateable>; + +// Group +export type Group = Selectable; +export type InsertableGroup = Insertable; +export type UpdatableGroup = Updateable>; + +// GroupUser +export type GroupUser = Selectable; +export type InsertableGroupUser = Insertable; +export type UpdatableGroupUser = Updateable>; + +// Page +export type Page = Selectable; +export type InsertablePage = Insertable; +export type UpdatablePage = Updateable>; + +// PageHistory +export type PageHistory = Selectable; +export type InsertablePageHistory = Insertable; +export type UpdatablePageHistory = Updateable>; + +// PageOrdering +export type PageOrdering = Selectable; +export type InsertablePageOrdering = Insertable; +export type UpdatablePageOrdering = Updateable>; + +// Comment +export type Comment = Selectable; +export type InsertableComment = Insertable; +export type UpdatableComment = Updateable>; + +// Attachment +export type Attachment = Selectable; +export type InsertableAttachment = Insertable; +export type UpdatableAttachment = Updateable>; diff --git a/apps/server/src/kysely/types/kysely.types.ts b/apps/server/src/kysely/types/kysely.types.ts new file mode 100644 index 00000000..39dae715 --- /dev/null +++ b/apps/server/src/kysely/types/kysely.types.ts @@ -0,0 +1,5 @@ +import { DB } from './db'; +import { Kysely, Transaction } from 'kysely'; + +export type KyselyDB = Kysely; +export type KyselyTransaction = Transaction; diff --git a/apps/server/src/kysely/utils.ts b/apps/server/src/kysely/utils.ts new file mode 100644 index 00000000..ecf8d06a --- /dev/null +++ b/apps/server/src/kysely/utils.ts @@ -0,0 +1,13 @@ +import { KyselyDB, KyselyTransaction } from './types/kysely.types'; + +export async function executeTx( + db: KyselyDB, + callback: (trx: KyselyTransaction) => Promise, + existingTrx?: KyselyTransaction, +): Promise { + if (existingTrx) { + return await callback(existingTrx); + } else { + return await db.transaction().execute((trx) => callback(trx)); + } +} diff --git a/apps/server/src/middlewares/domain.middleware.ts b/apps/server/src/middlewares/domain.middleware.ts index 8104a852..07c8321d 100644 --- a/apps/server/src/middlewares/domain.middleware.ts +++ b/apps/server/src/middlewares/domain.middleware.ts @@ -1,12 +1,12 @@ import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common'; import { FastifyRequest, FastifyReply } from 'fastify'; -import { WorkspaceRepository } from '../core/workspace/repositories/workspace.repository'; import { EnvironmentService } from '../integrations/environment/environment.service'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @Injectable() export class DomainMiddleware implements NestMiddleware { constructor( - private workspaceRepository: WorkspaceRepository, + private workspaceRepo: WorkspaceRepo, private environmentService: EnvironmentService, ) {} async use( @@ -15,7 +15,7 @@ export class DomainMiddleware implements NestMiddleware { next: () => void, ) { if (this.environmentService.isSelfHosted()) { - const workspace = await this.workspaceRepository.findFirst(); + const workspace = await this.workspaceRepo.findFirst(); if (!workspace) { throw new NotFoundException('Workspace not found'); } @@ -25,9 +25,7 @@ export class DomainMiddleware implements NestMiddleware { const header = req.headers.host; const subdomain = header.split('.')[0]; - const workspace = await this.workspaceRepository.findOneBy({ - hostname: subdomain, - }); + const workspace = await this.workspaceRepo.findByHostname(subdomain); if (!workspace) { throw new NotFoundException('Workspace not found'); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 3b92ed79..84fbae37 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -17,5 +17,10 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, + "strict": true, + "paths": { + "@docmost/db": ["./src/kysely"], + "@docmost/db/*": ["./src/kysely/*"], + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2905ee2..e5971a4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,6 +340,9 @@ importers: pg-tsquery: specifier: ^8.4.2 version: 8.4.2 + postgres: + specifier: ^3.4.4 + version: 3.4.4 reflect-metadata: specifier: ^0.2.1 version: 0.2.1 @@ -10209,6 +10212,11 @@ packages: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} dev: false + /postgres@3.4.4: + resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==} + engines: {node: '>=12'} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'}