mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-18 10:41:04 +10:00
Refactoring
* replace TypeORM with Kysely query builder * refactor migrations * other changes and fixes
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
export async function comparePasswordHash(
|
||||
plainPassword: string,
|
||||
passwordHash: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(plainPassword, passwordHash);
|
||||
}
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class ResolveCommentDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
|
||||
@IsBoolean()
|
||||
resolved: boolean;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
export class CreatePageDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
pageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
|
||||
|
||||
export class DeletePageDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
|
||||
|
||||
export class HistoryDetailsDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
historyId: string;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { IsString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class MovePageDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
pageId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@ -2,5 +2,5 @@ import { IsUUID } from 'class-validator';
|
||||
|
||||
export class PageDetailsDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@ -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[] };
|
||||
|
||||
@ -4,5 +4,5 @@ import { IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdatePageDto extends PartialType(CreatePageDto) {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ export class SearchResponseDto {
|
||||
icon: string;
|
||||
parentPageId: string;
|
||||
creatorId: string;
|
||||
rank: string;
|
||||
rank: number;
|
||||
highlight: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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, ' ')
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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: [];
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user