implement new invitation system

* fix comments on the frontend
* move jwt token service to its own module
* other fixes and updates
This commit is contained in:
Philipinho
2024-05-14 22:55:11 +01:00
parent 525990d6e5
commit eefe63d1cd
75 changed files with 10965 additions and 7846 deletions

View File

@ -18,6 +18,7 @@
"migration:down": "tsx ./src/kysely/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest",
"migration:redo": "tsx ./src/kysely/migrate.ts redo",
"migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
@ -30,7 +31,6 @@
"@aws-sdk/client-s3": "^3.565.0",
"@aws-sdk/s3-request-presigner": "^3.565.0",
"@casl/ability": "^6.7.1",
"@docmost/transactional": "workspace:^",
"@fastify/multipart": "^8.2.0",
"@fastify/static": "^7.0.3",
"@nestjs/bullmq": "^10.1.1",
@ -53,10 +53,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^4.26.2",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0",
"kysely": "^0.27.3",
"kysely-migration-cli": "^0.4.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nestjs-kysely": "^0.1.7",
"nodemailer": "^6.9.13",
"passport-jwt": "^4.0.1",

View File

@ -1,5 +1,4 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthModule } from '../core/auth/auth.module';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@ -8,6 +7,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { HistoryExtension } from './extensions/history.extension';
import { TokenModule } from '../core/auth/token.module';
@Module({
providers: [
@ -16,7 +16,7 @@ import { HistoryExtension } from './extensions/history.extension';
PersistenceExtension,
HistoryExtension,
],
imports: [AuthModule],
imports: [TokenModule],
})
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private collabWsAdapter: CollabWsAdapter;

View File

@ -1,32 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service';
import { JwtModule } from '@nestjs/jwt';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { GroupModule } from '../group/group.module';
import { TokenModule } from './token.module';
@Module({
imports: [
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.getJwtSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
},
};
},
inject: [EnvironmentService],
}),
WorkspaceModule,
GroupModule,
],
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, TokenService, JwtStrategy],
exports: [TokenService],
providers: [AuthService, SignupService, JwtStrategy],
})
export class AuthModule {}

View File

@ -9,8 +9,8 @@ import {
export class CreateUserDto {
@IsOptional()
@MinLength(3)
@MaxLength(35)
@MinLength(2)
@MaxLength(60)
@IsString()
name: string;

View File

@ -3,19 +3,19 @@ import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
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';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable()
export class SignupService {
constructor(
private userRepo: UserRepo,
private workspaceService: WorkspaceService,
private groupUserService: GroupUserService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -56,7 +56,7 @@ export class SignupService {
);
// add user to default group
await this.groupUserService.addUserToDefaultGroup(
await this.groupUserRepo.addUserToDefaultGroup(
user.id,
workspaceId,
trx,

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service';
@Module({
imports: [
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.getAppSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
issuer: 'Docmost',
},
};
},
inject: [EnvironmentService],
}),
],
providers: [TokenService],
exports: [TokenService],
})
export class TokenModule {}

View File

@ -11,7 +11,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
export type Subjects =
| 'Workspace'
| 'WorkspaceInvitation'
| 'Space'
| 'SpaceMember'
| 'Group'
@ -36,8 +35,6 @@ export default class CaslAbilityFactory {
can([Action.Manage], 'Workspace');
can([Action.Manage], 'WorkspaceUser');
can([Action.Manage], 'WorkspaceInvitation');
// Groups
can([Action.Manage], 'Group');
can([Action.Manage], 'GroupUser');

View File

@ -66,8 +66,7 @@ export class CommentService {
workspaceId: workspaceId,
});
// return created comment and creator relation
return this.findById(createdComment.id);
return createdComment;
}
async findByPageId(
@ -114,7 +113,12 @@ export class CommentService {
return comment;
}
async remove(id: string): Promise<void> {
await this.commentRepo.deleteComment(id);
async remove(commentId: string): Promise<void> {
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.commentRepo.deleteComment(commentId);
}
}

View File

@ -2,13 +2,9 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
import { GroupIdDto } from './group-id.dto';
export class AddGroupUserDto extends GroupIdDto {
// @IsOptional()
// @IsUUID()
// userId: string;
@IsArray()
@ArrayMaxSize(50, {
message: 'userIds must an array with no more than 50 elements',
message: 'you cannot add more than 50 users at a time',
})
@ArrayMinSize(1)
@IsUUID(4, { each: true })

View File

@ -7,17 +7,14 @@ import {
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { KyselyDB } from '@docmost/db/types/kysely.types';
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 { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class GroupUserService {
constructor(
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
@Inject(forwardRef(() => GroupService))
@ -40,24 +37,6 @@ export class GroupUserService {
return groupUsers;
}
async addUserToDefaultGroup(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const defaultGroup = await this.groupRepo.getDefaultGroup(
workspaceId,
trx,
);
await this.addUserToGroup(userId, defaultGroup.id, workspaceId, trx);
},
trx,
);
}
async addUsersToGroupBatch(
userIds: string[],
groupId: string,
@ -90,48 +69,6 @@ export class GroupUserService {
.execute();
}
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
await this.groupService.findAndValidateGroup(groupId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');
}
const groupUserExists = await this.groupUserRepo.getGroupUserById(
userId,
groupId,
trx,
);
if (groupUserExists) {
throw new BadRequestException(
'User is already a member of this group',
);
}
await this.groupUserRepo.insertGroupUser(
{
userId,
groupId,
},
trx,
);
},
trx,
);
}
async removeUserFromGroup(
userId: string,
groupId: string,

View File

@ -4,42 +4,22 @@ import {
HttpCode,
HttpStatus,
Post,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { UserService } from './user.service';
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,
private userRepo: UserRepo,
) {}
constructor(private readonly userService: UserService) {}
@HttpCode(HttpStatus.OK)
@Post('me')
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');
}
return user;
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getUserIno(
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,

View File

@ -4,19 +4,20 @@ import {
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { WorkspaceService } from '../services/workspace.service';
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 { AuthWorkspace } from '../../../decorators/auth-workspace.decorator';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WorkspaceInvitationService } from '../services/workspace-invitation.service';
import { Public } from '../../../decorators/public.decorator';
import {
AcceptInviteDto,
InvitationIdDto,
InviteUserDto,
RevokeInviteDto,
} from '../dto/invitation.dto';
@ -24,7 +25,6 @@ import { Action } from '../../casl/ability.action';
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';
@ -33,7 +33,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
export class WorkspaceController {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceUserService: WorkspaceUserService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
) {}
@ -59,16 +58,6 @@ export class WorkspaceController {
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'Workspace'),
)
@HttpCode(HttpStatus.OK)
@Post('delete')
async deleteWorkspace(@Body() deleteWorkspaceDto: DeleteWorkspaceDto) {
// return this.workspaceService.delete(deleteWorkspaceDto);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Read, 'WorkspaceUser'),
@ -80,10 +69,7 @@ export class WorkspaceController {
pagination: PaginationOptions,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceUserService.getWorkspaceUsers(
workspace.id,
pagination,
);
return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
}
@UseGuards(PoliciesGuard)
@ -93,7 +79,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('members/deactivate')
async deactivateWorkspaceMember() {
return this.workspaceUserService.deactivateUser();
return this.workspaceService.deactivateUser();
}
@UseGuards(PoliciesGuard)
@ -107,7 +93,7 @@ export class WorkspaceController {
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceUserService.updateWorkspaceUserRole(
return this.workspaceService.updateWorkspaceUserRole(
authUser,
workspaceUserRoleDto,
workspace.id,
@ -116,37 +102,91 @@ export class WorkspaceController {
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceInvitation'),
ability.can(Action.Read, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invite')
async inviteUser(
@Body() inviteUserDto: InviteUserDto,
@AuthUser() authUser: User,
@Post('invites')
async getInvitations(
@AuthWorkspace() workspace: Workspace,
@Body()
pagination: PaginationOptions,
) {
/* return this.workspaceInvitationService.createInvitation(
authUser,
return this.workspaceInvitationService.getInvitations(
workspace.id,
inviteUserDto,
);*/
pagination,
);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('invite/accept')
async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) {
// return this.workspaceInvitationService.acceptInvitation(
// acceptInviteDto.invitationId,
//);
@Post('invites/info')
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
return this.workspaceInvitationService.getInvitationById(
dto.invitationId,
req.raw.workspaceId,
);
}
// TODO: authorize permission with guards
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invite/revoke')
async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) {
// return this.workspaceInvitationService.revokeInvitation(
// revokeInviteDto.invitationId,
// );
@Post('invites/create')
async inviteUser(
@Body() inviteUserDto: InviteUserDto,
@AuthWorkspace() workspace: Workspace,
@AuthUser() authUser: User,
) {
return this.workspaceInvitationService.createInvitation(
inviteUserDto,
workspace.id,
authUser,
);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invites/resend')
async resendInvite(
@Body() revokeInviteDto: RevokeInviteDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceInvitationService.resendInvitation(
revokeInviteDto.invitationId,
workspace.id,
);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invites/revoke')
async revokeInvite(
@Body() revokeInviteDto: RevokeInviteDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceInvitationService.revokeInvitation(
revokeInviteDto.invitationId,
workspace.id,
);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('invites/accept')
async acceptInvite(
@Body() acceptInviteDto: AcceptInviteDto,
@Req() req: any,
) {
return this.workspaceInvitationService.acceptInvitation(
acceptInviteDto,
req.raw.workspaceId,
);
}
}

View File

@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class AddWorkspaceUserDto {
@IsNotEmpty()
@IsUUID()
userId: string;
@IsNotEmpty()
@IsString()
role: string;
}

View File

@ -1,13 +1,35 @@
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
import { UserRole } from '../../../helpers/types/permission';
export class InviteUserDto {
@IsString()
@IsOptional()
name: string;
@IsArray()
@ArrayMaxSize(50, {
message: 'you cannot invite more than 50 users at a time',
})
@ArrayMinSize(1)
@IsEmail({}, { each: true })
emails: string[];
@IsEmail()
email: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'you cannot add invited users to more than 25 groups at a time',
})
@ArrayMinSize(0)
@IsUUID(4, { each: true })
groupIds: string[];
@IsEnum(UserRole)
role: string;
@ -18,6 +40,19 @@ export class InvitationIdDto {
invitationId: string;
}
export class AcceptInviteDto extends InvitationIdDto {}
export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
name: string;
@MinLength(8)
@IsString()
password: string;
@IsNotEmpty()
@IsString()
token: string;
}
export class RevokeInviteDto extends InvitationIdDto {}

View File

@ -1,106 +1,318 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceService } from './workspace.service';
import { UserService } from '../../user/user.service';
import { WorkspaceUserService } from './workspace-user.service';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import {
Group,
User,
WorkspaceInvitation,
} from '@docmost/db/types/entity.types';
import { MailService } from '../../../integrations/mail/mail.service';
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
import { hashPassword } from '../../../helpers';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { TokenService } from '../../auth/services/token.service';
import { nanoIdGen } from '../../../helpers/nanoid.utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { TokensDto } from '../../auth/dto/tokens.dto';
// need reworking
@Injectable()
export class WorkspaceInvitationService {
private readonly logger = new Logger(WorkspaceInvitationService.name);
constructor(
private workspaceService: WorkspaceService,
private workspaceUserService: WorkspaceUserService,
private userService: UserService,
private userRepo: UserRepo,
private groupUserRepo: GroupUserRepo,
private mailService: MailService,
private environmentService: EnvironmentService,
private tokenService: TokenService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/*
async findInvitedUserByEmail(
email,
workspaceId,
): Promise<WorkspaceInvitation> {
return this.workspaceInvitationRepository.findOneBy({
email: email,
workspaceId: workspaceId,
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'role', 'workspaceId', 'createdAt'])
.where('workspaceId', '=', workspaceId);
if (pagination.query) {
query = query.where((eb) =>
eb('email', 'ilike', `%${pagination.query}%`),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
async getInvitationById(invitationId: string, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'createdAt'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
return invitation;
}
async createInvitation(
authUser: User,
workspaceId: string,
inviteUserDto: InviteUserDto,
): Promise<WorkspaceInvitation> {
// check if invited user is already a workspace member
const invitedUser =
await this.workspaceUserService.findWorkspaceUserByEmail(
inviteUserDto.email,
workspaceId,
);
workspaceId: string,
authUser: User,
): Promise<void> {
const { emails, role, groupIds } = inviteUserDto;
if (invitedUser) {
let invites: WorkspaceInvitation[] = [];
try {
await executeTx(this.db, async (trx) => {
// we do not want to invite existing members
const findExistingUsers = await this.db
.selectFrom('users')
.select(['email'])
.where('users.email', 'in', emails)
.where('users.workspaceId', '=', workspaceId)
.execute();
let existingUserEmails = [];
if (findExistingUsers) {
existingUserEmails = findExistingUsers.map((user) => user.email);
}
// filter out existing users
const inviteEmails = emails.filter(
(email) => !existingUserEmails.includes(email),
);
let validGroups = [];
if (groupIds && groupIds.length > 0) {
validGroups = await trx
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', groupIds)
.where('groups.workspaceId', '=', workspaceId)
.execute();
}
const invitesToInsert = inviteEmails.map((email) => ({
email: email,
role: role,
token: nanoIdGen(16),
workspaceId: workspaceId,
invitedById: authUser.id,
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
invites = await trx
.insertInto('workspaceInvitations')
.values(invitesToInsert)
.onConflict((oc) => oc.columns(['email', 'workspaceId']).doNothing())
.returningAll()
.execute();
});
} catch (err) {
this.logger.error(`createInvitation - ${err}`);
throw new BadRequestException(
'User is already a member of this workspace',
'An error occurred while processing the invitations.',
);
}
// check if user was already invited
const existingInvitation = await this.findInvitedUserByEmail(
inviteUserDto.email,
workspaceId,
);
if (existingInvitation) {
throw new BadRequestException('User has already been invited');
// do not send code to do nothing users
if (invites) {
invites.forEach((invitation: WorkspaceInvitation) => {
this.sendInvitationMail(
invitation.id,
invitation.email,
invitation.token,
authUser.name,
);
});
}
const invitation = new WorkspaceInvitation();
invitation.workspaceId = workspaceId;
invitation.email = inviteUserDto.email;
invitation.role = inviteUserDto.role;
invitation.invitedById = authUser.id;
// TODO: send invitation email
return await this.workspaceInvitationRepository.save(invitation);
}
async acceptInvitation(invitationId: string) {
const invitation = await this.workspaceInvitationRepository.findOneBy({
id: invitationId,
});
if (!invitation) {
throw new BadRequestException('Invalid or expired invitation code');
}
// TODO: to be completed
// check if user is already a member
const invitedUser =
await this.workspaceUserService.findWorkspaceUserByEmail(
invitation.email,
invitation.workspaceId,
);
if (invitedUser) {
throw new BadRequestException(
'User is already a member of this workspace',
);
}
// add create account for user
// add the user to the workspace
return null;
}
async revokeInvitation(invitationId: string): Promise<void> {
const invitation = await this.workspaceInvitationRepository.findOneBy({
id: invitationId,
});
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', dto.invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new BadRequestException('Invitation not found');
}
await this.workspaceInvitationRepository.delete(invitationId);
if (dto.token !== invitation.token) {
throw new BadRequestException('Invalid invitation token');
}
const password = await hashPassword(dto.password);
let newUser: User;
try {
await executeTx(this.db, async (trx) => {
newUser = await trx
.insertInto('users')
.values({
name: dto.name,
email: invitation.email,
password: password,
workspaceId: workspaceId,
role: invitation.role,
lastLoginAt: new Date(),
invitedById: invitation.invitedById,
})
.returningAll()
.executeTakeFirst();
// add user to default group
await this.groupUserRepo.addUserToDefaultGroup(
newUser.id,
workspaceId,
trx,
);
if (invitation.groupIds && invitation.groupIds.length > 0) {
// Ensure the groups are valid
const validGroups = await trx
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', invitation.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.execute();
if (validGroups && validGroups.length > 0) {
const groupUsersToInsert = validGroups.map((group) => ({
userId: newUser.id,
groupId: group.id,
}));
// add user to groups specified during invite
await trx
.insertInto('groupUsers')
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.execute();
}
}
// delete invitation record
await trx
.deleteFrom('workspaceInvitations')
.where('id', '=', invitation.id)
.execute();
});
} catch (err: any) {
this.logger.error(`acceptInvitation - ${err}`);
if (err.message.includes('unique constraint')) {
throw new BadRequestException('Invitation already accepted');
}
throw new BadRequestException(
'Failed to accept invitation. An error occurred.',
);
}
if (!newUser) {
return;
}
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
);
if (invitedByUser) {
const emailTemplate = InvitationAcceptedEmail({
invitedUserName: newUser.name,
invitedUserEmail: newUser.email,
});
await this.mailService.sendToQueue({
to: invitation.email,
subject: `${newUser.name} has accepted your Docmost invite`,
template: emailTemplate,
});
}
const tokens: TokensDto = await this.tokenService.generateTokens(newUser);
return { tokens };
}
*/
async resendInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
//
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new BadRequestException('Invitation not found');
}
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
);
await this.sendInvitationMail(
invitation.id,
invitation.email,
invitation.token,
invitedByUser.name,
);
}
async revokeInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async sendInvitationMail(
invitationId: string,
inviteeEmail: string,
inviteToken: string,
invitedByName: string,
): Promise<void> {
const inviteLink = `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
const emailTemplate = InvitationEmail({
inviteLink,
});
await this.mailService.sendToQueue({
to: inviteeEmail,
subject: `${invitedByName} invited you to Docmost`,
template: emailTemplate,
});
}
}

View File

@ -1,63 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
import { UserRole } from '../../../helpers/types/permission';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
@Injectable()
export class WorkspaceUserService {
constructor(private userRepo: UserRepo) {}
async getWorkspaceUsers(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated(
workspaceId,
pagination,
);
return users;
}
async updateWorkspaceUserRole(
authUser: User,
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (user.role === userRoleDto.role) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: userRoleDto.role,
},
user.id,
workspaceId,
);
}
async deactivateUser(): Promise<any> {
return 'todo';
}
}

View File

@ -8,7 +8,6 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
import { CreateSpaceDto } from '../../space/dto/create-space.dto';
import { SpaceRole, UserRole } from '../../../helpers/types/permission';
import { GroupService } from '../../group/services/group.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';
@ -16,6 +15,11 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class WorkspaceService {
@ -23,8 +27,9 @@ export class WorkspaceService {
private workspaceRepo: WorkspaceRepo,
private spaceService: SpaceService,
private spaceMemberService: SpaceMemberService,
private groupService: GroupService,
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -33,7 +38,6 @@ export class WorkspaceService {
}
async getWorkspaceInfo(workspaceId: string) {
// todo: add member count
const workspace = this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
@ -61,11 +65,10 @@ export class WorkspaceService {
);
// create default group
const group = await this.groupService.createDefaultGroup(
workspace.id,
user.id,
trx,
);
const group = await this.groupRepo.createDefaultGroup(workspace.id, {
userId: user.id,
trx: trx,
});
// add user to workspace
await trx
@ -181,11 +184,54 @@ export class WorkspaceService {
return workspace;
}
async delete(workspaceId: string): Promise<void> {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
async getWorkspaceUsers(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated(
workspaceId,
pagination,
);
return users;
}
async updateWorkspaceUserRole(
authUser: User,
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
//delete
if (user.role === userRoleDto.role) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: userRoleDto.role,
},
user.id,
workspaceId,
);
}
async deactivateUser(): Promise<any> {
return 'todo';
}
}

View File

@ -3,18 +3,12 @@ import { WorkspaceService } from './services/workspace.service';
import { WorkspaceController } from './controllers/workspace.controller';
import { SpaceModule } from '../space/space.module';
import { WorkspaceInvitationService } from './services/workspace-invitation.service';
import { WorkspaceUserService } from './services/workspace-user.service';
import { UserModule } from '../user/user.module';
import { GroupModule } from '../group/group.module';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [SpaceModule, UserModule, GroupModule],
imports: [SpaceModule, TokenModule],
controllers: [WorkspaceController],
providers: [
WorkspaceService,
WorkspaceUserService,
WorkspaceInvitationService,
],
providers: [WorkspaceService, WorkspaceInvitationService],
exports: [WorkspaceService],
})
export class WorkspaceModule {}

View File

@ -0,0 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { customAlphabet } = require('fix-esm').require('nanoid');
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoIdGen = customAlphabet(alphabet, 10);

View File

@ -9,9 +9,21 @@ export class EnvironmentService {
return this.configService.get<string>('NODE_ENV');
}
getAppUrl(): string {
return (
this.configService.get<string>('APP_URL') ||
'http://localhost:' + this.getPort()
);
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT'));
}
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL');
}

View File

@ -7,6 +7,9 @@ export class EnvironmentVariables {
@IsUrl({ protocols: ['postgres', 'postgresql'], require_tld: false })
DATABASE_URL: string;
@IsString()
APP_SECRET: string;
}
export function validate(config: Record<string, any>) {
@ -14,7 +17,13 @@ export function validate(config: Record<string, any>) {
const errors = validateSync(validatedConfig);
if (errors.length > 0) {
throw new Error(errors.toString());
errors.map((error) => {
console.error(error.toString());
});
console.log(
'Please fix the environment variables and try again. Shutting down...',
);
process.exit(1);
}
return validatedConfig;
}

View File

@ -18,8 +18,9 @@ export class MailService {
async sendEmail(message: MailMessage): Promise<void> {
if (message.template) {
// in case this method is used directly
message.html = render(message.template);
// in case this method is used directly. we do not send the tsx template from queue
message.html = render(message.template, { pretty: true });
message.text = render(message.template, { plainText: true });
}
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
@ -29,7 +30,10 @@ export class MailService {
async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) {
// transform the React object because it gets lost when sent via the queue
message.html = render(message.template);
message.html = render(message.template, { pretty: true });
message.text = render(message.template, {
plainText: true,
});
delete message.template;
}
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);

View File

@ -23,7 +23,7 @@ export const paragraph = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: '#333',
lineHeight: 1.5,
lineHeight: 1,
fontSize: 14,
};
@ -51,3 +51,16 @@ export const footer = {
maxWidth: '580px',
margin: '0 auto',
};
export const button = {
backgroundColor: '#176ae5',
borderRadius: '3px',
color: '#fff',
fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
fontSize: '16px',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
width: '100px',
padding: '8px',
};

View File

@ -3,11 +3,11 @@ import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface ChangePasswordEmailProps {
interface Props {
username?: string;
}
export const ChangePasswordEmail = ({ username }: ChangePasswordEmailProps) => {
export const ChangePasswordEmail = ({ username }: Props) => {
return (
<MailBody>
<Section style={content}>

View File

@ -0,0 +1,28 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
invitedUserName: string;
invitedUserEmail: string;
}
export const InvitationAcceptedEmail = ({
invitedUserName,
invitedUserEmail,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
{invitedUserName} ({invitedUserEmail}) has accepted your invitation,
and is now a member of the workspace.
</Text>
</Section>
</MailBody>
);
};
export default InvitationAcceptedEmail;

View File

@ -0,0 +1,37 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
inviteLink: string;
}
export const InvitationEmail = ({ inviteLink }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>You have been invited to Docmost.</Text>
<Text style={paragraph}>
Please click the button below to accept this invitation.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={inviteLink} style={button}>
Accept Invite
</Button>
</Section>
</MailBody>
);
};
export default InvitationEmail;

View File

@ -40,7 +40,7 @@ export function MailFooter() {
<Section style={footer}>
<Row>
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()}, All Rights Reserved <br />
© {new Date().getFullYear()} Docmost, All Rights Reserved <br />
</Text>
</Row>
</Section>

View File

@ -1,34 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_ordering')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('entity_id', 'uuid', (col) => col.notNull())
.addColumn('entity_type', 'varchar', (col) => col.notNull()) // can be page or space
.addColumn('children_ids', sql`uuid[]`, (col) => col.notNull())
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('page_ordering_entity_id_entity_type_unique', [
'entity_id',
'entity_type',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_ordering').execute();
}

View File

@ -0,0 +1,43 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.addColumn('token', 'varchar', (col) => col)
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('status')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addUniqueConstraint('invitation_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.dropColumn('token')
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('group_ids')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addColumn('status', 'varchar', (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropConstraint('invitation_email_workspace_id_unique')
.execute();
}

View File

@ -0,0 +1,14 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
}

View File

@ -1,14 +1,24 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { dbOrTx, executeTx } from '@docmost/db/utils';
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class GroupUserRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly userRepo: UserRepo,
) {}
async getGroupUserById(
userId: string,
@ -62,6 +72,78 @@ export class GroupUserRepo {
return result;
}
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) {
throw new NotFoundException('Group not found');
}
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');
}
const groupUserExists = await this.getGroupUserById(
userId,
groupId,
trx,
);
if (groupUserExists) {
throw new BadRequestException(
'User is already a member of this group',
);
}
await this.insertGroupUser(
{
userId,
groupId,
},
trx,
);
},
trx,
);
}
async addUserToDefaultGroup(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const defaultGroup = await this.groupRepo.getDefaultGroup(
workspaceId,
trx,
);
await this.insertGroupUser(
{
userId,
groupId: defaultGroup.id,
},
trx,
);
},
trx,
);
}
async delete(userId: string, groupId: string): Promise<void> {
await this.db
.deleteFrom('groupUsers')

View File

@ -11,6 +11,7 @@ import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
@Injectable()
export class GroupRepo {
@ -19,9 +20,10 @@ export class GroupRepo {
async findById(
groupId: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
return await this.db
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
@ -33,9 +35,10 @@ export class GroupRepo {
async findByName(
groupName: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
return await this.db
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
@ -85,6 +88,21 @@ export class GroupRepo {
);
}
async createDefaultGroup(
workspaceId: string,
opts?: { userId?: string; trx?: KyselyTransaction },
): Promise<Group> {
const { userId, trx } = opts;
const insertableGroup: InsertableGroup = {
name: DefaultGroup.EVERYONE,
isDefault: true,
creatorId: userId,
workspaceId: workspaceId,
};
return this.insertGroup(insertableGroup, trx);
}
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('groups')

View File

@ -93,7 +93,7 @@ export class UserRepo {
trx?: KyselyTransaction,
): Promise<User> {
const user: InsertableUser = {
name: insertableUser.name || insertableUser.email.split('@')[0],
name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password),
locale: 'en',

View File

@ -1,15 +1,10 @@
import type { ColumnType } from 'kysely';
import type { ColumnType } from "kysely";
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Json = JsonValue;
@ -151,6 +146,7 @@ export interface Users {
email: string;
emailVerifiedAt: Timestamp | null;
id: Generated<string>;
invitedById: string | null;
lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null;
locale: string | null;
@ -167,10 +163,11 @@ export interface Users {
export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>;
email: string;
groupIds: string[] | null;
id: Generated<string>;
invitedById: string | null;
role: string;
status: string | null;
token: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { AuthModule } from '../core/auth/auth.module';
import { TokenModule } from '../core/auth/token.module';
@Module({
imports: [AuthModule],
imports: [TokenModule],
providers: [WsGateway],
})
export class WsModule {}