Refactoring

* replace TypeORM with Kysely query builder
* refactor migrations
* other changes and fixes
This commit is contained in:
Philipinho
2024-03-29 01:46:11 +00:00
parent cacb5606b1
commit c18c9ae02b
122 changed files with 2619 additions and 3541 deletions

View File

@ -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);

View File

@ -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 {}

View File

@ -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<MultipartFile>, userId: string) {
async uploadAvatar(
filePromise: Promise<MultipartFile>,
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) {

View File

@ -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;
}

View File

@ -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<Attachment> {
constructor(private dataSource: DataSource) {
super(Attachment, dataSource.createEntityManager());
}
async findById(id: string) {
return this.findOneBy({ id: id });
}
}

View File

@ -1,8 +0,0 @@
import * as bcrypt from 'bcrypt';
export async function comparePasswordHash(
plainPassword: string,
passwordHash: string,
): Promise<boolean> {
return bcrypt.compare(plainPassword, passwordHash);
}

View File

@ -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<boolean> {
const workspaceCount = await this.workspaceRepository.count();
const workspaceCount = await this.workspaceRepo.count();
if (workspaceCount > 0) {
throw new ForbiddenException('Workspace setup already completed.');
}

View File

@ -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);

View File

@ -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<User> {
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<User> {
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<Workspace> {
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<User> {
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

View File

@ -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<TokensDto> {
async generateTokens(user): Promise<TokensDto> {
return {
accessToken: await this.generateAccessToken(user),
refreshToken: await this.generateRefreshToken(user.id, user.workspaceId),

View File

@ -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();

View File

@ -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<AppAbility>(createMongoAbility);
const userRole = user.role;
if (userRole === UserRole.OWNER || userRole === UserRole.ADMIN) {
// Workspace Users
can<any>([Action.Manage], Workspace);
can<any>([Action.Manage], 'workspaceUser');
can([Action.Manage], 'Workspace');
can([Action.Manage], 'WorkspaceUser');
can<any>([Action.Manage], WorkspaceInvitation);
can([Action.Manage], 'WorkspaceInvitation');
// Groups
can<any>([Action.Manage], Group);
can<any>([Action.Manage], GroupUser);
can([Action.Manage], 'Group');
can([Action.Manage], 'GroupUser');
// Attachments
can<any>([Action.Manage], Attachment);
can([Action.Manage], 'Attachment');
}
if (userRole === UserRole.MEMBER) {
// can<any>([Action.Read], WorkspaceUser);
// Groups
can<any>([Action.Read], Group);
can<any>([Action.Read], GroupUser);
can([Action.Read], 'Group');
can([Action.Read], 'GroupUser');
// Attachments
can<any>([Action.Read, Action.Create], Attachment);
can([Action.Read, Action.Create], 'Attachment');
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
createForUser(user: User) {
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
can<any>([Action.Manage], User, { id: user.id });
can<any>([Action.Read], User);
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
detectSubjectType: (item) => item as ExtractSubjectType<Subjects>,
});
}
}

View File

@ -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),

View File

@ -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) {

View File

@ -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 {}

View File

@ -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<PaginatedResult<Comment>> {
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<Comment> {
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<Comment> {
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<void> {
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);
}
}

View File

@ -1,9 +0,0 @@
import { IsBoolean, IsUUID } from 'class-validator';
export class ResolveCommentDto {
@IsUUID()
commentId: string;
@IsBoolean()
resolved: boolean;
}

View File

@ -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;
}

View File

@ -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<Comment> {
constructor(private dataSource: DataSource) {
super(Comment, dataSource.createEntityManager());
}
async findById(commentId: string) {
return this.findOneBy({ id: commentId });
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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(

View File

@ -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 {}

View File

@ -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<GroupUser> {
constructor(private dataSource: DataSource) {
super(GroupUser, dataSource.createEntityManager());
}
}

View File

@ -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<Group> {
constructor(private dataSource: DataSource) {
super(Group, dataSource.createEntityManager());
}
}

View File

@ -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<PaginatedResult<User>> {
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<void> {
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<GroupUser> {
return await transactionWrapper(
async (manager) => {
const group = await manager.findOneBy(Group, {
id: groupId,
workspaceId: workspaceId,
});
trx?: KyselyTransaction,
): Promise<void> {
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<GroupUser> {
return await this.groupUserRepository.findOneBy({
userId,
groupId,
});
await this.groupUserRepo.delete(userId, groupId);
}
}

View File

@ -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<Group> {
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<Group> {
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<Group> {
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<Group> {
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<Group> {
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<PaginatedResult<Group>> {
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<Group> {
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<Group> {
return this.groupRepository
.createQueryBuilder('group')
.where('LOWER(group.name) = LOWER(:groupName)', { groupName })
.andWhere('group.workspaceId = :workspaceId', { workspaceId })
.getOne();
}
}

View File

@ -3,7 +3,7 @@ import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreatePageDto {
@IsOptional()
@IsUUID()
id?: string;
pageId?: string;
@IsOptional()
@IsString()

View File

@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
export class DeletePageDto {
@IsUUID()
id: string;
pageId: string;
}

View File

@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
export class HistoryDetailsDto {
@IsUUID()
id: string;
historyId: string;
}

View File

@ -2,7 +2,7 @@ import { IsString, IsOptional, IsUUID } from 'class-validator';
export class MovePageDto {
@IsUUID()
id: string;
pageId: string;
@IsOptional()
@IsString()

View File

@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
export class PageDetailsDto {
@IsUUID()
id: string;
pageId: string;
}

View File

@ -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[] };

View File

@ -4,5 +4,5 @@ import { IsUUID } from 'class-validator';
export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsUUID()
id: string;
pageId: string;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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<T>(
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;

View File

@ -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<PageHistory> {
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,
},
},
});
}
}

View File

@ -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<Page> {
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);
}
}

View File

@ -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<PageHistory> {
async findById(historyId: string): Promise<PageHistory> {
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<void> {
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);
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<PageOrdering> {
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<PageOrdering> {
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<PageOrdering> {
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<TreeNode[]> {

View File

@ -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<Page> {
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<Page> {
return this.pageRepo.findById(pageId, includeContent, includeYdoc);
}
async create(
@ -61,26 +36,26 @@ export class PageService {
workspaceId: string,
createPageDto: CreatePageDto,
): Promise<Page> {
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<Page> {
const updateData = {
...updatePageDto,
lastUpdatedById: userId,
};
): Promise<void> {
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<void> {
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<void> {
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<void> {
await this.pageRepository.delete(pageId);
}
async lockOrUnlockPage(pageId: string, lock: boolean): Promise<Page> {
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<PageWithOrderingDto[]> {
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<PageWithOrderingDto[]>();
const pages = await this.pageRepo.getSpaceSidebarPages(spaceId, limit);
return transformPageResult(pages);
}
async getRecentSpacePages(
spaceId: string,
limit = 20,
offset = 0,
): Promise<Page[]> {
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<PaginatedResult<Page>> {
const { pages, count } = await this.pageRepo.getRecentPagesInSpace(
spaceId,
paginationOptions,
);
const paginationMeta = new PaginationMetaDto({ count, paginationOptions });
return new PaginatedResult(pages, paginationMeta);
}
}

View File

@ -4,6 +4,8 @@ export class SearchResponseDto {
icon: string;
parentPageId: string;
creatorId: string;
rank: string;
rank: number;
highlight: string;
createdAt: Date;
updatedAt: Date;
}

View File

@ -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')

View File

@ -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],
})

View File

@ -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<number>`ts_rank(tsv, to_ts_query(${searchQuery}))`.as('rank'),
sql<string>`ts_headline('english', page.textContent, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as(
'highlight',
),
])
.where('workspaceId', '=', workspaceId)
.where('tsv', '@@', sql<string>`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, ' ')

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<SpaceMember> {
constructor(private dataSource: DataSource) {
super(SpaceMember, dataSource.createEntityManager());
}
}

View File

@ -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<Space> {
constructor(private dataSource: DataSource) {
super(Space, dataSource.createEntityManager());
}
async findById(spaceId: string, workspaceId: string): Promise<Space> {
const queryBuilder = this.dataSource.createQueryBuilder(Space, 'space');
return await queryBuilder
.where('space.id = :id', { id: spaceId })
.andWhere('space.workspaceId = :workspaceId', { workspaceId })
.getOne();
}
}

View File

@ -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<SpaceMember> {
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<SpaceMember> {
//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<SpaceMember> {
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

View File

@ -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<Space> {
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<Space> {
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<PaginatedResult<Space>> {
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 });

View File

@ -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')

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
}
async findByEmail(email: string): Promise<User> {
const queryBuilder = this.dataSource.createQueryBuilder(User, 'user');
return await queryBuilder.where('user.email = :email', { email }).getOne();
}
async findById(userId: string): Promise<User> {
const queryBuilder = this.dataSource.createQueryBuilder(User, 'user');
return await queryBuilder.where('user.id = :id', { id: userId }).getOne();
}
async findOneByEmail(email: string, workspaceId: string): Promise<User> {
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<User> {
const queryBuilder = this.dataSource.createQueryBuilder(User, 'user');
return await queryBuilder
.where('user.id = :id', { id: userId })
.andWhere('user.workspaceId = :workspaceId', { workspaceId })
.getOne();
}
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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>(UserService);
userRepository = module.get<UserRepository>(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),
);
});
});
});

View File

@ -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;
}
}

View File

@ -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,
// );
}
}

View File

@ -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;
}

View File

@ -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: [];
}

View File

@ -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<WorkspaceInvitation> {
constructor(private dataSource: DataSource) {
super(WorkspaceInvitation, dataSource.createEntityManager());
}
}

View File

@ -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<Workspace> {
constructor(private dataSource: DataSource) {
super(Workspace, dataSource.createEntityManager());
}
async findById(workspaceId: string): Promise<Workspace> {
// 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<Workspace> {
const createdWorkspace = await this.find({
order: {
createdAt: 'ASC',
},
take: 1,
});
return createdWorkspace[0];
}
}

View File

@ -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);
}
*/
}

View File

@ -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<PaginatedResult<User>> {
const [workspaceUsers, count] = await this.userRepository.findAndCount({
where: {
workspaceId,
},
take: paginationOptions.limit,
skip: paginationOptions.skip,
});
): Promise<PaginatedResult<any>> {
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<any> {
return 'todo';
}
async findWorkspaceUser(userId: string, workspaceId: string): Promise<User> {
return await this.userRepository.findOneBy({
id: userId,
workspaceId,
});
}
async findWorkspaceUserByEmail(
email: string,
workspaceId: string,
): Promise<User> {
return await this.userRepository.findOneBy({
email: email,
workspaceId,
});
}
async findAndValidateWorkspaceUser(
userId: string,
workspaceId: string,
): Promise<User> {
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<any> {
return 'todo';
}
}

View File

@ -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<Workspace> {
return this.workspaceRepository.findById(workspaceId);
async findById(workspaceId: string) {
return this.workspaceRepo.findById(workspaceId);
}
async getWorkspaceInfo(workspaceId: string): Promise<Workspace> {
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<Workspace> {
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<void> {
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<Workspace> {
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<void> {
const workspace = await this.workspaceRepository.findById(
deleteWorkspaceDto.workspaceId,
);
async delete(workspaceId: string): Promise<void> {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
// delete
//delete
}
}

View File

@ -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 {}