feat(ee): personal spaces

This commit is contained in:
Philipinho
2026-06-20 13:30:58 +01:00
parent 1867aa8bf6
commit 9d19a3269f
26 changed files with 397 additions and 9 deletions
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -0,0 +1,19 @@
import { ModuleRef } from '@nestjs/core';
export async function provisionPersonalSpaceForNewUser(
moduleRef: ModuleRef,
userId: string,
workspaceId: string,
): Promise<void> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const personalSpaceModule = require('../../ee/personal-space/services/personal-space.service');
const personalSpaceService = moduleRef.get(
personalSpaceModule.PersonalSpaceService,
{ strict: false },
);
await personalSpaceService.provisionForNewUser(userId, workspaceId);
} catch {
// module not found
}
}
@@ -1,4 +1,6 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { provisionPersonalSpaceForNewUser } from '../../../common/helpers/personal-space-provisioning';
import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
@@ -22,6 +24,7 @@ export class SignupService {
private userRepo: UserRepo,
private workspaceService: WorkspaceService,
private groupUserRepo: GroupUserRepo,
private moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -89,6 +92,14 @@ export class SignupService {
},
});
if (!trx) {
await provisionPersonalSpaceForNewUser(
this.moduleRef,
user.id,
workspaceId,
);
}
return user;
}
@@ -1,7 +1,7 @@
import {
IsAlphanumeric,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@IsAlphanumeric()
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
message:
'Space slug must start with a letter or number and may contain hyphens and underscores',
})
slug: string;
}
@@ -48,6 +48,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
let space = null;
@@ -59,6 +60,7 @@ export class SpaceService {
workspaceId,
createSpaceDto,
trx,
options,
);
await this.spaceMemberService.addUserToSpace(
@@ -81,6 +83,7 @@ export class SpaceService {
after: {
name: space.name,
slug: space.slug,
...(space.isPersonal ? { isPersonal: true } : {}),
},
},
});
@@ -93,6 +96,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
const slugExists = await this.spaceRepo.slugExists(
createSpaceDto.slug,
@@ -112,6 +116,7 @@ export class SpaceService {
creatorId: userId,
workspaceId: workspaceId,
slug: createSpaceDto.slug,
isPersonal: options?.isPersonal ?? false,
},
trx,
);
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
allowMemberTemplates: boolean;
@IsOptional()
@IsBoolean()
enablePersonalSpaces: boolean;
}
@@ -6,6 +6,8 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { provisionPersonalSpaceForNewUser } from '../../../common/helpers/personal-space-provisioning';
import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { InjectKysely } from 'nestjs-kysely';
@@ -53,6 +55,7 @@ export class WorkspaceInvitationService {
private domainService: DomainService,
private tokenService: TokenService,
private sessionService: SessionService,
private moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@@ -310,6 +313,12 @@ export class WorkspaceInvitationService {
return;
}
await provisionPersonalSpaceForNewUser(
this.moduleRef,
newUser.id,
workspace.id,
);
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
@@ -333,7 +333,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.enablePersonalSpaces !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -361,6 +362,18 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.enablePersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.PERSONAL_SPACES,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -500,6 +513,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.enablePersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.personal ?? false;
if (prev !== updateWorkspaceDto.enablePersonalSpaces) {
before.enablePersonalSpaces = prev;
after.enablePersonalSpaces = updateWorkspaceDto.enablePersonalSpaces;
}
await this.workspaceRepo.updateSpaceSettings(
workspaceId,
'personal',
updateWorkspaceDto.enablePersonalSpaces,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -507,6 +534,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.enablePersonalSpaces;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('spaces')
.addColumn('is_personal', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
await sql`
CREATE UNIQUE INDEX spaces_personal_creator_unique
ON spaces (creator_id)
WHERE is_personal = true AND deleted_at IS NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('spaces_personal_creator_unique')
.ifExists()
.execute();
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
}
@@ -57,6 +57,22 @@ export class SpaceRepo {
.executeTakeFirst();
}
async findPersonalSpace(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Space | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('spaces')
.selectAll('spaces')
.where('workspaceId', '=', workspaceId)
.where('creatorId', '=', userId)
.where('isPersonal', '=', true)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async slugExists(
slug: string,
workspaceId: string,
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateSpaceSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}
+1
View File
@@ -322,6 +322,7 @@ export interface Spaces {
deletedAt: Timestamp | null;
description: string | null;
id: Generated<string>;
isPersonal: Generated<boolean>;
logo: string | null;
name: string | null;
settings: Json | null;