mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 20:54:07 +08:00
Merge branch 'main' into base
This commit is contained in:
@@ -3,6 +3,7 @@ export enum AttachmentType {
|
||||
WorkspaceIcon = 'workspace-icon',
|
||||
SpaceIcon = 'space-icon',
|
||||
File = 'file',
|
||||
Chat = 'chat',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
@@ -15,4 +16,9 @@ export const inlineFileExtensions = [
|
||||
'.pdf',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.m4a',
|
||||
'.webm',
|
||||
];
|
||||
|
||||
@@ -261,21 +261,29 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
const attachment = await this.attachmentRepo.findById(fileId);
|
||||
if (
|
||||
!attachment ||
|
||||
attachment.workspaceId !== workspace.id ||
|
||||
!attachment.pageId ||
|
||||
!attachment.spaceId
|
||||
) {
|
||||
if (!attachment || attachment.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(attachment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (attachment.aiChatId) {
|
||||
// Chat-owned attachment: only the user who uploaded (and therefore
|
||||
// owns the chat, per AttachmentRepo.claimAttachmentsForChat) can
|
||||
// read it back.
|
||||
if (attachment.creatorId !== user.id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
} else {
|
||||
if (!attachment.pageId || !attachment.spaceId) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
const page = await this.pageRepo.findById(attachment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||
@@ -540,6 +548,10 @@ export class AttachmentController {
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
res.header('Accept-Ranges', 'bytes');
|
||||
res.header(
|
||||
'Content-Security-Policy',
|
||||
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
||||
);
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
|
||||
@@ -71,6 +71,8 @@ export function getAttachmentFolderPath(
|
||||
return `${workspaceId}/space-logos`;
|
||||
case AttachmentType.File:
|
||||
return `${workspaceId}/files`;
|
||||
case AttachmentType.Chat:
|
||||
return `${workspaceId}/chat-files`;
|
||||
default:
|
||||
return `${workspaceId}/files`;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
job.data.pageId,
|
||||
);
|
||||
}
|
||||
if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) {
|
||||
await this.attachmentService.handleDeleteAiChatAttachments(
|
||||
job.data.aiChatId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
|
||||
job.name === QueueJob.ATTACHMENT_INDEXING
|
||||
|
||||
@@ -70,8 +70,8 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
if (
|
||||
existingAttachment.pageId !== pageId &&
|
||||
existingAttachment.fileExt !== preparedFile.fileExtension &&
|
||||
existingAttachment.pageId !== pageId ||
|
||||
existingAttachment.fileExt !== preparedFile.fileExtension ||
|
||||
existingAttachment.workspaceId !== workspaceId
|
||||
) {
|
||||
throw new BadRequestException('File attachment does not match');
|
||||
@@ -289,6 +289,31 @@ export class AttachmentService {
|
||||
);
|
||||
}
|
||||
|
||||
async handleDeleteAiChatAttachments(aiChatId: string) {
|
||||
try {
|
||||
const attachments = await this.attachmentRepo.findByAiChatId(aiChatId);
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
await this.storageService.delete(attachment.filePath);
|
||||
await this.attachmentRepo.deleteAttachmentById(attachment.id);
|
||||
} catch (err) {
|
||||
this.logger.log(
|
||||
`DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteSpaceAttachments(spaceId: string) {
|
||||
try {
|
||||
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum UserTokenType {
|
||||
FORGOT_PASSWORD = 'forgot-password',
|
||||
EMAIL_VERIFICATION = 'email-verification',
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@ import {
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import {
|
||||
AI_CHAT_THROTTLER,
|
||||
AUTH_THROTTLER,
|
||||
} from '../../integrations/throttle/throttler-names';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { SetupGuard } from './guards/setup.guard';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
||||
@@ -22,7 +29,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
@@ -31,12 +38,15 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private sessionService: SessionService,
|
||||
private environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
@@ -108,6 +118,7 @@ export class AuthController {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@SkipThrottle({ [AUTH_THROTTLER]: true })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('change-password')
|
||||
@@ -115,8 +126,15 @@ export class AuthController {
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
return this.authService.changePassword(dto, user.id, workspace.id);
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
return this.authService.changePassword(
|
||||
dto,
|
||||
user.id,
|
||||
workspace.id,
|
||||
currentSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -163,6 +181,7 @@ export class AuthController {
|
||||
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
|
||||
}
|
||||
|
||||
@SkipThrottle({ [AUTH_THROTTLER]: true })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('collab-token')
|
||||
@@ -173,13 +192,24 @@ export class AuthController {
|
||||
return this.authService.getCollabToken(user, workspace.id);
|
||||
}
|
||||
|
||||
@SkipThrottle({ [AUTH_THROTTLER]: true })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('logout')
|
||||
async logout(
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
const sessionId = (req.raw as any).sessionId;
|
||||
if (sessionId) {
|
||||
await this.sessionService.revokeSession(
|
||||
sessionId,
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
res.clearCookie('authToken');
|
||||
|
||||
this.auditService.log({
|
||||
@@ -192,6 +222,7 @@ export class AuthController {
|
||||
setAuthCookie(res: FastifyReply, token: string) {
|
||||
res.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export function computeEmailSignature(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
appSecret: string,
|
||||
): string {
|
||||
return createHmac('sha256', appSecret)
|
||||
.update(`${email.toLowerCase()}:${workspaceId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function throwIfEmailNotVerified(opts: {
|
||||
isCloud: boolean;
|
||||
emailVerifiedAt: Date | null;
|
||||
email: string;
|
||||
workspaceId: string;
|
||||
appSecret: string;
|
||||
}): void {
|
||||
if (!opts.isCloud || opts.emailVerifiedAt) return;
|
||||
|
||||
const emailSignature = computeEmailSignature(
|
||||
opts.email,
|
||||
opts.workspaceId,
|
||||
opts.appSecret,
|
||||
);
|
||||
throw new BadRequestException({
|
||||
message:
|
||||
'Please verify your email address. Check your inbox for the verification link.',
|
||||
emailSignature,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSsoEnforcement(workspace: Workspace) {
|
||||
if (workspace.enforceSso) {
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class CreateAdminUserDto extends CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@NoUrls()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@NoUrls()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@ export enum JwtType {
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
API_KEY = 'api_key',
|
||||
PDF_RENDER = 'pdf_render',
|
||||
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
email: string;
|
||||
workspaceId: string;
|
||||
type: 'access';
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type JwtCollabPayload = {
|
||||
@@ -44,3 +47,15 @@ export type JwtApiKeyPayload = {
|
||||
apiKeyId: string;
|
||||
type: 'api_key';
|
||||
};
|
||||
|
||||
export type JwtPdfRenderPayload = {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_render';
|
||||
};
|
||||
|
||||
export type JwtPdfExportDownloadPayload = {
|
||||
fileTaskId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_export_download';
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { TokenService } from './token.service';
|
||||
import { SessionService } from '../../session/session.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SignupService } from './signup.service';
|
||||
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
isUserDisabled,
|
||||
nanoIdGen,
|
||||
} from '../../../common/helpers';
|
||||
import { throwIfEmailNotVerified } from '../auth.util';
|
||||
import { ChangePasswordDto } from '../dto/change-password.dto';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
|
||||
@@ -36,16 +39,20 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private signupService: SignupService,
|
||||
private tokenService: TokenService,
|
||||
private sessionService: SessionService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private userRepo: UserRepo,
|
||||
private userTokenRepo: UserTokenRepo,
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
private environmentService: EnvironmentService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -69,6 +76,14 @@ export class AuthService {
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
|
||||
throwIfEmailNotVerified({
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
email: user.email,
|
||||
workspaceId,
|
||||
appSecret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||
|
||||
@@ -79,19 +94,19 @@ export class AuthService {
|
||||
metadata: { source: 'password' },
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
return this.sessionService.createSessionAndToken(user);
|
||||
}
|
||||
|
||||
async register(createUserDto: CreateUserDto, workspaceId: string) {
|
||||
const user = await this.signupService.signup(createUserDto, workspaceId);
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
return this.sessionService.createSessionAndToken(user);
|
||||
}
|
||||
|
||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||
const { workspace, user } =
|
||||
await this.signupService.initialSetup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
const authToken = await this.sessionService.createSessionAndToken(user);
|
||||
return { workspace, authToken };
|
||||
}
|
||||
|
||||
@@ -99,6 +114,7 @@ export class AuthService {
|
||||
dto: ChangePasswordDto,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
currentSessionId?: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
includePassword: true,
|
||||
@@ -127,6 +143,16 @@ export class AuthService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (currentSessionId) {
|
||||
await this.userSessionRepo.deleteAllExceptCurrent(
|
||||
currentSessionId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
} else {
|
||||
await this.userSessionRepo.deleteByUserId(userId, workspaceId);
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
@@ -233,6 +259,8 @@ export class AuthService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
|
||||
|
||||
this.auditService.setActorId(user.id);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET,
|
||||
@@ -247,6 +275,14 @@ export class AuthService {
|
||||
template: emailTemplate,
|
||||
});
|
||||
|
||||
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
|
||||
await this.userRepo.updateUser(
|
||||
{ emailVerifiedAt: new Date() },
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has MFA enabled or workspace enforces MFA
|
||||
const userHasMfa = user?.['mfa']?.isEnabled || false;
|
||||
const workspaceEnforcesMfa = workspace.enforceMfa || false;
|
||||
@@ -257,7 +293,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
const authToken = await this.sessionService.createSessionAndToken(user);
|
||||
return { authToken };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
JwtExchangePayload,
|
||||
JwtMfaTokenPayload,
|
||||
JwtPayload,
|
||||
JwtPdfExportDownloadPayload,
|
||||
JwtPdfRenderPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
@@ -24,7 +27,7 @@ export class TokenService {
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -34,6 +37,7 @@ export class TokenService {
|
||||
email: user.email,
|
||||
workspaceId: user.workspaceId,
|
||||
type: JwtType.ACCESS,
|
||||
sessionId,
|
||||
};
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
@@ -96,7 +100,7 @@ export class TokenService {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
expiresIn?: string | number;
|
||||
expiresIn?: StringValue | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
if (isUserDisabled(user)) {
|
||||
@@ -113,6 +117,30 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
}
|
||||
|
||||
async generatePdfRenderToken(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfRenderPayload = {
|
||||
pageId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_RENDER,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '60s' });
|
||||
}
|
||||
|
||||
async generatePdfExportDownloadToken(
|
||||
fileTaskId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfExportDownloadPayload = {
|
||||
fileTaskId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_EXPORT_DOWNLOAD,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnvironmentService } from '../../../integrations/environment/environmen
|
||||
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
@@ -16,6 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private sessionActivityService: SessionActivityService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
@@ -57,6 +61,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if ((payload as JwtPayload).sessionId) {
|
||||
const sessionId = (payload as JwtPayload).sessionId;
|
||||
const session = await this.userSessionRepo.findActiveById(sessionId);
|
||||
if (!session || session.userId !== payload.sub || session.workspaceId !== payload.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
req.raw.sessionId = sessionId;
|
||||
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { TokenService } from './services/token.service';
|
||||
return {
|
||||
secret: environmentService.getAppSecret(),
|
||||
signOptions: {
|
||||
expiresIn: environmentService.getJwtTokenExpiresIn(),
|
||||
expiresIn: environmentService.getJwtTokenExpiresIn() as StringValue,
|
||||
issuer: 'Docmost',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,13 +58,13 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
const comment = await this.commentService.create(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
workspaceId: workspace.id,
|
||||
user,
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
@@ -120,7 +120,7 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
@@ -134,14 +134,14 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
return this.commentService.update(comment, dto, user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
const comment = await this.commentRepo.findById(input.commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
@@ -152,8 +152,7 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Check page-level edit permission first
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
// Check if user is the comment owner
|
||||
const isOwner = comment.creatorId === user.id;
|
||||
@@ -169,7 +168,7 @@ export class CommentController {
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
'You can only delete your own comments',
|
||||
);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
|
||||
@Module({
|
||||
imports: [CollaborationModule],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto';
|
||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||
@@ -27,6 +28,7 @@ export class CommentService {
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private wsService: WsService,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||
private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -45,10 +47,10 @@ export class CommentService {
|
||||
}
|
||||
|
||||
async create(
|
||||
opts: { userId: string; page: Page; workspaceId: string },
|
||||
opts: { page: Page; workspaceId: string; user: User },
|
||||
createCommentDto: CreateCommentDto,
|
||||
) {
|
||||
const { userId, page, workspaceId } = opts;
|
||||
const { page, workspaceId, user } = opts;
|
||||
const commentContent = JSON.parse(createCommentDto.content);
|
||||
|
||||
if (createCommentDto.parentCommentId) {
|
||||
@@ -71,11 +73,39 @@ export class CommentService {
|
||||
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
|
||||
type: createCommentDto.type ?? 'page',
|
||||
parentCommentId: createCommentDto?.parentCommentId,
|
||||
creatorId: userId,
|
||||
creatorId: user.id,
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
if (createCommentDto.yjsSelection) {
|
||||
const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection);
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`,
|
||||
);
|
||||
} else {
|
||||
const documentName = `page.${page.id}`;
|
||||
try {
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'setCommentMark',
|
||||
documentName,
|
||||
{
|
||||
yjsSelection: parsed.data,
|
||||
commentId: inserted.id,
|
||||
resolved: false,
|
||||
user,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.commentRepo.findById(inserted.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
@@ -83,7 +113,7 @@ export class CommentService {
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
userIds: [user.id],
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
@@ -101,7 +131,7 @@ export class CommentService {
|
||||
page.id,
|
||||
page.spaceId,
|
||||
workspaceId,
|
||||
userId,
|
||||
user.id,
|
||||
!isReply,
|
||||
createCommentDto.parentCommentId,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
const yjsIdSchema = z.object({
|
||||
client: z.number().int().nonnegative(),
|
||||
clock: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const yjsRelativePositionSchema = z.object({
|
||||
type: yjsIdSchema,
|
||||
tname: z.string().nullable(),
|
||||
item: yjsIdSchema.nullable(),
|
||||
assoc: z.number().int(),
|
||||
});
|
||||
|
||||
export const yjsSelectionSchema = z.object({
|
||||
anchor: yjsRelativePositionSchema,
|
||||
head: yjsRelativePositionSchema,
|
||||
});
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@@ -18,4 +36,11 @@ export class CreateCommentDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentCommentId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { BaseModule } from './base/base.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@@ -31,6 +33,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
CommentModule,
|
||||
FavoriteModule,
|
||||
SearchModule,
|
||||
SpaceModule,
|
||||
GroupModule,
|
||||
@@ -39,6 +42,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
SessionModule,
|
||||
BaseModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class FavoriteIdsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class AddFavoriteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
export class RemoveFavoriteDto extends AddFavoriteDto {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class ListFavoritesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type?: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto';
|
||||
import { FavoriteIdsDto } from './dto/favorite-ids.dto';
|
||||
import { ListFavoritesDto } from './dto/list-favorites.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { FavoriteType } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('favorites')
|
||||
export class FavoriteController {
|
||||
constructor(
|
||||
private readonly favoriteService: FavoriteService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly templateRepo: TemplateRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('add')
|
||||
async addFavorite(
|
||||
@Body() dto: AddFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const resolved = await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.addFavorite(user.id, workspace.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.type === 'space' ? resolved.spaceId : undefined,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('remove')
|
||||
async removeFavorite(
|
||||
@Body() dto: RemoveFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.removeFavorite(user.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.spaceId,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('ids')
|
||||
async getFavoriteIds(
|
||||
@Body() dto: FavoriteIdsDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.favoriteService.getFavoriteIds(
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto.type as FavoriteType,
|
||||
dto.spaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async getUserFavorites(
|
||||
@Body() dto: ListFavoritesDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.favoriteService.getUserFavorites(
|
||||
user.id,
|
||||
workspace.id,
|
||||
pagination,
|
||||
dto.type as FavoriteType | undefined,
|
||||
dto.spaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAndValidate(
|
||||
dto: AddFavoriteDto | RemoveFavoriteDto,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<{ spaceId: string; page?: Page }> {
|
||||
if (dto.type === 'page') {
|
||||
if (!dto.pageId) throw new BadRequestException('pageId is required');
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) throw new NotFoundException('Page not found');
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
return { spaceId: page.spaceId, page };
|
||||
}
|
||||
|
||||
if (dto.type === 'space') {
|
||||
if (!dto.spaceId) throw new BadRequestException('spaceId is required');
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) throw new NotFoundException('Space not found');
|
||||
await this.validateSpaceAccess(user.id, space.id);
|
||||
return { spaceId: space.id };
|
||||
}
|
||||
|
||||
if (dto.type === 'template') {
|
||||
if (!dto.templateId)
|
||||
throw new BadRequestException('templateId is required');
|
||||
const template = await this.templateRepo.findById(
|
||||
dto.templateId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!template) throw new NotFoundException('Template not found');
|
||||
if (template.spaceId) {
|
||||
await this.validateSpaceAccess(user.id, template.spaceId);
|
||||
}
|
||||
return { spaceId: template.spaceId };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid favorite type');
|
||||
}
|
||||
|
||||
private async validateSpaceAccess(
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<void> {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
if (!userSpaceIds.includes(spaceId)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { FavoriteController } from './favorite.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FavoriteController],
|
||||
providers: [FavoriteService],
|
||||
exports: [FavoriteService],
|
||||
})
|
||||
export class FavoriteModule {}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FavoriteRepo,
|
||||
FavoriteType,
|
||||
} from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { InsertableFavorite } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async getFavoriteIds(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
type: FavoriteType,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const result = await this.favoriteRepo.getFavoriteIds(
|
||||
userId,
|
||||
workspaceId,
|
||||
type,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE) {
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: result.items,
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((id) => accessibleSet.has(id));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.SPACE) {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
result.items = result.items.filter((id) => spaceSet.has(id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async addFavorite(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const favorite: InsertableFavorite = {
|
||||
userId,
|
||||
pageId: opts.pageId ?? null,
|
||||
spaceId: opts.spaceId ?? null,
|
||||
templateId: opts.templateId ?? null,
|
||||
type: opts.type,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
await this.favoriteRepo.insert(favorite);
|
||||
}
|
||||
|
||||
async removeFavorite(
|
||||
userId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.type === FavoriteType.PAGE && opts.pageId) {
|
||||
await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId);
|
||||
} else if (opts.type === FavoriteType.SPACE && opts.spaceId) {
|
||||
await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId);
|
||||
} else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) {
|
||||
await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFavorites(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const result = await this.favoriteRepo.findUserFavorites(
|
||||
userId,
|
||||
workspaceId,
|
||||
pagination,
|
||||
type,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
|
||||
const pageFavorites = result.items.filter(
|
||||
(f) => f.type === FavoriteType.PAGE && f.pageId,
|
||||
);
|
||||
|
||||
let accessiblePageSet: Set<string> | undefined;
|
||||
if (pageFavorites.length > 0) {
|
||||
const pageIds = pageFavorites.map((f) => f.pageId as string);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
accessiblePageSet = new Set(accessibleIds);
|
||||
}
|
||||
|
||||
result.items = result.items.filter((f) => {
|
||||
if (f.type === FavoriteType.PAGE) {
|
||||
return f.pageId && accessiblePageSet?.has(f.pageId);
|
||||
}
|
||||
if (f.type === FavoriteType.SPACE) {
|
||||
return f.spaceId && spaceSet.has(f.spaceId);
|
||||
}
|
||||
if (f.type === FavoriteType.TEMPLATE) {
|
||||
const templateSpaceId = (f as any).template?.spaceId;
|
||||
return !templateSpaceId || spaceSet.has(templateSpaceId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -29,6 +30,7 @@ export class GroupUserService {
|
||||
@Inject(forwardRef(() => GroupService))
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -137,6 +139,12 @@ export class GroupUserService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
@@ -34,6 +35,7 @@ export class GroupService {
|
||||
@Inject(forwardRef(() => GroupUserService))
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -189,6 +191,12 @@ export class GroupService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export class NotificationIdDto {
|
||||
@IsUUID()
|
||||
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
|
||||
@IsOptional()
|
||||
notificationIds?: string[];
|
||||
}
|
||||
|
||||
export class ListNotificationsDto extends PaginationOptions {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['direct', 'updates', 'all'])
|
||||
type?: 'direct' | 'updates' | 'all' = 'all';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,50 @@ export const NotificationType = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
|
||||
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
(typeof NotificationType)[keyof typeof NotificationType];
|
||||
|
||||
export type NotificationSettingKey =
|
||||
| 'page.updated'
|
||||
| 'page.userMention'
|
||||
| 'comment.userMention'
|
||||
| 'comment.created'
|
||||
| 'comment.resolved';
|
||||
|
||||
export const NotificationTypeToSettingKey: Partial<
|
||||
Record<NotificationType, NotificationSettingKey>
|
||||
> = {
|
||||
[NotificationType.PAGE_UPDATED]: 'page.updated',
|
||||
[NotificationType.PAGE_USER_MENTION]: 'page.userMention',
|
||||
[NotificationType.COMMENT_USER_MENTION]: 'comment.userMention',
|
||||
[NotificationType.COMMENT_CREATED]: 'comment.created',
|
||||
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
|
||||
};
|
||||
|
||||
export type NotificationTab = 'direct' | 'updates' | 'all';
|
||||
|
||||
export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
NotificationType.COMMENT_CREATED,
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
NotificationType.PAGE_PERMISSION_GRANTED,
|
||||
];
|
||||
|
||||
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NotificationType.PAGE_UPDATED,
|
||||
];
|
||||
|
||||
export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
|
||||
if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
|
||||
if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
import { NotificationService } from './notification.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { MarkNotificationsReadDto } from './dto/notification.dto';
|
||||
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
@@ -21,10 +20,10 @@ export class NotificationController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async getNotifications(
|
||||
@Body() pagination: PaginationOptions,
|
||||
@Body() dto: ListNotificationsDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.notificationService.findByUserId(user.id, pagination);
|
||||
return this.notificationService.findByUserId(user.id, dto, dto.type);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
@@ -13,6 +15,8 @@ import { PageNotificationService } from './services/page.notification';
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
VerificationNotificationService,
|
||||
PageUpdateEmailRateLimiter,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
IVerificationReconcileJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -24,7 +33,9 @@ export class NotificationProcessor
|
||||
constructor(
|
||||
private readonly commentNotificationService: CommentNotificationService,
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly verificationNotificationService: VerificationNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
@@ -35,12 +46,24 @@ export class NotificationProcessor
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
| IPageUpdateNotificationJob
|
||||
| IPermissionGrantedNotificationJob
|
||||
| IVerificationExpiringNotificationJob
|
||||
| IVerificationExpiredNotificationJob
|
||||
| IVerificationReconcileJob
|
||||
| IPageVerifiedNotificationJob
|
||||
| IApprovalRequestedNotificationJob
|
||||
| IApprovalRejectedNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
|
||||
if (job.name === QueueJob.VERIFICATION_RECONCILE) {
|
||||
await this.runVerificationReconcile();
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = await this.resolveWorkspaceId(job);
|
||||
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||
|
||||
switch (job.name) {
|
||||
@@ -76,6 +99,59 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_UPDATED: {
|
||||
await this.pageNotificationService.processPageUpdate(
|
||||
job.data as IPageUpdateNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_UPDATE_DIGEST: {
|
||||
const { userId } = job.data as unknown as { userId: string };
|
||||
await this.pageNotificationService.processDigest(userId, appUrl);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
|
||||
await this.verificationNotificationService.processVerificationExpiring(
|
||||
job.data as IVerificationExpiringNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
|
||||
await this.verificationNotificationService.processVerificationExpired(
|
||||
job.data as IVerificationExpiredNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processPageVerified(
|
||||
job.data as IPageVerifiedNotificationJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRequested(
|
||||
job.data as IApprovalRequestedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRejected(
|
||||
job.data as IApprovalRejectedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
@@ -86,6 +162,49 @@ export class NotificationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceId(job: Job): Promise<string> {
|
||||
if (
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRING ||
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRED
|
||||
) {
|
||||
const { verificationId } = job.data as { verificationId: string };
|
||||
const row = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.select('workspaceId')
|
||||
.where('id', '=', verificationId)
|
||||
.executeTakeFirst();
|
||||
return row?.workspaceId ?? '';
|
||||
}
|
||||
return (job.data as { workspaceId: string }).workspaceId;
|
||||
}
|
||||
|
||||
private async runVerificationReconcile(): Promise<void> {
|
||||
let eeModule: { PageVerificationSchedulerService?: unknown };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
eeModule = require('../../ee/page-verification/page-verification-scheduler.service');
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const schedulerClass = eeModule.PageVerificationSchedulerService as
|
||||
| (new (...args: unknown[]) => { reconcile(): Promise<void> })
|
||||
| undefined;
|
||||
if (!schedulerClass) return;
|
||||
|
||||
const scheduler = this.moduleRef.get(schedulerClass, { strict: false });
|
||||
if (!scheduler) {
|
||||
this.logger.warn(
|
||||
'VERIFICATION_RECONCILE fired but scheduler service not resolvable',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await scheduler.reconcile();
|
||||
}
|
||||
|
||||
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
|
||||
@@ -6,6 +6,8 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { WsGateway } from '../../ws/ws.gateway';
|
||||
import { MailService } from '../../integrations/mail/mail.service';
|
||||
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -13,12 +15,23 @@ export class NotificationService {
|
||||
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly wsGateway: WsGateway,
|
||||
private readonly mailService: MailService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async create(data: InsertableNotification) {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id'])
|
||||
.where('id', '=', data.userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const notification = await this.notificationRepo.insert(data);
|
||||
|
||||
this.wsGateway.server
|
||||
@@ -28,8 +41,35 @@ export class NotificationService {
|
||||
return notification;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||
return this.notificationRepo.findByUserId(userId, pagination);
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
type: NotificationTab = 'all',
|
||||
) {
|
||||
const result = await this.notificationRepo.findByUserId(
|
||||
userId,
|
||||
pagination,
|
||||
type,
|
||||
);
|
||||
|
||||
const pageIds = result.items
|
||||
.map((n: any) => n.pageId)
|
||||
.filter(Boolean);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
const accessiblePageIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessiblePageIds);
|
||||
|
||||
result.items = result.items.filter(
|
||||
(n: any) => !n.pageId || accessibleSet.has(n.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
@@ -53,17 +93,27 @@ export class NotificationService {
|
||||
notificationId: string,
|
||||
subject: string,
|
||||
template: any,
|
||||
type?: NotificationType,
|
||||
) {
|
||||
try {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.select(['email', 'settings'])
|
||||
.where('id', '=', userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.email) return;
|
||||
|
||||
if (type) {
|
||||
const settingKey = NotificationTypeToSettingKey[type];
|
||||
if (settingKey) {
|
||||
const settings = user.settings as any;
|
||||
if (settings?.notifications?.[settingKey] === false) return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
subject,
|
||||
|
||||
@@ -86,12 +86,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} mentioned you in a comment`,
|
||||
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
);
|
||||
|
||||
notifiedUserIds.add(userId);
|
||||
@@ -110,12 +112,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
recipientId,
|
||||
notification.id,
|
||||
`${actor.name} commented on ${pageTitle}`,
|
||||
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_CREATED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +175,7 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) return;
|
||||
|
||||
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||
|
||||
@@ -179,6 +184,7 @@ export class CommentNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
const KEY_PREFIX = 'page-update:emails:';
|
||||
const DIGEST_PREFIX = 'page-update:digest:';
|
||||
const TTL_SECONDS = 86400; // 24 hours
|
||||
const MAX_IMMEDIATE_EMAILS = 4;
|
||||
|
||||
@Injectable()
|
||||
export class PageUpdateEmailRateLimiter {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
async canSendEmail(userId: string): Promise<boolean> {
|
||||
const key = KEY_PREFIX + userId;
|
||||
const count = await this.redis.incr(key);
|
||||
await this.redis.expire(key, TTL_SECONDS, 'NX');
|
||||
return count <= MAX_IMMEDIATE_EMAILS;
|
||||
}
|
||||
|
||||
async addToDigest(userId: string, notificationId: string): Promise<boolean> {
|
||||
const key = DIGEST_PREFIX + userId;
|
||||
const len = await this.redis.rpush(key, notificationId);
|
||||
await this.redis.expire(key, TTL_SECONDS);
|
||||
return len === 1;
|
||||
}
|
||||
|
||||
async popDigest(userId: string): Promise<string[]> {
|
||||
const key = DIGEST_PREFIX + userId;
|
||||
const [ids] = await this.redis
|
||||
.multi()
|
||||
.lrange(key, 0, -1)
|
||||
.del(key)
|
||||
.exec();
|
||||
|
||||
return (ids?.[1] as string[]) ?? [];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PageUpdateEmailRateLimiter } from './page-update-email-rate-limiter';
|
||||
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||
import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
|
||||
import { PageUpdateDigestEmail } from '@docmost/transactional/emails/page-update-digest-email';
|
||||
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
|
||||
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
|
||||
const DIGEST_DELAY_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
@Injectable()
|
||||
export class PageNotificationService {
|
||||
private readonly logger = new Logger(PageNotificationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly rateLimiter: PageUpdateEmailRateLimiter,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
) {}
|
||||
|
||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||
@@ -41,10 +59,9 @@ export class PageNotificationService {
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...usersWithSpaceAccess,
|
||||
]);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
|
||||
const accessibleMentions = newMentions.filter((m) =>
|
||||
@@ -97,6 +114,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { mentionId },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const pageUrl = `${basePageUrl}`;
|
||||
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||
@@ -106,6 +124,7 @@ export class PageNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +158,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { role },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
|
||||
|
||||
@@ -156,6 +176,237 @@ export class PageNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
||||
const { pageId, spaceId, workspaceId, actorIds } = data;
|
||||
|
||||
const watcherIds = await this.watcherRepo.getPageUpdateRecipientIds(
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (watcherIds.length === 0) return;
|
||||
|
||||
const actorSet = new Set(actorIds);
|
||||
const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
|
||||
if (candidateIds.length === 0) return;
|
||||
|
||||
const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
|
||||
if (eligibleUsers.size === 0) return;
|
||||
|
||||
const afterPrefs = [...eligibleUsers.keys()];
|
||||
|
||||
const recentlyNotified =
|
||||
await this.notificationRepo.getRecentlyNotifiedUserIds(
|
||||
afterPrefs,
|
||||
pageId,
|
||||
NotificationType.PAGE_UPDATED,
|
||||
PAGE_UPDATE_COOLDOWN_HOURS,
|
||||
);
|
||||
const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
|
||||
if (afterCooldown.length === 0) return;
|
||||
|
||||
const usersWithSpaceAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
afterCooldown,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...usersWithSpaceAccess,
|
||||
]);
|
||||
if (usersWithPageAccess.length === 0) return;
|
||||
|
||||
const recipientIds = new Set(usersWithPageAccess);
|
||||
const actorId = actorIds[0];
|
||||
|
||||
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, basePageUrl, spaceName } = context;
|
||||
|
||||
for (const userId of recipientIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_UPDATED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const canSend = await this.rateLimiter.canSendEmail(userId);
|
||||
if (canSend) {
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} updated ${pageTitle}`,
|
||||
PageUpdateEmail({
|
||||
userName: eligibleUsers.get(userId) ?? '',
|
||||
actorName: actor.name,
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
spaceName,
|
||||
}),
|
||||
NotificationType.PAGE_UPDATED,
|
||||
);
|
||||
} else {
|
||||
const isFirst = await this.rateLimiter.addToDigest(
|
||||
userId,
|
||||
notification.id,
|
||||
);
|
||||
if (isFirst) {
|
||||
await this.scheduleDigest(userId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getEligiblePageUpdateUsers(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const users = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'settings'])
|
||||
.where('id', 'in', userIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
const eligible = new Map<string, string>();
|
||||
for (const u of users) {
|
||||
const settings = u.settings as any;
|
||||
if (settings?.notifications?.['page.updated'] !== false) {
|
||||
eligible.set(u.id, u.name);
|
||||
}
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private async scheduleDigest(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.notificationQueue
|
||||
.add(
|
||||
QueueJob.PAGE_UPDATE_DIGEST,
|
||||
{ userId, workspaceId },
|
||||
{ delay: DIGEST_DELAY_MS, removeOnComplete: true },
|
||||
)
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
`Failed to schedule digest for ${userId}: ${err.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async processDigest(userId: string, appUrl: string): Promise<void> {
|
||||
const notificationIds = await this.rateLimiter.popDigest(userId);
|
||||
if (notificationIds.length === 0) return;
|
||||
|
||||
const [user, notifications] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('notifications')
|
||||
.select(['id', 'pageId', 'actorId'])
|
||||
.where('id', 'in', notificationIds)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
if (!user || notifications.length === 0) return;
|
||||
|
||||
const pageIds = [
|
||||
...new Set(notifications.map((n) => n.pageId).filter(Boolean)),
|
||||
];
|
||||
const actorIds = [
|
||||
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
|
||||
];
|
||||
|
||||
const allPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('spaces', 'spaces.id', 'pages.spaceId')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.title',
|
||||
'pages.slugId',
|
||||
'pages.spaceId',
|
||||
'spaces.slug as spaceSlug',
|
||||
])
|
||||
.where('pages.id', 'in', pageIds)
|
||||
.execute();
|
||||
|
||||
if (allPages.length === 0) return;
|
||||
|
||||
const spaceIds = [...new Set(allPages.map((p) => p.spaceId))];
|
||||
|
||||
const accessibleSpaceIds = new Set<string>();
|
||||
for (const spaceId of spaceIds) {
|
||||
const usersWithAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess([userId], spaceId);
|
||||
if (usersWithAccess.has(userId)) accessibleSpaceIds.add(spaceId);
|
||||
}
|
||||
|
||||
const spaceFilteredPages = allPages.filter((p) =>
|
||||
accessibleSpaceIds.has(p.spaceId),
|
||||
);
|
||||
if (spaceFilteredPages.length === 0) return;
|
||||
|
||||
const accessiblePageIds = new Set<string>();
|
||||
for (const p of spaceFilteredPages) {
|
||||
const hasAccess = await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
p.id,
|
||||
[userId],
|
||||
);
|
||||
if (hasAccess.includes(userId)) accessiblePageIds.add(p.id);
|
||||
}
|
||||
|
||||
const pages = spaceFilteredPages.filter((p) => accessiblePageIds.has(p.id));
|
||||
if (pages.length === 0) return;
|
||||
|
||||
const actors = actorIds.length > 0
|
||||
? await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('id', 'in', actorIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const actorMap = new Map(actors.map((a) => [a.id, a.name]));
|
||||
const pageActors = new Map<string, Set<string>>();
|
||||
for (const n of notifications) {
|
||||
if (!n.pageId || !n.actorId) continue;
|
||||
const names = pageActors.get(n.pageId) ?? new Set();
|
||||
const name = actorMap.get(n.actorId);
|
||||
if (name) names.add(name);
|
||||
pageActors.set(n.pageId, names);
|
||||
}
|
||||
|
||||
const pageUpdates = pages.map((p) => ({
|
||||
title: getPageTitle(p.title),
|
||||
url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`,
|
||||
updatedBy: [...(pageActors.get(p.id) ?? [])],
|
||||
}));
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notificationIds[0],
|
||||
`Your digest: ${pageUpdates.length} page ${pageUpdates.length === 1 ? 'update' : 'updates'}`,
|
||||
PageUpdateDigestEmail({
|
||||
userName: user.name,
|
||||
pageUpdates,
|
||||
totalUpdates: pageUpdates.length,
|
||||
}),
|
||||
NotificationType.PAGE_UPDATED,
|
||||
);
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
@@ -175,7 +426,7 @@ export class PageNotificationService {
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug'])
|
||||
.select(['id', 'slug', 'name'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
@@ -186,6 +437,11 @@ export class PageNotificationService {
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
|
||||
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
|
||||
return {
|
||||
actor,
|
||||
pageTitle: getPageTitle(page.title),
|
||||
basePageUrl,
|
||||
spaceName: space.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email';
|
||||
import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email';
|
||||
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
|
||||
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
private async getAlreadyNotifiedUserIds(
|
||||
pageVerificationId: string,
|
||||
type: string,
|
||||
): Promise<Set<string>> {
|
||||
const rows = await this.db
|
||||
.selectFrom('notifications')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', pageVerificationId)
|
||||
.where('type', '=', type)
|
||||
.execute();
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
private async filterAccessibleRecipients(
|
||||
userIds: string[],
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
): Promise<string[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
);
|
||||
if (inSpace.size === 0) return [];
|
||||
return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...inSpace,
|
||||
]);
|
||||
}
|
||||
|
||||
async processVerificationExpiring(
|
||||
data: IVerificationExpiringNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
const expiresAtMs = new Date(verification.expiresAt).getTime();
|
||||
if (expiresAtMs <= Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const expiresAtIso = new Date(verification.expiresAt).toISOString();
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
data: { expiresAt: expiresAtIso },
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs to be re-verified soon`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiringEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processVerificationExpired(
|
||||
data: IVerificationExpiredNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
if (new Date(verification.expiresAt).getTime() > Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" verification has expired`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiredEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processPageVerified(data: IPageVerifiedNotificationJob) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_VERIFIED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRequested(
|
||||
data: IApprovalRequestedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REQUESTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs your approval`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRequestedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRejected(
|
||||
data: IApprovalRejectedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
|
||||
data;
|
||||
|
||||
const recipients = await this.filterAccessibleRecipients(
|
||||
[requestedById],
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: requestedById,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REJECTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" was returned for revision`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
requestedById,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRejectedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
comment,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserName(userId: string): Promise<string> {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select('name')
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return user?.name ?? 'Someone';
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const [page, space] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title', 'slugId'])
|
||||
.where('id', '=', pageId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug', 'name'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!page || !space) return null;
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreatedByUserDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { SpaceIdDto } from './page.dto';
|
||||
|
||||
export class SidebarPageDto {
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
|
||||
@Injectable()
|
||||
export class PageAccessService {
|
||||
constructor(
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -99,4 +101,25 @@ export class PageAccessService {
|
||||
|
||||
return { hasRestriction: hasAnyRestriction };
|
||||
}
|
||||
|
||||
async validateCanComment(
|
||||
page: Page,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.validateCanEdit(page, user);
|
||||
return;
|
||||
} catch {
|
||||
// User cannot edit — check if reader commenting is enabled
|
||||
}
|
||||
|
||||
await this.validateCanView(page, user);
|
||||
|
||||
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
|
||||
const settings = space?.settings as Record<string, any> | null;
|
||||
if (!settings?.comments?.allowViewerComments) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import {
|
||||
@@ -336,6 +337,29 @@ export class PageController {
|
||||
return this.pageService.getRecentPages(user.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('created-by-user')
|
||||
async getCreatedByPages(
|
||||
@Body() dto: CreatedByUserDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const targetUserId = dto.userId ?? user.id;
|
||||
|
||||
if (dto.spaceId) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
dto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('trash')
|
||||
async getDeletedPages(
|
||||
|
||||
@@ -47,6 +47,10 @@ import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import {
|
||||
INTERNAL_LINK_REGEX,
|
||||
extractPageSlugId,
|
||||
} from '../../../integrations/export/utils';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
@@ -296,7 +300,7 @@ export class PageService {
|
||||
}
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: 200,
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
@@ -448,6 +452,20 @@ export class PageService {
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update page verifications
|
||||
await trx
|
||||
.updateTable('pageVerifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update notifications — access follows the page after a move
|
||||
await trx
|
||||
.updateTable('notifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
@@ -510,6 +528,11 @@ export class PageService {
|
||||
});
|
||||
});
|
||||
|
||||
const slugIdMap = new Map<string, CopyPageMapEntry>();
|
||||
for (const [, entry] of pageMap) {
|
||||
slugIdMap.set(entry.oldSlugId, entry);
|
||||
}
|
||||
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment>();
|
||||
|
||||
const insertablePages: InsertablePage[] = await Promise.all(
|
||||
@@ -576,6 +599,28 @@ export class PageService {
|
||||
node.attrs.slugId = mappedPage.newSlugId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update internal page links in link marks
|
||||
for (const mark of node.marks) {
|
||||
if (
|
||||
mark.type.name === 'link' &&
|
||||
mark.attrs.internal &&
|
||||
mark.attrs.href
|
||||
) {
|
||||
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
|
||||
if (match) {
|
||||
const slugId = extractPageSlugId(match[5]);
|
||||
if (slugId && slugIdMap.has(slugId)) {
|
||||
const mappedPage = slugIdMap.get(slugId);
|
||||
//@ts-ignore
|
||||
mark.attrs.href = mark.attrs.href.replace(
|
||||
slugId,
|
||||
mappedPage.newSlugId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
@@ -825,6 +870,33 @@ export class PageService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
const result = await this.pageRepo.getCreatedByPages(
|
||||
creatorId,
|
||||
requestingUserId,
|
||||
pagination,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId: requestingUserId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getDeletedSpacePages(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -91,9 +91,15 @@ export class SearchService {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||
if (isRestricted) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const pageIdsToSearch = [];
|
||||
if (share.includeSubPages) {
|
||||
const pageList = await this.pageRepo.getPageAndDescendants(
|
||||
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
|
||||
share.pageId,
|
||||
{
|
||||
includeContent: false,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeSessionDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
|
||||
const THROTTLE_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
@Injectable()
|
||||
export class SessionActivityService {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
trackActivity(sessionId: string, userId: string, workspaceId: string): void {
|
||||
const key = `session:activity:${sessionId}`;
|
||||
|
||||
this.redis
|
||||
.set(key, '1', 'EX', THROTTLE_SECONDS, 'NX')
|
||||
.then((result) => {
|
||||
if (result === null) return; // key already exists, throttled
|
||||
|
||||
this.userSessionRepo.updateLastActiveAt(sessionId).catch(() => {});
|
||||
this.userRepo
|
||||
.updateUser({ lastActiveAt: new Date() }, userId, workspaceId)
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { RevokeSessionDto } from './dto/revoke-session.dto';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sessions')
|
||||
export class SessionController {
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async listSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId ?? null;
|
||||
const sessions = await this.sessionService.getActiveSessions(
|
||||
user.id,
|
||||
workspace.id,
|
||||
currentSessionId,
|
||||
);
|
||||
return { sessions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revokeSession(
|
||||
@Body() dto: RevokeSessionDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (dto.sessionId === currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot revoke current session. Use logout instead.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeSession(
|
||||
dto.sessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke-all')
|
||||
async revokeAllSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (!currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Current session not found. Please log in again.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeAllOtherSessions(
|
||||
currentSessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { SessionActivityService } from './session-activity.service';
|
||||
import { SessionController } from './session.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [SessionController],
|
||||
providers: [SessionService, SessionActivityService],
|
||||
exports: [SessionService, SessionActivityService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import {
|
||||
AuditContext,
|
||||
AUDIT_CONTEXT_KEY,
|
||||
} from '../../common/middlewares/audit-context.middleware';
|
||||
import * as Bowser from 'bowser';
|
||||
|
||||
const MAX_SESSIONS_PER_USER = 25;
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
@Interval('session-cleanup', 24 * 60 * 60 * 1000)
|
||||
async cleanupSessions() {
|
||||
try {
|
||||
await this.userSessionRepo.deleteStale(RETENTION_DAYS);
|
||||
await this.userSessionRepo.trimExcessSessions(MAX_SESSIONS_PER_USER);
|
||||
this.logger.debug('Session cleanup completed');
|
||||
} catch (err) {
|
||||
this.logger.error('Session cleanup failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async createSessionAndToken(user: User): Promise<string> {
|
||||
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
const ipAddress = auditContext?.ipAddress ?? null;
|
||||
const userAgent = auditContext?.userAgent ?? null;
|
||||
|
||||
const deviceName = this.parseDeviceName(userAgent);
|
||||
const expiresAt = this.environmentService.getCookieExpiresIn();
|
||||
|
||||
const session = await this.userSessionRepo.insertSession({
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
deviceName,
|
||||
ipAddress,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user, session.id);
|
||||
}
|
||||
|
||||
async getActiveSessions(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
currentSessionId: string | null,
|
||||
) {
|
||||
const sessions = await this.userSessionRepo.findActiveByUser(
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const mapped = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
deviceName: s.deviceName,
|
||||
geoLocation: s.geoLocation,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
createdAt: s.createdAt,
|
||||
isCurrentDevice: s.id === currentSessionId,
|
||||
}));
|
||||
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isCurrentDevice) return -1;
|
||||
if (b.isCurrentDevice) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeById(sessionId, userId, workspaceId);
|
||||
}
|
||||
|
||||
async revokeAllOtherSessions(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeAllExceptCurrent(
|
||||
currentSessionId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private parseDeviceName(userAgent: string | null): string | null {
|
||||
if (!userAgent) return null;
|
||||
|
||||
try {
|
||||
const parsed = Bowser.parse(userAgent);
|
||||
|
||||
const os = parsed.os?.name;
|
||||
const browser = parsed.browser?.name;
|
||||
const platformType = parsed.platform?.type;
|
||||
|
||||
if (platformType === 'mobile' || platformType === 'tablet') {
|
||||
return parsed.platform?.model || os || 'Mobile Device';
|
||||
}
|
||||
|
||||
if (os) {
|
||||
return browser ? `${browser} on ${os}` : os;
|
||||
}
|
||||
|
||||
return browser || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { hasLicenseOrEE } from '../../common/helpers';
|
||||
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -45,7 +44,7 @@ export class ShareController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly licenseCheckService: LicenseCheckService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -81,11 +80,10 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...shareData,
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
workspace.licenseKey,
|
||||
workspace.plan,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,11 +257,10 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...treeData,
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
workspace.licenseKey,
|
||||
workspace.plan,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ export class SpaceMemberService {
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -272,6 +274,12 @@ export class SpaceMemberService {
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
affectedUserIds,
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Space, User } from '@docmost/db/types/entity.types';
|
||||
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Feature } from '../../../common/features';
|
||||
import { SpaceMemberService } from './space-member.service';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
@@ -133,17 +134,34 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||
if (
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
|
||||
) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.SECURITY_SETTINGS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined' &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.VIEWER_COMMENTS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +197,22 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.allowViewerComments !== 'undefined') {
|
||||
const prev = settingsBefore?.comments?.allowViewerComments ?? false;
|
||||
if (prev !== updateSpaceDto.allowViewerComments) {
|
||||
before.allowViewerComments = prev;
|
||||
after.allowViewerComments = updateSpaceDto.allowViewerComments;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateCommentSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'allowViewerComments',
|
||||
updateSpaceDto.allowViewerComments,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -53,7 +53,41 @@ export class SpaceController {
|
||||
pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.spaceMemberService.getUserSpaces(user.id, pagination);
|
||||
const result = await this.spaceMemberService.getUserSpaces(
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const spaceIds = result.items.map((s) => s.id);
|
||||
const roles = await this.spaceMemberRepo.getUserRolesForSpaces(
|
||||
user.id,
|
||||
spaceIds,
|
||||
);
|
||||
|
||||
const roleMap = new Map<string, string[]>();
|
||||
for (const row of roles) {
|
||||
const existing = roleMap.get(row.spaceId) || [];
|
||||
existing.push(row.role);
|
||||
roleMap.set(row.spaceId, existing);
|
||||
}
|
||||
|
||||
result.items = result.items.map((space) => {
|
||||
const spaceRoles = roleMap.get(space.id);
|
||||
const role = spaceRoles
|
||||
? findHighestUserSpaceRole(
|
||||
spaceRoles.map((r) => ({ userId: user.id, role: r })),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...space,
|
||||
membership: { userId: user.id, role },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -35,4 +35,24 @@ export class UpdateUserDto extends PartialType(
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationPageUpdates: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationPageUserMention: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentUserMention: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentCreated: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentResolved: boolean;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export class UserController {
|
||||
const workspaceInfo = {
|
||||
...rest,
|
||||
memberCount,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
|
||||
return { user: authUser, workspace: workspaceInfo };
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { NotificationSettingKey } from '../notification/notification.constants';
|
||||
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
||||
@@ -60,6 +61,24 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
const notificationSettings: Record<string, NotificationSettingKey> = {
|
||||
notificationPageUpdates: 'page.updated',
|
||||
notificationPageUserMention: 'page.userMention',
|
||||
notificationCommentUserMention: 'comment.userMention',
|
||||
notificationCommentCreated: 'comment.created',
|
||||
notificationCommentResolved: 'comment.resolved',
|
||||
};
|
||||
|
||||
for (const [dtoField, settingKey] of Object.entries(notificationSettings)) {
|
||||
if (typeof updateUserDto[dtoField] !== 'undefined') {
|
||||
return this.userRepo.updateNotificationSetting(
|
||||
userId,
|
||||
settingKey,
|
||||
updateUserDto[dtoField],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
||||
|
||||
if (updateUserDto.name) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SpaceWatcherDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
spaceId: string;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { SpaceWatcherDto } from './dto/space-watcher.dto';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('spaces')
|
||||
export class SpaceWatcherController {
|
||||
constructor(
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
private async loadSpaceAndAuthorize(
|
||||
spaceId: string,
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspace.id);
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, space.id);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return space;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('watched-ids')
|
||||
async getWatchedSpaceIds(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.watcherService.getWatchedSpaceIds(user.id, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('watch')
|
||||
async watchSpace(
|
||||
@Body() dto: SpaceWatcherDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||
|
||||
await this.watcherService.watchSpace(user.id, space.id, workspace.id);
|
||||
|
||||
return { watching: true };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('unwatch')
|
||||
async unwatchSpace(
|
||||
@Body() dto: SpaceWatcherDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||
|
||||
await this.watcherService.unwatchSpace(user.id, space.id);
|
||||
|
||||
return { watching: false };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('watch-status')
|
||||
async getWatchStatus(
|
||||
@Body() dto: SpaceWatcherDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
|
||||
|
||||
const watching = await this.watcherService.isWatchingSpace(
|
||||
user.id,
|
||||
space.id,
|
||||
);
|
||||
|
||||
return { watching };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
/***
|
||||
import {
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
@@ -16,12 +14,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WatcherPageDto } from './dto/watcher.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -29,7 +22,7 @@ export class WatcherController {
|
||||
constructor(
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -44,10 +37,7 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.watchPage(
|
||||
user.id,
|
||||
@@ -67,12 +57,14 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.unwatchPage(user.id, page.id);
|
||||
await this.watcherService.unwatchPage(
|
||||
user.id,
|
||||
page.id,
|
||||
page.spaceId,
|
||||
page.workspaceId,
|
||||
);
|
||||
|
||||
return { watching: false };
|
||||
}
|
||||
@@ -85,15 +77,10 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||
|
||||
return { watching };
|
||||
}
|
||||
|
||||
}
|
||||
***/
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { CaslModule } from '../casl/casl.module';
|
||||
import { WatcherController } from './watcher.controller';
|
||||
import { SpaceWatcherController } from './space-watcher.controller';
|
||||
import { PageAccessModule } from '../page/page-access/page-access.module';
|
||||
|
||||
@Module({
|
||||
imports: [CaslModule],
|
||||
controllers: [],
|
||||
imports: [PageAccessModule],
|
||||
controllers: [WatcherController, SpaceWatcherController],
|
||||
providers: [WatcherService],
|
||||
exports: [WatcherService],
|
||||
})
|
||||
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InsertableWatcher } from '@docmost/db/types/entity.types';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class WatcherService {
|
||||
constructor(private readonly watcherRepo: WatcherRepo) {}
|
||||
constructor(
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async watchPage(
|
||||
userId: string,
|
||||
@@ -50,14 +54,62 @@ export class WatcherService {
|
||||
return this.watcherRepo.insertMany(watchers, trx);
|
||||
}
|
||||
|
||||
async unwatchPage(userId: string, pageId: string) {
|
||||
return this.watcherRepo.mute(userId, pageId);
|
||||
async unwatchPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
return this.watcherRepo.mute(userId, pageId, spaceId, workspaceId);
|
||||
}
|
||||
|
||||
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
|
||||
return this.watcherRepo.isWatching(userId, pageId);
|
||||
}
|
||||
|
||||
async watchSpace(
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const watcher: InsertableWatcher = {
|
||||
userId,
|
||||
pageId: null,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
type: WatcherType.SPACE,
|
||||
addedById: userId,
|
||||
};
|
||||
return this.watcherRepo.upsertSpace(watcher, trx);
|
||||
}
|
||||
|
||||
async unwatchSpace(userId: string, spaceId: string) {
|
||||
return this.watcherRepo.deleteSpaceWatch(userId, spaceId);
|
||||
}
|
||||
|
||||
async getWatchedSpaceIds(userId: string, workspaceId: string) {
|
||||
const result = await this.watcherRepo.getWatchedSpaceIds(userId, workspaceId);
|
||||
|
||||
const spaceIds = result.items.map((r) => r.spaceId);
|
||||
|
||||
if (spaceIds.length === 0) {
|
||||
return { items: spaceIds, meta: result.meta };
|
||||
}
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
|
||||
return {
|
||||
items: spaceIds.filter((id) => spaceSet.has(id)),
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
|
||||
return this.watcherRepo.isWatchingSpace(userId, spaceId);
|
||||
}
|
||||
|
||||
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||
return this.watcherRepo.findPageWatchers(pageId, pagination);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace')
|
||||
@@ -42,7 +44,9 @@ export class WorkspaceController {
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -58,6 +62,23 @@ export class WorkspaceController {
|
||||
return this.workspaceService.getWorkspaceInfo(workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('entitlements')
|
||||
async getEntitlements(@AuthWorkspace() workspace: Workspace) {
|
||||
let { licenseKey } = workspace;
|
||||
const { plan } = workspace;
|
||||
|
||||
if (!licenseKey) {
|
||||
licenseKey = await this.workspaceRepo.findLicenseKeyById(workspace.id);
|
||||
}
|
||||
|
||||
return {
|
||||
cloud: this.environmentService.isCloud(),
|
||||
tier: this.licenseCheckService.resolveTier(licenseKey, plan),
|
||||
features: this.licenseCheckService.resolveFeatures(licenseKey, plan),
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async updateWorkspace(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsArray()
|
||||
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(60)
|
||||
@IsString()
|
||||
@NoUrls()
|
||||
name: string;
|
||||
|
||||
@MinLength(8)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
import { IsEnum, IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
export class UpdateWorkspaceUserRoleDto {
|
||||
@IsNotEmpty()
|
||||
@@ -6,6 +7,6 @@ export class UpdateWorkspaceUserRoleDto {
|
||||
userId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsEnum(UserRole)
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,16 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
mcpEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiChat: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||
import { TokenService } from '../../auth/services/token.service';
|
||||
import { SessionService } from '../../session/session.service';
|
||||
import { nanoIdGen } from '../../../common/helpers';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
@@ -49,6 +50,7 @@ export class WorkspaceInvitationService {
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
private tokenService: TokenService,
|
||||
private sessionService: SessionService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@@ -350,7 +352,7 @@ export class WorkspaceInvitationService {
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(newUser);
|
||||
const authToken = await this.sessionService.createSessionAndToken(newUser);
|
||||
return { authToken };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
|
||||
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { SpaceService } from '../../space/services/space.service';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Feature } from '../../../common/features';
|
||||
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';
|
||||
@@ -40,6 +42,7 @@ import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -62,11 +65,13 @@ export class WorkspaceService {
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
private shareRepo: ShareRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -85,7 +90,7 @@ export class WorkspaceService {
|
||||
async getWorkspacePublicData(workspaceId: string) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey', 'plan'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -106,12 +111,9 @@ export class WorkspaceService {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
const { licenseKey, plan, ...rest } = workspace;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -142,7 +144,7 @@ export class WorkspaceService {
|
||||
status = WorkspaceStatus.Active;
|
||||
plan = 'standard';
|
||||
billingEmail = user.email;
|
||||
settings = { ai: { generative: true } };
|
||||
settings = { ai: { generative: true, chat: true } };
|
||||
}
|
||||
|
||||
// create workspace
|
||||
@@ -244,7 +246,7 @@ export class WorkspaceService {
|
||||
await this.billingQueue.add(
|
||||
QueueJob.WELCOME_EMAIL,
|
||||
{ userId: user.id },
|
||||
{ delay: 60 * 1000 }, // 1m
|
||||
{ delay: 30 * 60 * 1000 }, // 30m
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
@@ -328,18 +330,38 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'trashRetentionDays'])
|
||||
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
if (!ws) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'mcp', ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -440,11 +462,41 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined') {
|
||||
const prev = settingsBefore?.templates?.allowMemberTemplates ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowMemberTemplates) {
|
||||
before.allowMemberTemplates = prev;
|
||||
after.allowMemberTemplates = updateWorkspaceDto.allowMemberTemplates;
|
||||
}
|
||||
await this.workspaceRepo.updateTemplateSettings(
|
||||
workspaceId,
|
||||
'allowMemberTemplates',
|
||||
updateWorkspaceDto.allowMemberTemplates,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiChat !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.chat ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiChat) {
|
||||
before.aiChat = prev;
|
||||
after.aiChat = updateWorkspaceDto.aiChat;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'chat',
|
||||
updateWorkspaceDto.aiChat,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
@@ -503,10 +555,7 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
@@ -655,11 +704,15 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_DEACTIVATED,
|
||||
@@ -773,6 +826,12 @@ export class WorkspaceService {
|
||||
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
|
||||
Reference in New Issue
Block a user