diff --git a/apps/server/src/core/auth/guards/JwtGuard.ts b/apps/server/src/core/auth/guards/JwtGuard.ts deleted file mode 100644 index e3648d2..0000000 --- a/apps/server/src/core/auth/guards/JwtGuard.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index c4d5651..5dde0e0 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -8,6 +8,7 @@ import { AttachmentModule } from './attachment/attachment.module'; import { EnvironmentModule } from '../environment/environment.module'; import { CommentModule } from './comment/comment.module'; import { SearchModule } from './search/search.module'; +import { SpaceModule } from './space/space.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { SearchModule } from './search/search.module'; AttachmentModule, CommentModule, SearchModule, + SpaceModule, ], }) export class CoreModule {} diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index 8881eb3..64364e9 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -16,4 +16,7 @@ export class CreatePageDto { @IsOptional() @IsString() parentPageId?: string; + + @IsString() + spaceId: string; } diff --git a/apps/server/src/core/page/entities/page-history.entity.ts b/apps/server/src/core/page/entities/page-history.entity.ts index c37203a..728a1aa 100644 --- a/apps/server/src/core/page/entities/page-history.entity.ts +++ b/apps/server/src/core/page/entities/page-history.entity.ts @@ -10,6 +10,7 @@ import { 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 { @@ -48,6 +49,13 @@ export class PageHistory { @JoinColumn({ name: 'lastUpdatedById' }) lastUpdatedBy: User; + @Column() + spaceId: string; + + @ManyToOne(() => Space, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'spaceId' }) + space: Space; + @Column() workspaceId: string; diff --git a/apps/server/src/core/page/entities/page-ordering.entity.ts b/apps/server/src/core/page/entities/page-ordering.entity.ts index e89d332..c820b39 100644 --- a/apps/server/src/core/page/entities/page-ordering.entity.ts +++ b/apps/server/src/core/page/entities/page-ordering.entity.ts @@ -10,6 +10,7 @@ import { DeleteDateColumn, } from 'typeorm'; import { Workspace } from '../../workspace/entities/workspace.entity'; +import { Space } from '../../space/entities/space.entity'; @Entity('page_ordering') @Unique(['entityId', 'entityType']) @@ -23,9 +24,12 @@ export class PageOrdering { @Column({ type: 'varchar', length: 50, nullable: false }) entityType: string; - @Column('uuid', { array: true }) + @Column('uuid', { array: true, default: '{}' }) childrenIds: string[]; + @Column('uuid') + workspaceId: string; + @ManyToOne(() => Workspace, (workspace) => workspace.id, { onDelete: 'CASCADE', }) @@ -33,7 +37,13 @@ export class PageOrdering { workspace: Workspace; @Column('uuid') - workspaceId: string; + spaceId: string; + + @ManyToOne(() => Space, (space) => space.id, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'spaceId' }) + space: Space; @DeleteDateColumn({ nullable: true }) deletedAt: Date; diff --git a/apps/server/src/core/page/entities/page.entity.ts b/apps/server/src/core/page/entities/page.entity.ts index 0067e18..3c2fced 100644 --- a/apps/server/src/core/page/entities/page.entity.ts +++ b/apps/server/src/core/page/entities/page.entity.ts @@ -14,6 +14,7 @@ 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('pages_tsv_idx', ['tsv']) @@ -82,6 +83,13 @@ export class Page { @JoinColumn({ name: 'deletedById' }) deletedBy: User; + @Column() + spaceId: string; + + @ManyToOne(() => Space, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'spaceId' }) + space: Space; + @Column() workspaceId: string; diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 5e810c5..d00a901 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -9,7 +9,7 @@ import { import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-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 { MovePageDto } from './dto/move-page.dto'; import { PageDetailsDto } from './dto/page-details.dto'; @@ -71,39 +71,27 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('recent') - async getRecentWorkspacePages(@JwtUser() jwtUser) { - const workspaceId = ( - await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) - ).id; - return this.pageService.getRecentWorkspacePages(workspaceId); + async getRecentSpacePages(@Body() { spaceId }) { + console.log(spaceId); + return this.pageService.getRecentSpacePages(spaceId); } @HttpCode(HttpStatus.OK) @Post() - async getWorkspacePages(@JwtUser() jwtUser) { - const workspaceId = ( - await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) - ).id; - return this.pageService.getSidebarPagesByWorkspaceId(workspaceId); + async getSpacePages(spaceId: string) { + return this.pageService.getSidebarPagesBySpaceId(spaceId); } @HttpCode(HttpStatus.OK) @Post('ordering') - async getWorkspacePageOrder(@JwtUser() jwtUser) { - const workspaceId = ( - await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) - ).id; - return this.pageOrderService.getWorkspacePageOrder(workspaceId); + async getSpacePageOrder(spaceId: string) { + return this.pageOrderService.getSpacePageOrder(spaceId); } @HttpCode(HttpStatus.OK) @Post('tree') - async workspacePageTree(@JwtUser() jwtUser) { - const workspaceId = ( - await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) - ).id; - - return this.pageOrderService.convertToTree(workspaceId); + async spacePageTree(@Body() { spaceId }) { + return this.pageOrderService.convertToTree(spaceId); } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/page/page.util.ts b/apps/server/src/core/page/page.util.ts index 70c8196..1b925a2 100644 --- a/apps/server/src/core/page/page.util.ts +++ b/apps/server/src/core/page/page.util.ts @@ -2,7 +2,8 @@ import { MovePageDto } from './dto/move-page.dto'; import { EntityManager } from 'typeorm'; export enum OrderingEntity { - workspace = 'WORKSPACE', + workspace = 'SPACE', + space = 'SPACE', page = 'PAGE', } diff --git a/apps/server/src/core/page/repositories/page.repository.ts b/apps/server/src/core/page/repositories/page.repository.ts index 6156dde..28c6977 100644 --- a/apps/server/src/core/page/repositories/page.repository.ts +++ b/apps/server/src/core/page/repositories/page.repository.ts @@ -18,6 +18,7 @@ export class PageRepository extends Repository { 'page.parentPageId', 'page.creatorId', 'page.lastUpdatedById', + 'page.spaceId', 'page.workspaceId', 'page.isLocked', 'page.status', diff --git a/apps/server/src/core/page/services/page-ordering.service.ts b/apps/server/src/core/page/services/page-ordering.service.ts index ad85482..6ca8704 100644 --- a/apps/server/src/core/page/services/page-ordering.service.ts +++ b/apps/server/src/core/page/services/page-ordering.service.ts @@ -34,7 +34,7 @@ export class PageOrderingService { const movedPage = await manager .createQueryBuilder(Page, 'page') .where('page.id = :movedPageId', { movedPageId }) - .select(['page.id', 'page.workspaceId', 'page.parentPageId']) + .select(['page.id', 'page.spaceId', 'page.parentPageId']) .getOne(); if (!movedPage) throw new BadRequestException('Moved page not found'); @@ -43,15 +43,15 @@ export class PageOrderingService { if (movedPage.parentPageId) { await this.removeFromParent(movedPage.parentPageId, dto.id, manager); } - const workspaceOrdering = await this.getEntityOrdering( - movedPage.workspaceId, - OrderingEntity.workspace, + const spaceOrdering = await this.getEntityOrdering( + movedPage.spaceId, + OrderingEntity.space, manager, ); - orderPageList(workspaceOrdering.childrenIds, dto); + orderPageList(spaceOrdering.childrenIds, dto); - await manager.save(workspaceOrdering); + await manager.save(spaceOrdering); } else { const parentPageId = dto.parentId; @@ -65,7 +65,7 @@ export class PageOrderingService { parentPageOrdering = await this.createPageOrdering( parentPageId, OrderingEntity.page, - movedPage.workspaceId, + movedPage.spaceId, 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.parentPageId) { - await this.removeFromWorkspacePageOrder( - movedPage.workspaceId, + await this.removeFromSpacePageOrder( + movedPage.spaceId, dto.id, manager, ); @@ -95,36 +95,32 @@ export class PageOrderingService { }); } - async addPageToOrder( - workspaceId: string, - pageId: string, - parentPageId?: string, - ) { + async addPageToOrder(spaceId: string, pageId: string, parentPageId?: string) { await this.dataSource.transaction(async (manager: EntityManager) => { if (parentPageId) { await this.upsertOrdering( parentPageId, OrderingEntity.page, pageId, - workspaceId, + spaceId, manager, ); } else { - await this.addToWorkspacePageOrder(workspaceId, pageId, manager); + await this.addToSpacePageOrder(spaceId, pageId, manager); } }); } - async addToWorkspacePageOrder( - workspaceId: string, + async addToSpacePageOrder( + spaceId: string, pageId: string, manager: EntityManager, ) { await this.upsertOrdering( - workspaceId, - OrderingEntity.workspace, + spaceId, + OrderingEntity.space, pageId, - workspaceId, + spaceId, manager, ); } @@ -142,14 +138,14 @@ export class PageOrderingService { ); } - async removeFromWorkspacePageOrder( - workspaceId: string, + async removeFromSpacePageOrder( + spaceId: string, pageId: string, manager: EntityManager, ) { await this.removeChildFromOrdering( - workspaceId, - OrderingEntity.workspace, + spaceId, + OrderingEntity.space, pageId, manager, ); @@ -179,11 +175,7 @@ export class PageOrderingService { if (page.parentPageId) { await this.removeFromParent(page.parentPageId, page.id, manager); } else { - await this.removeFromWorkspacePageOrder( - page.workspaceId, - page.id, - manager, - ); + await this.removeFromSpacePageOrder(page.spaceId, page.id, manager); } } @@ -191,7 +183,7 @@ export class PageOrderingService { entityId: string, entityType: string, childId: string, - workspaceId: string, + spaceId: string, manager: EntityManager, ) { let ordering = await this.getEntityOrdering(entityId, entityType, manager); @@ -200,7 +192,7 @@ export class PageOrderingService { ordering = await this.createPageOrdering( entityId, entityType, - workspaceId, + spaceId, manager, ); } @@ -229,35 +221,35 @@ export class PageOrderingService { async createPageOrdering( entityId: string, entityType: string, - workspaceId: string, + spaceId: string, manager: EntityManager, ): Promise { await manager.query( - `INSERT INTO page_ordering ("entityId", "entityType", "workspaceId", "childrenIds") + `INSERT INTO page_ordering ("entityId", "entityType", "spaceId", "childrenIds") VALUES ($1, $2, $3, '{}') ON CONFLICT ("entityId", "entityType") DO NOTHING`, - [entityId, entityType, workspaceId], + [entityId, entityType, spaceId], ); return await this.getEntityOrdering(entityId, entityType, manager); } - async getWorkspacePageOrder(workspaceId: string): Promise { + async getSpacePageOrder(spaceId: string): Promise { return await this.dataSource .createQueryBuilder(PageOrdering, 'ordering') - .select(['ordering.id', 'ordering.childrenIds', 'ordering.workspaceId']) - .where('ordering.entityId = :workspaceId', { workspaceId }) + .select(['ordering.id', 'ordering.childrenIds', 'ordering.spaceId']) + .where('ordering.entityId = :spaceId', { spaceId }) .andWhere('ordering.entityType = :entityType', { - entityType: OrderingEntity.workspace, + entityType: OrderingEntity.space, }) .getOne(); } - async convertToTree(workspaceId: string): Promise { - const workspaceOrder = await this.getWorkspacePageOrder(workspaceId); + async convertToTree(spaceId: string): Promise { + const spaceOrder = await this.getSpacePageOrder(spaceId); - const pageOrder = workspaceOrder ? workspaceOrder.childrenIds : undefined; - const pages = await this.pageService.getSidebarPagesByWorkspaceId(workspaceId); + const pageOrder = spaceOrder ? spaceOrder.childrenIds : undefined; + const pages = await this.pageService.getSidebarPagesBySpaceId(spaceId); const pageMap: { [id: string]: PageWithOrderingDto } = {}; pages.forEach((page) => { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 53e4bd4..e58d2e5 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -67,7 +67,7 @@ export class PageService { page.lastUpdatedById = userId; 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({ where: { id: createPageDto.parentPageId }, select: ['id'], @@ -79,7 +79,7 @@ export class PageService { const createdPage = await this.pageRepository.save(page); await this.pageOrderingService.addPageToOrder( - workspaceId, + createPageDto.spaceId, createPageDto.id, createPageDto.parentPageId, ); @@ -174,12 +174,7 @@ export class PageService { const restoredPage = await manager .createQueryBuilder(Page, 'page') .where('page.id = :pageId', { pageId }) - .select([ - 'page.id', - 'page.title', - 'page.workspaceId', - 'page.parentPageId', - ]) + .select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId']) .getOne(); if (!restoredPage) { @@ -188,7 +183,7 @@ export class PageService { // add page back to its hierarchy await this.pageOrderingService.addPageToOrder( - restoredPage.workspaceId, + restoredPage.spaceId, pageId, restoredPage.parentPageId, ); @@ -222,8 +217,8 @@ export class PageService { return await this.pageRepository.findById(pageId); } - async getSidebarPagesByWorkspaceId( - workspaceId: string, + async getSidebarPagesBySpaceId( + spaceId: string, limit = 200, ): Promise { const pages = await this.pageRepository @@ -234,12 +229,13 @@ export class PageService { 'ordering.entityId = page.id AND ordering.entityType = :entityType', { entityType: OrderingEntity.page }, ) - .where('page.workspaceId = :workspaceId', { workspaceId }) + .where('page.spaceId = :spaceId', { spaceId }) .select([ 'page.id', 'page.title', 'page.icon', 'page.parentPageId', + 'page.spaceId', 'ordering.childrenIds', 'page.creatorId', 'page.createdAt', @@ -251,14 +247,14 @@ export class PageService { return transformPageResult(pages); } - async getRecentWorkspacePages( - workspaceId: string, + async getRecentSpacePages( + spaceId: string, limit = 20, offset = 0, ): Promise { const pages = await this.pageRepository .createQueryBuilder('page') - .where('page.workspaceId = :workspaceId', { workspaceId }) + .where('page.spaceId = :spaceId', { spaceId }) .select(this.pageRepository.baseFields) .orderBy('page.updatedAt', 'DESC') .offset(offset) diff --git a/apps/server/src/core/space/dto/create-space.dto.ts b/apps/server/src/core/space/dto/create-space.dto.ts new file mode 100644 index 0000000..0f59c0e --- /dev/null +++ b/apps/server/src/core/space/dto/create-space.dto.ts @@ -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; +} diff --git a/apps/server/src/core/space/dto/delete-space.dto.ts b/apps/server/src/core/space/dto/delete-space.dto.ts new file mode 100644 index 0000000..ce0344e --- /dev/null +++ b/apps/server/src/core/space/dto/delete-space.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteSpaceDto { + @IsString() + spaceId: string; +} diff --git a/apps/server/src/core/space/dto/update-space.dto.ts b/apps/server/src/core/space/dto/update-space.dto.ts new file mode 100644 index 0000000..f474d43 --- /dev/null +++ b/apps/server/src/core/space/dto/update-space.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSpaceDto } from './create-space.dto'; + +export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {} diff --git a/apps/server/src/core/space/entities/space-user.entity.ts b/apps/server/src/core/space/entities/space-user.entity.ts new file mode 100644 index 0000000..94d719a --- /dev/null +++ b/apps/server/src/core/space/entities/space-user.entity.ts @@ -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; +} diff --git a/apps/server/src/core/space/entities/space.entity.ts b/apps/server/src/core/space/entities/space.entity.ts new file mode 100644 index 0000000..350e8a0 --- /dev/null +++ b/apps/server/src/core/space/entities/space.entity.ts @@ -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; +} diff --git a/apps/server/src/core/space/repositories/space-user.repository.ts b/apps/server/src/core/space/repositories/space-user.repository.ts new file mode 100644 index 0000000..5b90c7d --- /dev/null +++ b/apps/server/src/core/space/repositories/space-user.repository.ts @@ -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 { + constructor(private dataSource: DataSource) { + super(SpaceUser, dataSource.createEntityManager()); + } +} diff --git a/apps/server/src/core/space/repositories/space.repository.ts b/apps/server/src/core/space/repositories/space.repository.ts new file mode 100644 index 0000000..4e5e89b --- /dev/null +++ b/apps/server/src/core/space/repositories/space.repository.ts @@ -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 { + constructor(private dataSource: DataSource) { + super(Space, dataSource.createEntityManager()); + } + + async findById(spaceId: string) { + return this.findOneBy({ id: spaceId }); + } +} diff --git a/apps/server/src/core/space/space.controller.spec.ts b/apps/server/src/core/space/space.controller.spec.ts new file mode 100644 index 0000000..0402b23 --- /dev/null +++ b/apps/server/src/core/space/space.controller.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts new file mode 100644 index 0000000..3a1a7f2 --- /dev/null +++ b/apps/server/src/core/space/space.controller.ts @@ -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) {} +} diff --git a/apps/server/src/core/space/space.module.ts b/apps/server/src/core/space/space.module.ts new file mode 100644 index 0000000..a69a0c1 --- /dev/null +++ b/apps/server/src/core/space/space.module.ts @@ -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 {} diff --git a/apps/server/src/core/space/space.service.spec.ts b/apps/server/src/core/space/space.service.spec.ts new file mode 100644 index 0000000..f97afbe --- /dev/null +++ b/apps/server/src/core/space/space.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/space/space.service.ts b/apps/server/src/core/space/space.service.ts new file mode 100644 index 0000000..4e36902 --- /dev/null +++ b/apps/server/src/core/space/space.service.ts @@ -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 { + 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); + } +} diff --git a/apps/server/src/core/user/entities/user.entity.ts b/apps/server/src/core/user/entities/user.entity.ts index 549fbe2..1062e20 100644 --- a/apps/server/src/core/user/entities/user.entity.ts +++ b/apps/server/src/core/user/entities/user.entity.ts @@ -12,6 +12,8 @@ import { Workspace } from '../../workspace/entities/workspace.entity'; import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity'; import { Page } from '../../page/entities/page.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') export class User { @@ -66,6 +68,12 @@ export class User { @OneToMany(() => Comment, (comment) => comment.creator) comments: Comment[]; + @OneToMany(() => Space, (space) => space.creator) + spaces: Space[]; + + @OneToMany(() => SpaceUser, (spaceUser) => spaceUser.user) + spaceUsers: SpaceUser[]; + toJSON() { delete this.password; return this; diff --git a/apps/server/src/core/user/user.controller.ts b/apps/server/src/core/user/user.controller.ts index fc434cb..c17de4b 100644 --- a/apps/server/src/core/user/user.controller.ts +++ b/apps/server/src/core/user/user.controller.ts @@ -10,7 +10,7 @@ import { Body, } from '@nestjs/common'; import { UserService } from './user.service'; -import { JwtGuard } from '../auth/guards/JwtGuard'; +import { JwtGuard } from '../auth/guards/jwt.guard'; import { FastifyRequest } from 'fastify'; import { User } from './entities/user.entity'; import { Workspace } from '../workspace/entities/workspace.entity'; diff --git a/apps/server/src/core/user/user.module.ts b/apps/server/src/core/user/user.module.ts index 5295709..609d292 100644 --- a/apps/server/src/core/user/user.module.ts +++ b/apps/server/src/core/user/user.module.ts @@ -1,4 +1,4 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { forwardRef, Global, Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -7,6 +7,7 @@ import { UserRepository } from './repositories/user.repository'; import { AuthModule } from '../auth/auth.module'; import { WorkspaceModule } from '../workspace/workspace.module'; +@Global() @Module({ imports: [ TypeOrmModule.forFeature([User]), diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index 439f9cd..7fe8846 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -31,16 +31,24 @@ export class UserService { user = await this.userRepository.save(user); - //TODO: only create workspace if it is not a signup to an existing workspace - await this.workspaceService.create(user.id); + await this.workspaceService.createOrJoinWorkspace(user.id); return user; } async getUserInstance(userId: string) { const user: User = await this.findById(userId); + + if (!user) { + throw new NotFoundException('User not found'); + } + const workspace: Workspace = await this.workspaceService.getUserCurrentWorkspace(userId); + + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } return { user, workspace }; } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 35a48e5..94ed682 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -11,7 +11,7 @@ import { } from '@nestjs/common'; import { WorkspaceService } from '../services/workspace.service'; 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 { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; @@ -24,6 +24,17 @@ import { AddWorkspaceUserDto } from '../dto/add-workspace-user.dto'; export class WorkspaceController { 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) @Post('create') async createWorkspace( diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 5e8b028..eb36683 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -14,12 +14,14 @@ import { generateHostname } from '../workspace.util'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; +import { SpaceService } from '../../space/space.service'; @Injectable() export class WorkspaceService { constructor( private workspaceRepository: WorkspaceRepository, private workspaceUserRepository: WorkspaceUserRepository, + private spaceService: SpaceService, ) {} async findById(workspaceId: string): Promise { @@ -30,6 +32,45 @@ export class WorkspaceService { 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( userId: string, createWorkspaceDto?: CreateWorkspaceDto, @@ -50,7 +91,6 @@ export class WorkspaceService { } workspace = await this.workspaceRepository.save(workspace); - await this.addUserToWorkspace(userId, workspace.id, 'owner'); return workspace; } @@ -151,6 +191,10 @@ export class WorkspaceService { relations: ['workspace'], }); + if (!userWorkspace) { + throw new NotFoundException('No workspace found for this user'); + } + return userWorkspace.workspace; } diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index 87ff54f..af16e87 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -8,11 +8,13 @@ import { WorkspaceUser } from './entities/workspace-user.entity'; import { WorkspaceInvitation } from './entities/workspace-invitation.entity'; import { WorkspaceUserRepository } from './repositories/workspace-user.repository'; import { AuthModule } from '../auth/auth.module'; +import { SpaceModule } from '../space/space.module'; @Module({ imports: [ TypeOrmModule.forFeature([Workspace, WorkspaceUser, WorkspaceInvitation]), AuthModule, + SpaceModule, ], controllers: [WorkspaceController], providers: [WorkspaceService, WorkspaceRepository, WorkspaceUserRepository], diff --git a/apps/server/src/database/migrations/1706807570313-Spaces.ts b/apps/server/src/database/migrations/1706807570313-Spaces.ts new file mode 100644 index 0000000..6119e74 --- /dev/null +++ b/apps/server/src/database/migrations/1706807570313-Spaces.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Spaces1706807570313 implements MigrationInterface { + name = 'Spaces1706807570313' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/apps/server/src/database/migrations/1708941651476-AddSpacesUsers.ts b/apps/server/src/database/migrations/1708941651476-AddSpacesUsers.ts new file mode 100644 index 0000000..1e843c6 --- /dev/null +++ b/apps/server/src/database/migrations/1708941651476-AddSpacesUsers.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSpacesUsers1708941651476 implements MigrationInterface { + name = 'AddSpacesUsers1708941651476' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +}