feat: spaces - WIP

This commit is contained in:
Philipinho
2024-02-28 02:39:46 +00:00
parent 1d620eba49
commit 40251aef7d
32 changed files with 512 additions and 119 deletions

View File

@ -1,30 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { TokenService } from '../services/token.service';
@Injectable()
export class JwtGuard implements CanActivate {
constructor(private tokenService: TokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token: string = await this.tokenService.extractTokenFromHeader(
request,
);
if (!token) {
throw new UnauthorizedException('Invalid jwt token');
}
try {
request['user'] = await this.tokenService.verifyJwt(token);
} catch (error) {
throw new UnauthorizedException('Could not verify jwt token');
}
return true;
}
}

View File

@ -8,6 +8,7 @@ import { AttachmentModule } from './attachment/attachment.module';
import { EnvironmentModule } from '../environment/environment.module'; import { EnvironmentModule } from '../environment/environment.module';
import { CommentModule } from './comment/comment.module'; import { CommentModule } from './comment/comment.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
@Module({ @Module({
imports: [ imports: [
@ -21,6 +22,7 @@ import { SearchModule } from './search/search.module';
AttachmentModule, AttachmentModule,
CommentModule, CommentModule,
SearchModule, SearchModule,
SpaceModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -16,4 +16,7 @@ export class CreatePageDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
parentPageId?: string; parentPageId?: string;
@IsString()
spaceId: string;
} }

View File

@ -10,6 +10,7 @@ import {
import { Workspace } from '../../workspace/entities/workspace.entity'; import { Workspace } from '../../workspace/entities/workspace.entity';
import { Page } from './page.entity'; import { Page } from './page.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { Space } from '../../space/entities/space.entity';
@Entity('page_history') @Entity('page_history')
export class PageHistory { export class PageHistory {
@ -48,6 +49,13 @@ export class PageHistory {
@JoinColumn({ name: 'lastUpdatedById' }) @JoinColumn({ name: 'lastUpdatedById' })
lastUpdatedBy: User; lastUpdatedBy: User;
@Column()
spaceId: string;
@ManyToOne(() => Space, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'spaceId' })
space: Space;
@Column() @Column()
workspaceId: string; workspaceId: string;

View File

@ -10,6 +10,7 @@ import {
DeleteDateColumn, DeleteDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Workspace } from '../../workspace/entities/workspace.entity'; import { Workspace } from '../../workspace/entities/workspace.entity';
import { Space } from '../../space/entities/space.entity';
@Entity('page_ordering') @Entity('page_ordering')
@Unique(['entityId', 'entityType']) @Unique(['entityId', 'entityType'])
@ -23,9 +24,12 @@ export class PageOrdering {
@Column({ type: 'varchar', length: 50, nullable: false }) @Column({ type: 'varchar', length: 50, nullable: false })
entityType: string; entityType: string;
@Column('uuid', { array: true }) @Column('uuid', { array: true, default: '{}' })
childrenIds: string[]; childrenIds: string[];
@Column('uuid')
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.id, { @ManyToOne(() => Workspace, (workspace) => workspace.id, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@ -33,7 +37,13 @@ export class PageOrdering {
workspace: Workspace; workspace: Workspace;
@Column('uuid') @Column('uuid')
workspaceId: string; spaceId: string;
@ManyToOne(() => Space, (space) => space.id, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'spaceId' })
space: Space;
@DeleteDateColumn({ nullable: true }) @DeleteDateColumn({ nullable: true })
deletedAt: Date; deletedAt: Date;

View File

@ -14,6 +14,7 @@ import { User } from '../../user/entities/user.entity';
import { Workspace } from '../../workspace/entities/workspace.entity'; import { Workspace } from '../../workspace/entities/workspace.entity';
import { Comment } from '../../comment/entities/comment.entity'; import { Comment } from '../../comment/entities/comment.entity';
import { PageHistory } from './page-history.entity'; import { PageHistory } from './page-history.entity';
import { Space } from '../../space/entities/space.entity';
@Entity('pages') @Entity('pages')
@Index('pages_tsv_idx', ['tsv']) @Index('pages_tsv_idx', ['tsv'])
@ -82,6 +83,13 @@ export class Page {
@JoinColumn({ name: 'deletedById' }) @JoinColumn({ name: 'deletedById' })
deletedBy: User; deletedBy: User;
@Column()
spaceId: string;
@ManyToOne(() => Space, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'spaceId' })
space: Space;
@Column() @Column()
workspaceId: string; workspaceId: string;

View File

@ -9,7 +9,7 @@ import {
import { PageService } from './services/page.service'; import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto'; import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto'; import { UpdatePageDto } from './dto/update-page.dto';
import { JwtGuard } from '../auth/guards/JwtGuard'; import { JwtGuard } from '../auth/guards/jwt.guard';
import { WorkspaceService } from '../workspace/services/workspace.service'; import { WorkspaceService } from '../workspace/services/workspace.service';
import { MovePageDto } from './dto/move-page.dto'; import { MovePageDto } from './dto/move-page.dto';
import { PageDetailsDto } from './dto/page-details.dto'; import { PageDetailsDto } from './dto/page-details.dto';
@ -71,39 +71,27 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('recent') @Post('recent')
async getRecentWorkspacePages(@JwtUser() jwtUser) { async getRecentSpacePages(@Body() { spaceId }) {
const workspaceId = ( console.log(spaceId);
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) return this.pageService.getRecentSpacePages(spaceId);
).id;
return this.pageService.getRecentWorkspacePages(workspaceId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
async getWorkspacePages(@JwtUser() jwtUser) { async getSpacePages(spaceId: string) {
const workspaceId = ( return this.pageService.getSidebarPagesBySpaceId(spaceId);
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id)
).id;
return this.pageService.getSidebarPagesByWorkspaceId(workspaceId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('ordering') @Post('ordering')
async getWorkspacePageOrder(@JwtUser() jwtUser) { async getSpacePageOrder(spaceId: string) {
const workspaceId = ( return this.pageOrderService.getSpacePageOrder(spaceId);
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id)
).id;
return this.pageOrderService.getWorkspacePageOrder(workspaceId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('tree') @Post('tree')
async workspacePageTree(@JwtUser() jwtUser) { async spacePageTree(@Body() { spaceId }) {
const workspaceId = ( return this.pageOrderService.convertToTree(spaceId);
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id)
).id;
return this.pageOrderService.convertToTree(workspaceId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@ -2,7 +2,8 @@ import { MovePageDto } from './dto/move-page.dto';
import { EntityManager } from 'typeorm'; import { EntityManager } from 'typeorm';
export enum OrderingEntity { export enum OrderingEntity {
workspace = 'WORKSPACE', workspace = 'SPACE',
space = 'SPACE',
page = 'PAGE', page = 'PAGE',
} }

View File

@ -18,6 +18,7 @@ export class PageRepository extends Repository<Page> {
'page.parentPageId', 'page.parentPageId',
'page.creatorId', 'page.creatorId',
'page.lastUpdatedById', 'page.lastUpdatedById',
'page.spaceId',
'page.workspaceId', 'page.workspaceId',
'page.isLocked', 'page.isLocked',
'page.status', 'page.status',

View File

@ -34,7 +34,7 @@ export class PageOrderingService {
const movedPage = await manager const movedPage = await manager
.createQueryBuilder(Page, 'page') .createQueryBuilder(Page, 'page')
.where('page.id = :movedPageId', { movedPageId }) .where('page.id = :movedPageId', { movedPageId })
.select(['page.id', 'page.workspaceId', 'page.parentPageId']) .select(['page.id', 'page.spaceId', 'page.parentPageId'])
.getOne(); .getOne();
if (!movedPage) throw new BadRequestException('Moved page not found'); if (!movedPage) throw new BadRequestException('Moved page not found');
@ -43,15 +43,15 @@ export class PageOrderingService {
if (movedPage.parentPageId) { if (movedPage.parentPageId) {
await this.removeFromParent(movedPage.parentPageId, dto.id, manager); await this.removeFromParent(movedPage.parentPageId, dto.id, manager);
} }
const workspaceOrdering = await this.getEntityOrdering( const spaceOrdering = await this.getEntityOrdering(
movedPage.workspaceId, movedPage.spaceId,
OrderingEntity.workspace, OrderingEntity.space,
manager, manager,
); );
orderPageList(workspaceOrdering.childrenIds, dto); orderPageList(spaceOrdering.childrenIds, dto);
await manager.save(workspaceOrdering); await manager.save(spaceOrdering);
} else { } else {
const parentPageId = dto.parentId; const parentPageId = dto.parentId;
@ -65,7 +65,7 @@ export class PageOrderingService {
parentPageOrdering = await this.createPageOrdering( parentPageOrdering = await this.createPageOrdering(
parentPageId, parentPageId,
OrderingEntity.page, OrderingEntity.page,
movedPage.workspaceId, movedPage.spaceId,
manager, manager,
); );
} }
@ -78,8 +78,8 @@ export class PageOrderingService {
// If movedPage didn't have a parent initially (was at root level), update the root level // If movedPage didn't have a parent initially (was at root level), update the root level
if (!movedPage.parentPageId) { if (!movedPage.parentPageId) {
await this.removeFromWorkspacePageOrder( await this.removeFromSpacePageOrder(
movedPage.workspaceId, movedPage.spaceId,
dto.id, dto.id,
manager, manager,
); );
@ -95,36 +95,32 @@ export class PageOrderingService {
}); });
} }
async addPageToOrder( async addPageToOrder(spaceId: string, pageId: string, parentPageId?: string) {
workspaceId: string,
pageId: string,
parentPageId?: string,
) {
await this.dataSource.transaction(async (manager: EntityManager) => { await this.dataSource.transaction(async (manager: EntityManager) => {
if (parentPageId) { if (parentPageId) {
await this.upsertOrdering( await this.upsertOrdering(
parentPageId, parentPageId,
OrderingEntity.page, OrderingEntity.page,
pageId, pageId,
workspaceId, spaceId,
manager, manager,
); );
} else { } else {
await this.addToWorkspacePageOrder(workspaceId, pageId, manager); await this.addToSpacePageOrder(spaceId, pageId, manager);
} }
}); });
} }
async addToWorkspacePageOrder( async addToSpacePageOrder(
workspaceId: string, spaceId: string,
pageId: string, pageId: string,
manager: EntityManager, manager: EntityManager,
) { ) {
await this.upsertOrdering( await this.upsertOrdering(
workspaceId, spaceId,
OrderingEntity.workspace, OrderingEntity.space,
pageId, pageId,
workspaceId, spaceId,
manager, manager,
); );
} }
@ -142,14 +138,14 @@ export class PageOrderingService {
); );
} }
async removeFromWorkspacePageOrder( async removeFromSpacePageOrder(
workspaceId: string, spaceId: string,
pageId: string, pageId: string,
manager: EntityManager, manager: EntityManager,
) { ) {
await this.removeChildFromOrdering( await this.removeChildFromOrdering(
workspaceId, spaceId,
OrderingEntity.workspace, OrderingEntity.space,
pageId, pageId,
manager, manager,
); );
@ -179,11 +175,7 @@ export class PageOrderingService {
if (page.parentPageId) { if (page.parentPageId) {
await this.removeFromParent(page.parentPageId, page.id, manager); await this.removeFromParent(page.parentPageId, page.id, manager);
} else { } else {
await this.removeFromWorkspacePageOrder( await this.removeFromSpacePageOrder(page.spaceId, page.id, manager);
page.workspaceId,
page.id,
manager,
);
} }
} }
@ -191,7 +183,7 @@ export class PageOrderingService {
entityId: string, entityId: string,
entityType: string, entityType: string,
childId: string, childId: string,
workspaceId: string, spaceId: string,
manager: EntityManager, manager: EntityManager,
) { ) {
let ordering = await this.getEntityOrdering(entityId, entityType, manager); let ordering = await this.getEntityOrdering(entityId, entityType, manager);
@ -200,7 +192,7 @@ export class PageOrderingService {
ordering = await this.createPageOrdering( ordering = await this.createPageOrdering(
entityId, entityId,
entityType, entityType,
workspaceId, spaceId,
manager, manager,
); );
} }
@ -229,35 +221,35 @@ export class PageOrderingService {
async createPageOrdering( async createPageOrdering(
entityId: string, entityId: string,
entityType: string, entityType: string,
workspaceId: string, spaceId: string,
manager: EntityManager, manager: EntityManager,
): Promise<PageOrdering> { ): Promise<PageOrdering> {
await manager.query( await manager.query(
`INSERT INTO page_ordering ("entityId", "entityType", "workspaceId", "childrenIds") `INSERT INTO page_ordering ("entityId", "entityType", "spaceId", "childrenIds")
VALUES ($1, $2, $3, '{}') VALUES ($1, $2, $3, '{}')
ON CONFLICT ("entityId", "entityType") DO NOTHING`, ON CONFLICT ("entityId", "entityType") DO NOTHING`,
[entityId, entityType, workspaceId], [entityId, entityType, spaceId],
); );
return await this.getEntityOrdering(entityId, entityType, manager); return await this.getEntityOrdering(entityId, entityType, manager);
} }
async getWorkspacePageOrder(workspaceId: string): Promise<PageOrdering> { async getSpacePageOrder(spaceId: string): Promise<PageOrdering> {
return await this.dataSource return await this.dataSource
.createQueryBuilder(PageOrdering, 'ordering') .createQueryBuilder(PageOrdering, 'ordering')
.select(['ordering.id', 'ordering.childrenIds', 'ordering.workspaceId']) .select(['ordering.id', 'ordering.childrenIds', 'ordering.spaceId'])
.where('ordering.entityId = :workspaceId', { workspaceId }) .where('ordering.entityId = :spaceId', { spaceId })
.andWhere('ordering.entityType = :entityType', { .andWhere('ordering.entityType = :entityType', {
entityType: OrderingEntity.workspace, entityType: OrderingEntity.space,
}) })
.getOne(); .getOne();
} }
async convertToTree(workspaceId: string): Promise<TreeNode[]> { async convertToTree(spaceId: string): Promise<TreeNode[]> {
const workspaceOrder = await this.getWorkspacePageOrder(workspaceId); const spaceOrder = await this.getSpacePageOrder(spaceId);
const pageOrder = workspaceOrder ? workspaceOrder.childrenIds : undefined; const pageOrder = spaceOrder ? spaceOrder.childrenIds : undefined;
const pages = await this.pageService.getSidebarPagesByWorkspaceId(workspaceId); const pages = await this.pageService.getSidebarPagesBySpaceId(spaceId);
const pageMap: { [id: string]: PageWithOrderingDto } = {}; const pageMap: { [id: string]: PageWithOrderingDto } = {};
pages.forEach((page) => { pages.forEach((page) => {

View File

@ -67,7 +67,7 @@ export class PageService {
page.lastUpdatedById = userId; page.lastUpdatedById = userId;
if (createPageDto.parentPageId) { if (createPageDto.parentPageId) {
// TODO: make sure parent page belongs to same workspace and user has permissions // TODO: make sure parent page belongs to same space and user has permissions
const parentPage = await this.pageRepository.findOne({ const parentPage = await this.pageRepository.findOne({
where: { id: createPageDto.parentPageId }, where: { id: createPageDto.parentPageId },
select: ['id'], select: ['id'],
@ -79,7 +79,7 @@ export class PageService {
const createdPage = await this.pageRepository.save(page); const createdPage = await this.pageRepository.save(page);
await this.pageOrderingService.addPageToOrder( await this.pageOrderingService.addPageToOrder(
workspaceId, createPageDto.spaceId,
createPageDto.id, createPageDto.id,
createPageDto.parentPageId, createPageDto.parentPageId,
); );
@ -174,12 +174,7 @@ export class PageService {
const restoredPage = await manager const restoredPage = await manager
.createQueryBuilder(Page, 'page') .createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId }) .where('page.id = :pageId', { pageId })
.select([ .select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId'])
'page.id',
'page.title',
'page.workspaceId',
'page.parentPageId',
])
.getOne(); .getOne();
if (!restoredPage) { if (!restoredPage) {
@ -188,7 +183,7 @@ export class PageService {
// add page back to its hierarchy // add page back to its hierarchy
await this.pageOrderingService.addPageToOrder( await this.pageOrderingService.addPageToOrder(
restoredPage.workspaceId, restoredPage.spaceId,
pageId, pageId,
restoredPage.parentPageId, restoredPage.parentPageId,
); );
@ -222,8 +217,8 @@ export class PageService {
return await this.pageRepository.findById(pageId); return await this.pageRepository.findById(pageId);
} }
async getSidebarPagesByWorkspaceId( async getSidebarPagesBySpaceId(
workspaceId: string, spaceId: string,
limit = 200, limit = 200,
): Promise<PageWithOrderingDto[]> { ): Promise<PageWithOrderingDto[]> {
const pages = await this.pageRepository const pages = await this.pageRepository
@ -234,12 +229,13 @@ export class PageService {
'ordering.entityId = page.id AND ordering.entityType = :entityType', 'ordering.entityId = page.id AND ordering.entityType = :entityType',
{ entityType: OrderingEntity.page }, { entityType: OrderingEntity.page },
) )
.where('page.workspaceId = :workspaceId', { workspaceId }) .where('page.spaceId = :spaceId', { spaceId })
.select([ .select([
'page.id', 'page.id',
'page.title', 'page.title',
'page.icon', 'page.icon',
'page.parentPageId', 'page.parentPageId',
'page.spaceId',
'ordering.childrenIds', 'ordering.childrenIds',
'page.creatorId', 'page.creatorId',
'page.createdAt', 'page.createdAt',
@ -251,14 +247,14 @@ export class PageService {
return transformPageResult(pages); return transformPageResult(pages);
} }
async getRecentWorkspacePages( async getRecentSpacePages(
workspaceId: string, spaceId: string,
limit = 20, limit = 20,
offset = 0, offset = 0,
): Promise<Page[]> { ): Promise<Page[]> {
const pages = await this.pageRepository const pages = await this.pageRepository
.createQueryBuilder('page') .createQueryBuilder('page')
.where('page.workspaceId = :workspaceId', { workspaceId }) .where('page.spaceId = :spaceId', { spaceId })
.select(this.pageRepository.baseFields) .select(this.pageRepository.baseFields)
.orderBy('page.updatedAt', 'DESC') .orderBy('page.updatedAt', 'DESC')
.offset(offset) .offset(offset)

View File

@ -0,0 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateSpaceDto {
@MinLength(4)
@MaxLength(64)
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class DeleteSpaceDto {
@IsString()
spaceId: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Space } from './space.entity';
@Entity('space_users')
@Unique(['spaceId', 'userId'])
export class SpaceUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@ManyToOne(() => User, (user) => user.spaceUsers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: User;
@Column()
spaceId: string;
@ManyToOne(() => Space, (space) => space.spaceUsers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'spaceId' })
space: Space;
@Column({ length: 100, nullable: true })
role: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { SpaceUser } from './space-user.entity';
@Entity('spaces')
export class Space {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255, nullable: true })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 255, nullable: true })
icon: string;
@Column({ length: 255, nullable: true, unique: true })
hostname: string;
@Column()
creatorId: string;
@ManyToOne(() => User, (user) => user.spaces)
@JoinColumn({ name: 'creatorId' })
creator: User;
@Column()
workspaceId: string;
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@OneToMany(() => SpaceUser, (workspaceUser) => workspaceUser.space)
spaceUsers: SpaceUser[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { SpaceUser } from '../entities/space-user.entity';
@Injectable()
export class SpaceUserRepository extends Repository<SpaceUser> {
constructor(private dataSource: DataSource) {
super(SpaceUser, dataSource.createEntityManager());
}
}

View File

@ -0,0 +1,14 @@
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) {
return this.findOneBy({ id: spaceId });
}
}

View File

@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpaceController } from './space.controller';
import { SpaceService } from './space.service';
describe('SpaceController', () => {
let controller: SpaceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SpaceController],
providers: [SpaceService],
}).compile();
controller = module.get<SpaceController>(SpaceController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,22 @@
import {
Controller,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { JwtGuard } from '../auth/guards/jwt.guard';
import { SpaceService } from './space.service';
@UseGuards(JwtGuard)
@Controller('spaces')
export class SpaceController {
constructor(private readonly spaceService: SpaceService) {}
// get all spaces user is a member of
@HttpCode(HttpStatus.OK)
@Post('/')
async getUserSpaces(@Req() req: FastifyRequest) {}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { SpaceService } from './space.service';
import { SpaceController } from './space.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Space } from './entities/space.entity';
import { AuthModule } from '../auth/auth.module';
import { SpaceUser } from './entities/space-user.entity';
import { SpaceRepository } from './repositories/space.repository';
import { SpaceUserRepository } from './repositories/space-user.repository';
@Module({
imports: [TypeOrmModule.forFeature([Space, SpaceUser]), AuthModule],
controllers: [SpaceController],
providers: [SpaceService, SpaceRepository, SpaceUserRepository],
exports: [SpaceService, SpaceRepository, SpaceUserRepository],
})
export class SpaceModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpaceService } from './space.service';
describe('SpaceService', () => {
let service: SpaceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SpaceService],
}).compile();
service = module.get<SpaceService>(SpaceService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,69 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { CreateSpaceDto } from './dto/create-space.dto';
import { Space } from './entities/space.entity';
import { plainToInstance } from 'class-transformer';
import { SpaceRepository } from './repositories/space.repository';
import { SpaceUserRepository } from './repositories/space-user.repository';
import { SpaceUser } from './entities/space-user.entity';
@Injectable()
export class SpaceService {
constructor(
private spaceRepository: SpaceRepository,
private spaceUserRepository: SpaceUserRepository,
) {}
async create(userId: string, workspaceId, createSpaceDto?: CreateSpaceDto) {
let space: Space;
if (createSpaceDto) {
space = plainToInstance(Space, createSpaceDto);
} else {
space = new Space();
}
space.creatorId = userId;
space.workspaceId = workspaceId;
space.name = createSpaceDto?.name ?? 'untitled space';
space.description = createSpaceDto?.description ?? null;
space = await this.spaceRepository.save(space);
return space;
}
async addUserToSpace(
userId: string,
spaceId: string,
role: string,
): Promise<SpaceUser> {
const existingSpaceUser = await this.spaceUserRepository.findOne({
where: { userId: userId, spaceId: spaceId },
});
if (existingSpaceUser) {
throw new BadRequestException('User already added to this space');
}
const spaceUser = new SpaceUser();
spaceUser.userId = userId;
spaceUser.spaceId = spaceId;
spaceUser.role = role;
return this.spaceUserRepository.save(spaceUser);
}
async getUserSpacesInWorkspace(userId: string, workspaceId: string) {
const spaces = await this.spaceUserRepository.find({
relations: ['space'],
where: {
userId: userId,
space: {
workspaceId: workspaceId,
},
},
});
return spaces.map((userSpace: SpaceUser) => userSpace.space);
}
}

View File

@ -12,6 +12,8 @@ import { Workspace } from '../../workspace/entities/workspace.entity';
import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity'; import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity';
import { Page } from '../../page/entities/page.entity'; import { Page } from '../../page/entities/page.entity';
import { Comment } from '../../comment/entities/comment.entity'; import { Comment } from '../../comment/entities/comment.entity';
import { Space } from '../../space/entities/space.entity';
import { SpaceUser } from '../../space/entities/space-user.entity';
@Entity('users') @Entity('users')
export class User { export class User {
@ -66,6 +68,12 @@ export class User {
@OneToMany(() => Comment, (comment) => comment.creator) @OneToMany(() => Comment, (comment) => comment.creator)
comments: Comment[]; comments: Comment[];
@OneToMany(() => Space, (space) => space.creator)
spaces: Space[];
@OneToMany(() => SpaceUser, (spaceUser) => spaceUser.user)
spaceUsers: SpaceUser[];
toJSON() { toJSON() {
delete this.password; delete this.password;
return this; return this;

View File

@ -10,7 +10,7 @@ import {
Body, Body,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { JwtGuard } from '../auth/guards/JwtGuard'; import { JwtGuard } from '../auth/guards/jwt.guard';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { Workspace } from '../workspace/entities/workspace.entity'; import { Workspace } from '../workspace/entities/workspace.entity';

View File

@ -1,4 +1,4 @@
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Global, Module } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@ -7,6 +7,7 @@ import { UserRepository } from './repositories/user.repository';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module'; import { WorkspaceModule } from '../workspace/workspace.module';
@Global()
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([User]), TypeOrmModule.forFeature([User]),

View File

@ -31,16 +31,24 @@ export class UserService {
user = await this.userRepository.save(user); user = await this.userRepository.save(user);
//TODO: only create workspace if it is not a signup to an existing workspace await this.workspaceService.createOrJoinWorkspace(user.id);
await this.workspaceService.create(user.id);
return user; return user;
} }
async getUserInstance(userId: string) { async getUserInstance(userId: string) {
const user: User = await this.findById(userId); const user: User = await this.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
const workspace: Workspace = const workspace: Workspace =
await this.workspaceService.getUserCurrentWorkspace(userId); await this.workspaceService.getUserCurrentWorkspace(userId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
return { user, workspace }; return { user, workspace };
} }

View File

@ -11,7 +11,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { WorkspaceService } from '../services/workspace.service'; import { WorkspaceService } from '../services/workspace.service';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { JwtGuard } from '../../auth/guards/JwtGuard'; import { JwtGuard } from '../../auth/guards/jwt.guard';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto';
@ -24,6 +24,17 @@ import { AddWorkspaceUserDto } from '../dto/add-workspace-user.dto';
export class WorkspaceController { export class WorkspaceController {
constructor(private readonly workspaceService: WorkspaceService) {} constructor(private readonly workspaceService: WorkspaceService) {}
@HttpCode(HttpStatus.OK)
@Post('test')
async test(
@Req() req: FastifyRequest,
//@Body() createWorkspaceDto: CreateWorkspaceDto,
) {
//const jwtPayload = req['user'];
// const userId = jwtPayload.sub;
// return this.workspaceService.createOrJoinWorkspace();
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
async createWorkspace( async createWorkspace(

View File

@ -14,12 +14,14 @@ import { generateHostname } from '../workspace.util';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { SpaceService } from '../../space/space.service';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
constructor( constructor(
private workspaceRepository: WorkspaceRepository, private workspaceRepository: WorkspaceRepository,
private workspaceUserRepository: WorkspaceUserRepository, private workspaceUserRepository: WorkspaceUserRepository,
private spaceService: SpaceService,
) {} ) {}
async findById(workspaceId: string): Promise<Workspace> { async findById(workspaceId: string): Promise<Workspace> {
@ -30,6 +32,45 @@ export class WorkspaceService {
return this.workspaceRepository.save(workspace); return this.workspaceRepository.save(workspace);
} }
async createOrJoinWorkspace(userId) {
// context:
// only create workspace if it is not a signup to an existing workspace
// OS version is limited to one workspace.
// if there is no existing workspace, create a new workspace
// and make first user owner/admin
// check if workspace already exists in the db
// if not create default, if yes add the user to existing workspace if signup is open.
const workspaceCount = await this.workspaceRepository.count();
if (workspaceCount === 0) {
// create first workspace
// add user to workspace as admin
const newWorkspace = await this.create(userId);
await this.addUserToWorkspace(userId, newWorkspace.id, 'owner');
// maybe create default space and add user to it too.
const newSpace = await this.spaceService.create(userId, newWorkspace.id);
await this.spaceService.addUserToSpace(userId, newSpace.id, 'owner');
} else {
//TODO: accept role as param
// if no role is passed use default new member role found in workspace settings
// fetch the oldest workspace and add user to it
const firstWorkspace = await this.workspaceRepository.find({
order: {
createdAt: 'ASC',
},
take: 1,
});
await this.addUserToWorkspace(userId, firstWorkspace[0].id, 'member');
// get workspace
// if there is a default space, we should add new users to it.
}
}
async create( async create(
userId: string, userId: string,
createWorkspaceDto?: CreateWorkspaceDto, createWorkspaceDto?: CreateWorkspaceDto,
@ -50,7 +91,6 @@ export class WorkspaceService {
} }
workspace = await this.workspaceRepository.save(workspace); workspace = await this.workspaceRepository.save(workspace);
await this.addUserToWorkspace(userId, workspace.id, 'owner');
return workspace; return workspace;
} }
@ -151,6 +191,10 @@ export class WorkspaceService {
relations: ['workspace'], relations: ['workspace'],
}); });
if (!userWorkspace) {
throw new NotFoundException('No workspace found for this user');
}
return userWorkspace.workspace; return userWorkspace.workspace;
} }

View File

@ -8,11 +8,13 @@ import { WorkspaceUser } from './entities/workspace-user.entity';
import { WorkspaceInvitation } from './entities/workspace-invitation.entity'; import { WorkspaceInvitation } from './entities/workspace-invitation.entity';
import { WorkspaceUserRepository } from './repositories/workspace-user.repository'; import { WorkspaceUserRepository } from './repositories/workspace-user.repository';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SpaceModule } from '../space/space.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Workspace, WorkspaceUser, WorkspaceInvitation]), TypeOrmModule.forFeature([Workspace, WorkspaceUser, WorkspaceInvitation]),
AuthModule, AuthModule,
SpaceModule,
], ],
controllers: [WorkspaceController], controllers: [WorkspaceController],
providers: [WorkspaceService, WorkspaceRepository, WorkspaceUserRepository], providers: [WorkspaceService, WorkspaceRepository, WorkspaceUserRepository],

View File

@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Spaces1706807570313 implements MigrationInterface {
name = 'Spaces1706807570313'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "spaces" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255), "description" text, "icon" character varying(255), "hostname" character varying(255), "creatorId" uuid NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_4f0a029f6eefd773fde2143b261" UNIQUE ("hostname"), CONSTRAINT "PK_dbe542974aca57afcb60709d4c8" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "page_history" ADD "spaceId" uuid NOT NULL`);
await queryRunner.query(`ALTER TABLE "pages" ADD "spaceId" uuid NOT NULL`);
await queryRunner.query(`ALTER TABLE "page_ordering" ADD "spaceId" uuid NOT NULL`);
await queryRunner.query(`ALTER TABLE "spaces" ADD CONSTRAINT "FK_8469f60fb94d43a0280a83d0b35" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "spaces" ADD CONSTRAINT "FK_f8c6dec54d8a2fdd26ea036fc8d" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "page_history" ADD CONSTRAINT "FK_8caa9d435480f4390b9885c4bd0" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "pages" ADD CONSTRAINT "FK_c4aef9b23f1222bebc5897de72d" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "page_ordering" ADD CONSTRAINT "FK_17f9d5fd14d32a81d58ad747359" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "page_ordering" DROP CONSTRAINT "FK_17f9d5fd14d32a81d58ad747359"`);
await queryRunner.query(`ALTER TABLE "pages" DROP CONSTRAINT "FK_c4aef9b23f1222bebc5897de72d"`);
await queryRunner.query(`ALTER TABLE "page_history" DROP CONSTRAINT "FK_8caa9d435480f4390b9885c4bd0"`);
await queryRunner.query(`ALTER TABLE "spaces" DROP CONSTRAINT "FK_f8c6dec54d8a2fdd26ea036fc8d"`);
await queryRunner.query(`ALTER TABLE "spaces" DROP CONSTRAINT "FK_8469f60fb94d43a0280a83d0b35"`);
await queryRunner.query(`ALTER TABLE "page_ordering" DROP COLUMN "spaceId"`);
await queryRunner.query(`ALTER TABLE "pages" DROP COLUMN "spaceId"`);
await queryRunner.query(`ALTER TABLE "page_history" DROP COLUMN "spaceId"`);
await queryRunner.query(`DROP TABLE "spaces"`);
}
}

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSpacesUsers1708941651476 implements MigrationInterface {
name = 'AddSpacesUsers1708941651476'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "space_users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "spaceId" uuid NOT NULL, "role" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_5819a4f6b83e86596c57c19e39f" UNIQUE ("spaceId", "userId"), CONSTRAINT "PK_8d03fbe7f6bc26f9ac665250e1d" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "space_users" ADD CONSTRAINT "FK_e735cdb3781f344a2dff3083fd5" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "space_users" ADD CONSTRAINT "FK_dae4f7e55306bdcec6ac8f602c1" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "space_users" DROP CONSTRAINT "FK_dae4f7e55306bdcec6ac8f602c1"`);
await queryRunner.query(`ALTER TABLE "space_users" DROP CONSTRAINT "FK_e735cdb3781f344a2dff3083fd5"`);
await queryRunner.query(`DROP TABLE "space_users"`);
}
}