(parsed.unit);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ const days = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
+ const { amount, unit } = daysToRetention(days);
+ setRetentionAmount(amount);
+ setRetentionUnit(unit);
+ }, [workspace?.trashRetentionDays]);
+
+ const handleSave = async () => {
+ const num = typeof retentionAmount === "number" ? retentionAmount : 1;
+ const clamped = Math.max(1, num);
+ setRetentionAmount(clamped);
+ const days = retentionToDays(clamped, retentionUnit);
+
+ if (days === currentDays) return;
+
+ setSaving(true);
+ try {
+ const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
+ setWorkspace(updatedWorkspace);
+ notifications.show({
+ message: t("Trash retention updated"),
+ });
+ } catch (err: any) {
+ notifications.show({
+ message: err?.response?.data?.message || t("Failed to update trash retention"),
+ color: "red",
+ });
+ const { amount, unit } = daysToRetention(currentDays);
+ setRetentionAmount(amount);
+ setRetentionUnit(unit);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const isDirty = retentionToDays(
+ typeof retentionAmount === "number" ? retentionAmount : 1,
+ retentionUnit,
+ ) !== currentDays;
+
+ return (
+
+ {t("Trash retention")}
+
+ {t("Pages in trash will be permanently deleted after this period.")}
+
+
+
+
+ setRetentionAmount(val)}
+ min={1}
+ hideControls
+ size="sm"
+ w={60}
+ disabled={!hasAccess}
+ />
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx
index a32c5867..a9530fad 100644
--- a/apps/client/src/ee/security/pages/security.tsx
+++ b/apps/client/src/ee/security/pages/security.tsx
@@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
+import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
@@ -42,6 +43,13 @@ export default function Security() {
>
)}
+ {!isCloud() && (
+ <>
+
+
+ >
+ )}
+
Single sign-on (SSO)
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts
index a8348052..cf074ee3 100644
--- a/apps/client/src/features/page/queries/page-query.ts
+++ b/apps/client/src/features/page/queries/page-query.ts
@@ -155,7 +155,9 @@ export function useDeletePageMutation() {
});
},
onError: (error) => {
- notifications.show({ message: t("Failed to delete page"), color: "red" });
+ const message =
+ error["response"]?.data?.message || t("Failed to delete page");
+ notifications.show({ message, color: "red" });
},
});
}
diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx
index c5335cd2..ad2ba500 100644
--- a/apps/client/src/features/page/trash/components/trash.tsx
+++ b/apps/client/src/features/page/trash/components/trash.tsx
@@ -31,9 +31,12 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
+import { useAtom } from "jotai";
+import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function Trash() {
const { t } = useTranslation();
+ const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -108,7 +111,7 @@ export default function Trash() {
} variant="light" color="red">
- {t("Pages in trash will be permanently deleted after 30 days.")}
+ {t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index 18b8bdf9..82c06336 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -25,6 +25,7 @@ export interface IWorkspace {
aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean;
+ trashRetentionDays?: number;
}
export interface IWorkspaceSettings {
diff --git a/apps/server/package.json b/apps/server/package.json
index 0e421a1d..31ba43c4 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -36,6 +36,7 @@
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
+ "@clickhouse/client": "^1.17.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
@@ -83,6 +84,7 @@
"mime-types": "^2.1.35",
"msgpackr": "^1.11.8",
"nanoid": "3.3.11",
+ "nestjs-cls": "^6.2.0",
"nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 13e28e18..fc1d2c8b 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
+import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentService } from './integrations/environment/environment.service';
+import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -22,6 +24,7 @@ import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
+import { ClsModule } from 'nestjs-cls';
const enterpriseModules = [];
try {
@@ -39,6 +42,10 @@ try {
@Module({
imports: [
+ ClsModule.forRoot({
+ global: true,
+ middleware: { mount: true },
+ }),
LoggerModule,
CoreModule,
DatabaseModule,
@@ -77,6 +84,12 @@ try {
...enterpriseModules,
],
controllers: [AppController],
- providers: [AppService],
+ providers: [
+ AppService,
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: AuditActorInterceptor,
+ },
+ ],
})
export class AppModule {}
diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts
new file mode 100644
index 00000000..6792590e
--- /dev/null
+++ b/apps/server/src/common/events/audit-events.ts
@@ -0,0 +1,136 @@
+export const AuditEvent = {
+ // Workspace
+ WORKSPACE_CREATED: 'workspace.created',
+ WORKSPACE_UPDATED: 'workspace.updated',
+ WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
+ WORKSPACE_INVITE_RESENT: 'workspace.invite_resent',
+ WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
+
+ // User
+ USER_CREATED: 'user.created',
+ USER_DELETED: 'user.deleted',
+ USER_LOGIN: 'user.login',
+ USER_LOGOUT: 'user.logout',
+ USER_ROLE_CHANGED: 'user.role_changed',
+ USER_PASSWORD_CHANGED: 'user.password_changed',
+ USER_PASSWORD_RESET: 'user.password_reset',
+ USER_UPDATED: 'user.updated',
+
+ // API Keys
+ API_KEY_CREATED: 'api_key.created',
+ API_KEY_UPDATED: 'api_key.updated',
+ API_KEY_DELETED: 'api_key.deleted',
+
+ // Space
+ SPACE_CREATED: 'space.created',
+ SPACE_UPDATED: 'space.updated',
+ SPACE_DELETED: 'space.deleted',
+ SPACE_MEMBER_ADDED: 'space.member_added',
+ SPACE_MEMBER_REMOVED: 'space.member_removed',
+ SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
+
+ // Group
+ GROUP_CREATED: 'group.created',
+ GROUP_UPDATED: 'group.updated',
+ GROUP_DELETED: 'group.deleted',
+ GROUP_MEMBER_ADDED: 'group.member_added',
+ GROUP_MEMBER_REMOVED: 'group.member_removed',
+
+ // Comment
+ COMMENT_CREATED: 'comment.created',
+ COMMENT_DELETED: 'comment.deleted',
+
+ // Page
+ PAGE_CREATED: 'page.created',
+ PAGE_TRASHED: 'page.trashed',
+ PAGE_DELETED: 'page.deleted',
+ PAGE_RESTORED: 'page.restored',
+ PAGE_MOVED_TO_SPACE: 'page.moved_to_space',
+ PAGE_DUPLICATED: 'page.duplicated',
+
+ // Share
+ SHARE_CREATED: 'share.created',
+ SHARE_DELETED: 'share.deleted',
+
+ // Import / Export
+ PAGE_IMPORTED: 'page.imported',
+ PAGE_EXPORTED: 'page.exported',
+ SPACE_EXPORTED: 'space.exported',
+
+ // SSO provider management
+ SSO_PROVIDER_CREATED: 'sso.provider_created',
+ SSO_PROVIDER_UPDATED: 'sso.provider_updated',
+ SSO_PROVIDER_DELETED: 'sso.provider_deleted',
+
+ // MFA
+ USER_MFA_ENABLED: 'user.mfa_enabled',
+ USER_MFA_DISABLED: 'user.mfa_disabled',
+ USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
+
+ // License
+ LICENSE_ACTIVATED: 'license.activated',
+ LICENSE_REMOVED: 'license.removed',
+
+ // Page permission
+ PAGE_RESTRICTED: 'page.restricted',
+ PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
+ PAGE_PERMISSION_ADDED: 'page.permission_added',
+ PAGE_PERMISSION_REMOVED: 'page.permission_removed',
+
+ // Comment updates / resolve
+ COMMENT_UPDATED: 'comment.updated',
+ COMMENT_RESOLVED: 'comment.resolved',
+ COMMENT_REOPENED: 'comment.reopened',
+
+ // Attachment
+ ATTACHMENT_UPLOADED: 'attachment.uploaded',
+ // ATTACHMENT_DELETED: 'attachment.deleted',
+} as const;
+
+export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
+
+export const EXCLUDED_AUDIT_EVENTS: Set = new Set([
+ // AuditEvent.PAGE_MOVED_TO_SPACE,
+ //AuditEvent.PAGE_DUPLICATED,
+]);
+
+export const AuditResource = {
+ WORKSPACE: 'workspace',
+ USER: 'user',
+ PAGE: 'page',
+ SPACE: 'space',
+ SPACE_MEMBER: 'space_member',
+ GROUP: 'group',
+ COMMENT: 'comment',
+ SHARE: 'share',
+ API_KEY: 'api_key',
+ SSO_PROVIDER: 'sso_provider',
+ WORKSPACE_INVITATION: 'workspace_invitation',
+ ATTACHMENT: 'attachment',
+ LICENSE: 'license',
+} as const;
+
+export type AuditResourceType =
+ (typeof AuditResource)[keyof typeof AuditResource];
+
+export type ActorType = 'user' | 'system' | 'api_key';
+
+export interface AuditLogPayload {
+ event: AuditEventType;
+ resourceType: AuditResourceType;
+ resourceId?: string;
+ spaceId?: string;
+ changes?: {
+ before?: Record;
+ after?: Record;
+ };
+ metadata?: Record;
+}
+
+export interface AuditLogData extends AuditLogPayload {
+ workspaceId: string;
+ actorId?: string;
+ actorType: ActorType;
+ ipAddress?: string;
+ userAgent?: string;
+}
diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts
new file mode 100644
index 00000000..570c96d8
--- /dev/null
+++ b/apps/server/src/common/helpers/cache-keys.ts
@@ -0,0 +1,3 @@
+export const CacheKey = {
+ LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
+};
diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts
index 7c94bb48..1970ecf9 100644
--- a/apps/server/src/common/helpers/utils.ts
+++ b/apps/server/src/common/helpers/utils.ts
@@ -120,6 +120,30 @@ export function normalizePostgresUrl(url: string): string {
return parsed.toString();
}
+export function diffAuditTrackedFields(
+ fields: readonly string[],
+ dto: Record,
+ before: Record | undefined | null,
+ after: Record | undefined | null,
+): { before: Record; after: Record } | null {
+ const beforeDiff: Record = {};
+ const afterDiff: Record = {};
+ let hasChanges = false;
+
+ for (const field of fields) {
+ if (typeof dto[field] === 'undefined') continue;
+ const oldVal = JSON.stringify(before?.[field] ?? null);
+ const newVal = JSON.stringify(after?.[field] ?? null);
+ if (oldVal !== newVal) {
+ beforeDiff[field] = before?.[field];
+ afterDiff[field] = after?.[field];
+ hasChanges = true;
+ }
+ }
+
+ return hasChanges ? { before: beforeDiff, after: afterDiff } : null;
+}
+
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
diff --git a/apps/server/src/common/interceptors/audit-actor.interceptor.ts b/apps/server/src/common/interceptors/audit-actor.interceptor.ts
new file mode 100644
index 00000000..c98ceb7f
--- /dev/null
+++ b/apps/server/src/common/interceptors/audit-actor.interceptor.ts
@@ -0,0 +1,29 @@
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ NestInterceptor,
+} from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { ClsService } from 'nestjs-cls';
+import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
+
+@Injectable()
+export class AuditActorInterceptor implements NestInterceptor {
+ constructor(private readonly cls: ClsService) {}
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const request = context.switchToHttp().getRequest();
+ const user = request.user?.user;
+
+ if (user?.id) {
+ const auditContext = this.cls.get(AUDIT_CONTEXT_KEY);
+ if (auditContext) {
+ auditContext.actorId = user.id;
+ this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
+ }
+ }
+
+ return next.handle();
+ }
+}
diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts
new file mode 100644
index 00000000..d58c4353
--- /dev/null
+++ b/apps/server/src/common/middlewares/audit-context.middleware.ts
@@ -0,0 +1,50 @@
+import { Injectable, NestMiddleware } from '@nestjs/common';
+import { FastifyRequest, FastifyReply } from 'fastify';
+import { ClsService } from 'nestjs-cls';
+
+export interface AuditContext {
+ workspaceId: string | null;
+ actorId: string | null;
+ actorType: 'user' | 'system' | 'api_key';
+ ipAddress: string | null;
+}
+
+export const AUDIT_CONTEXT_KEY = 'auditContext';
+
+@Injectable()
+export class AuditContextMiddleware implements NestMiddleware {
+ constructor(private readonly cls: ClsService) {}
+
+ use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
+ const workspaceId = (req as any).workspaceId ?? null;
+ const ipAddress = this.extractIpAddress(req);
+
+ const auditContext: AuditContext = {
+ workspaceId,
+ actorId: null,
+ actorType: 'user',
+ ipAddress,
+ };
+
+ this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
+
+ next();
+ }
+
+ private extractIpAddress(req: FastifyRequest['raw']): string | null {
+ const xForwardedFor = req.headers['x-forwarded-for'];
+ if (xForwardedFor) {
+ const ips = Array.isArray(xForwardedFor)
+ ? xForwardedFor[0]
+ : xForwardedFor.split(',')[0];
+ return ips?.trim() ?? null;
+ }
+
+ const xRealIp = req.headers['x-real-ip'];
+ if (xRealIp) {
+ return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
+ }
+
+ return (req as any).socket?.remoteAddress ?? null;
+ }
+}
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index 4694e0f0..d70f0034 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -6,6 +6,7 @@ import {
Get,
HttpCode,
HttpStatus,
+ Inject,
Logger,
NotFoundException,
Param,
@@ -54,6 +55,11 @@ import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@Controller()
export class AttachmentController {
@@ -69,6 +75,7 @@ export class AttachmentController {
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@@ -132,6 +139,18 @@ export class AttachmentController {
attachmentId: attachmentId,
});
+ this.auditService.log({
+ event: AuditEvent.ATTACHMENT_UPLOADED,
+ resourceType: AuditResource.ATTACHMENT,
+ resourceId: fileResponse?.id ?? attachmentId,
+ spaceId,
+ metadata: {
+ fileName: fileResponse?.fileName,
+ pageId,
+ spaceId,
+ },
+ });
+
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index a11e0360..f83fc1cf 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -3,6 +3,7 @@ import {
Controller,
HttpCode,
HttpStatus,
+ Inject,
Post,
Res,
UseGuards,
@@ -24,6 +25,11 @@ import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { validateSsoEnforcement } from './auth.util';
import { ModuleRef } from '@nestjs/core';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@Controller('auth')
export class AuthController {
@@ -33,6 +39,7 @@ export class AuthController {
private authService: AuthService,
private environmentService: EnvironmentService,
private moduleRef: ModuleRef,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -169,8 +176,17 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
- async logout(@Res({ passthrough: true }) res: FastifyReply) {
+ async logout(
+ @AuthUser() user: User,
+ @Res({ passthrough: true }) res: FastifyReply,
+ ) {
res.clearCookie('authToken');
+
+ this.auditService.log({
+ event: AuditEvent.USER_LOGOUT,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ });
}
setAuthCookie(res: FastifyReply, token: string) {
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index d2dac116..da5be855 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -1,5 +1,6 @@
import {
BadRequestException,
+ Inject,
Injectable,
NotFoundException,
UnauthorizedException,
@@ -29,6 +30,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
import { DomainService } from '../../../integrations/environment/domain.service';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class AuthService {
@@ -40,6 +46,7 @@ export class AuthService {
private mailService: MailService,
private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
@@ -64,6 +71,13 @@ export class AuthService {
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
+ this.auditService.log({
+ event: AuditEvent.USER_LOGIN,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ metadata: { source: 'password' },
+ });
+
return this.tokenService.generateAccessToken(user);
}
@@ -112,6 +126,12 @@ export class AuthService {
workspaceId,
);
+ this.auditService.log({
+ event: AuditEvent.USER_PASSWORD_CHANGED,
+ resourceType: AuditResource.USER,
+ resourceId: userId,
+ });
+
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
@@ -135,16 +155,27 @@ export class AuthService {
const token = nanoIdGen(16);
- const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
+ await executeTx(this.db, async (trx) => {
+ await trx
+ .deleteFrom('userTokens')
+ .where('userId', '=', user.id)
+ .where('type', '=', UserTokenType.FORGOT_PASSWORD)
+ .execute();
- await this.userTokenRepo.insertUserToken({
- token: token,
- userId: user.id,
- workspaceId: user.workspaceId,
- expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
- type: UserTokenType.FORGOT_PASSWORD,
+ await this.userTokenRepo.insertUserToken(
+ {
+ token,
+ userId: user.id,
+ workspaceId: user.workspaceId,
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
+ type: UserTokenType.FORGOT_PASSWORD,
+ },
+ { trx },
+ );
});
+ const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
+
const emailTemplate = ForgotPasswordEmail({
username: user.name,
resetLink: resetLink,
@@ -201,6 +232,13 @@ export class AuthService {
.execute();
});
+ this.auditService.setActorId(user.id);
+ this.auditService.log({
+ event: AuditEvent.USER_PASSWORD_RESET,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ });
+
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
diff --git a/apps/server/src/core/auth/services/signup.service.ts b/apps/server/src/core/auth/services/signup.service.ts
index bf089478..ab683c97 100644
--- a/apps/server/src/core/auth/services/signup.service.ts
+++ b/apps/server/src/core/auth/services/signup.service.ts
@@ -1,4 +1,4 @@
-import { BadRequestException, Injectable } from '@nestjs/common';
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
@@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRole } from '../../../common/helpers/types/permission';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class SignupService {
@@ -18,6 +23,7 @@ export class SignupService {
private workspaceService: WorkspaceService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async signup(
@@ -36,7 +42,7 @@ export class SignupService {
);
}
- return await executeTx(
+ const user = await executeTx(
this.db,
async (trx) => {
// create user
@@ -66,6 +72,24 @@ export class SignupService {
},
trx,
);
+
+ this.auditService.log({
+ event: AuditEvent.USER_CREATED,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ changes: {
+ after: {
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ },
+ },
+ metadata: {
+ source: 'signup',
+ },
+ });
+
+ return user;
}
async initialSetup(
diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts
index 1872d56e..6bb23381 100644
--- a/apps/server/src/core/comment/comment.controller.ts
+++ b/apps/server/src/core/comment/comment.controller.ts
@@ -5,6 +5,7 @@ import {
HttpCode,
HttpStatus,
UseGuards,
+ Inject,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
@@ -25,6 +26,11 @@ import {
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -35,6 +41,7 @@ export class CommentController {
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -51,7 +58,7 @@ export class CommentController {
await this.pageAccessService.validateCanEdit(page, user);
- return this.commentService.create(
+ const comment = await this.commentService.create(
{
userId: user.id,
page,
@@ -59,6 +66,18 @@ export class CommentController {
},
createCommentDto,
);
+
+ this.auditService.log({
+ event: AuditEvent.COMMENT_CREATED,
+ resourceType: AuditResource.COMMENT,
+ resourceId: comment.id,
+ spaceId: page.spaceId,
+ metadata: {
+ pageId: page.id,
+ },
+ });
+
+ return comment;
}
@HttpCode(HttpStatus.OK)
@@ -136,20 +155,32 @@ export class CommentController {
if (isOwner) {
await this.commentRepo.deleteComment(comment.id);
- return;
- }
-
- const ability = await this.spaceAbility.createForUser(
- user,
- comment.spaceId,
- );
-
- // 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',
+ } else {
+ const ability = await this.spaceAbility.createForUser(
+ user,
+ comment.spaceId,
);
+
+ // 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',
+ );
+ }
+ await this.commentRepo.deleteComment(comment.id);
}
- await this.commentRepo.deleteComment(comment.id);
+
+ this.auditService.log({
+ event: AuditEvent.COMMENT_DELETED,
+ resourceType: AuditResource.COMMENT,
+ resourceId: comment.id,
+ spaceId: comment.spaceId,
+ changes: {
+ before: {
+ pageId: comment.pageId,
+ creatorId: comment.creatorId,
+ },
+ },
+ });
}
}
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index df95bff2..f336cf8c 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -16,9 +16,15 @@ import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
+import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
+import {
+ AUDIT_SERVICE,
+ NoopAuditService,
+} from '../integrations/audit/audit.service';
+import { ClsMiddleware } from 'nestjs-cls';
@Module({
imports: [
@@ -37,17 +43,31 @@ import { WatcherModule } from './watcher/watcher.module';
NotificationModule,
WatcherModule,
],
+ providers: [
+ {
+ provide: AUDIT_SERVICE,
+ useClass: NoopAuditService,
+ },
+ ],
+ exports: [AUDIT_SERVICE],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
+ const excludedRoutes = [
+ { path: 'auth/setup', method: RequestMethod.POST },
+ { path: 'health', method: RequestMethod.GET },
+ { path: 'health/live', method: RequestMethod.GET },
+ { path: 'billing/stripe/webhook', method: RequestMethod.POST },
+ ];
+
consumer
.apply(DomainMiddleware)
- .exclude(
- { path: 'auth/setup', method: RequestMethod.POST },
- { path: 'health', method: RequestMethod.GET },
- { path: 'health/live', method: RequestMethod.GET },
- { path: 'billing/stripe/webhook', method: RequestMethod.POST },
- )
+ .exclude(...excludedRoutes)
+ .forRoutes('*');
+
+ consumer
+ .apply(AuditContextMiddleware)
+ .exclude(...excludedRoutes)
.forRoutes('*');
}
}
diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts
index 78ca0cf2..e0bdc23a 100644
--- a/apps/server/src/core/group/services/group-user.service.ts
+++ b/apps/server/src/core/group/services/group-user.service.ts
@@ -14,6 +14,11 @@ 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 { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class GroupUserService {
@@ -25,6 +30,7 @@ export class GroupUserService {
private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupUsers(
@@ -72,6 +78,20 @@ export class GroupUserService {
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.execute();
+
+ for (const user of validUsers) {
+ this.auditService.log({
+ event: AuditEvent.GROUP_MEMBER_ADDED,
+ resourceType: AuditResource.GROUP,
+ resourceId: groupId,
+ changes: {
+ after: {
+ userId: user.id,
+ userName: user.name,
+ },
+ },
+ });
+ }
}
async removeUserFromGroup(
@@ -115,8 +135,24 @@ export class GroupUserService {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
+ { trx },
);
}
});
+
+ this.auditService.log({
+ event: AuditEvent.GROUP_MEMBER_REMOVED,
+ resourceType: AuditResource.GROUP,
+ resourceId: groupId,
+ changes: {
+ before: {
+ userId: user.id,
+ userName: user.name,
+ },
+ },
+ metadata: {
+ groupName: group.name,
+ },
+ });
}
}
diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts
index 8e8a02ef..7d6005f0 100644
--- a/apps/server/src/core/group/services/group.service.ts
+++ b/apps/server/src/core/group/services/group.service.ts
@@ -18,6 +18,12 @@ import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import { diffAuditTrackedFields } from '../../../common/helpers';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class GroupService {
@@ -29,6 +35,7 @@ export class GroupService {
private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupInfo(groupId: string, workspaceId: string): Promise {
@@ -74,6 +81,18 @@ export class GroupService {
);
}
+ this.auditService.log({
+ event: AuditEvent.GROUP_CREATED,
+ resourceType: AuditResource.GROUP,
+ resourceId: createdGroup.id,
+ changes: {
+ after: {
+ name: createdGroup.name,
+ description: createdGroup.description,
+ },
+ },
+ });
+
return createdGroup;
}
@@ -95,6 +114,8 @@ export class GroupService {
throw new BadRequestException('You cannot update a default group');
}
+ const groupBefore = { name: group.name, description: group.description };
+
if (updateGroupDto.name) {
const existingGroup = await this.groupRepo.findByName(
updateGroupDto.name,
@@ -121,6 +142,22 @@ export class GroupService {
workspaceId,
);
+ const changes = diffAuditTrackedFields(
+ ['name', 'description'],
+ updateGroupDto,
+ groupBefore,
+ group,
+ );
+
+ if (changes) {
+ this.auditService.log({
+ event: AuditEvent.GROUP_UPDATED,
+ resourceType: AuditResource.GROUP,
+ resourceId: group.id,
+ changes,
+ });
+ }
+
return group;
}
@@ -154,6 +191,18 @@ export class GroupService {
);
}
});
+
+ this.auditService.log({
+ event: AuditEvent.GROUP_DELETED,
+ resourceType: AuditResource.GROUP,
+ resourceId: groupId,
+ changes: {
+ before: {
+ name: group.name,
+ description: group.description,
+ },
+ },
+ });
}
async findAndValidateGroup(
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index 28962293..eff24936 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
+ Inject,
NotFoundException,
Post,
UseGuards,
@@ -25,7 +26,7 @@ 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
-import { User, Workspace } from '@docmost/db/types/entity.types';
+import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
import {
SpaceCaslAction,
@@ -40,6 +41,12 @@ import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
+import { getPageTitle } from '../../common/helpers';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -50,6 +57,7 @@ export class PageController {
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -129,6 +137,19 @@ export class PageController {
const permissions = { canEdit, hasRestriction };
+ this.auditService.log({
+ event: AuditEvent.PAGE_CREATED,
+ resourceType: AuditResource.PAGE,
+ resourceId: page.id,
+ spaceId: page.spaceId,
+ changes: {
+ after: {
+ title: getPageTitle(page.title),
+ spaceId: page.spaceId,
+ },
+ },
+ });
+
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -153,8 +174,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- const { hasRestriction } =
- await this.pageAccessService.validateCanEdit(page, user);
+ const { hasRestriction } = await this.pageAccessService.validateCanEdit(
+ page,
+ user,
+ );
const updatedPage = await this.pageService.update(
page,
@@ -202,6 +225,21 @@ export class PageController {
);
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
+
+ this.auditService.log({
+ event: AuditEvent.PAGE_DELETED,
+ resourceType: AuditResource.PAGE,
+ resourceId: page.id,
+ spaceId: page.spaceId,
+ changes: {
+ before: {
+ pageId: page.id,
+ slugId: page.slugId,
+ title: getPageTitle(page.title),
+ spaceId: page.spaceId,
+ },
+ },
+ });
} else {
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
@@ -211,6 +249,21 @@ export class PageController {
user.id,
workspace.id,
);
+
+ this.auditService.log({
+ event: AuditEvent.PAGE_TRASHED,
+ resourceType: AuditResource.PAGE,
+ resourceId: page.id,
+ spaceId: page.spaceId,
+ changes: {
+ before: {
+ pageId: page.id,
+ slugId: page.slugId,
+ title: getPageTitle(page.title),
+ spaceId: page.spaceId,
+ },
+ },
+ });
}
}
@@ -227,20 +280,30 @@ export class PageController {
throw new NotFoundException('Page not found');
}
- //Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
- // so page is virtually lost. Fix.
+ // only users with "can edit" space level permission can restore pages
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
- //TODO: can users with page level edit, but no space level edit restore pages they can edit?
-
- // Check page-level edit permission (if restoring to a restricted ancestor)
+ // make sure they have page level access to the page
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
+ this.auditService.log({
+ event: AuditEvent.PAGE_RESTORED,
+ resourceType: AuditResource.PAGE,
+ resourceId: page.id,
+ spaceId: page.spaceId,
+ changes: {
+ after: {
+ title: getPageTitle(page.title),
+ spaceId: page.spaceId,
+ },
+ },
+ });
+
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
@@ -286,7 +349,7 @@ export class PageController {
deletedPageDto.spaceId,
);
- if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
+ if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
@@ -410,7 +473,26 @@ export class PageController {
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
- return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
+ const { childPageIds } = await this.pageService.movePageToSpace(
+ movedPage,
+ dto.spaceId,
+ user.id,
+ );
+
+ this.auditService.log({
+ event: AuditEvent.PAGE_MOVED_TO_SPACE,
+ resourceType: AuditResource.PAGE,
+ resourceId: movedPage.id,
+ spaceId: movedPage.spaceId,
+ changes: {
+ before: { spaceId: movedPage.spaceId },
+ after: { spaceId: dto.spaceId },
+ },
+ metadata: {
+ title: getPageTitle(movedPage.title),
+ ...(childPageIds.length > 0 && { childPageIds }),
+ },
+ });
}
@HttpCode(HttpStatus.OK)
@@ -425,6 +507,8 @@ export class PageController {
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
+ let result;
+
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -440,7 +524,27 @@ export class PageController {
throw new ForbiddenException();
}
- return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
+ result = await this.pageService.duplicatePage(
+ copiedPage,
+ dto.spaceId,
+ user,
+ );
+
+ this.auditService.log({
+ event: AuditEvent.PAGE_DUPLICATED,
+ resourceType: AuditResource.PAGE,
+ resourceId: result.id,
+ spaceId: dto.spaceId,
+ metadata: {
+ sourcePageId: copiedPage.id,
+ title: getPageTitle(copiedPage.title),
+ sourceSpaceId: copiedPage.spaceId,
+ targetSpaceId: dto.spaceId,
+ ...(result.childPageIds.length > 0 && {
+ childPageIds: result.childPageIds,
+ }),
+ },
+ });
} else {
// If no spaceId, it's a duplicate in same space
const ability = await this.spaceAbility.createForUser(
@@ -451,8 +555,28 @@ export class PageController {
throw new ForbiddenException();
}
- return this.pageService.duplicatePage(copiedPage, undefined, user);
+ result = await this.pageService.duplicatePage(
+ copiedPage,
+ undefined,
+ user,
+ );
+
+ this.auditService.log({
+ event: AuditEvent.PAGE_DUPLICATED,
+ resourceType: AuditResource.PAGE,
+ resourceId: result.id,
+ spaceId: copiedPage.spaceId,
+ metadata: {
+ sourcePageId: copiedPage.id,
+ title: getPageTitle(copiedPage.title),
+ ...(result.childPageIds.length > 0 && {
+ childPageIds: result.childPageIds,
+ }),
+ },
+ });
}
+
+ return result;
}
@HttpCode(HttpStatus.OK)
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 2c68d3ef..8df9e4bd 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -368,6 +368,8 @@ export class PageService {
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
+ let childPageIds: string[] = [];
+
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
@@ -413,11 +415,13 @@ export class PageService {
const pageIdsToMove = accessiblePages.map((p) => p.id);
+ childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
+
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
- pageIdsToMove.filter((id) => id !== rootPage.id),
+ childPageIds,
trx,
);
}
@@ -462,6 +466,8 @@ export class PageService {
});
}
});
+
+ return { childPageIds };
}
async duplicatePage(
@@ -680,10 +686,12 @@ export class PageService {
});
const hasChildren = pages.length > 1;
+ const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
return {
...duplicatedPage,
hasChildren,
+ childPageIds,
};
}
diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts
index f0646367..42a4a11a 100644
--- a/apps/server/src/core/page/services/trash-cleanup.service.ts
+++ b/apps/server/src/core/page/services/trash-cleanup.service.ts
@@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
+const DEFAULT_RETENTION_DAYS = 30;
+
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
- private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@@ -21,36 +22,46 @@ export class TrashCleanupService {
try {
this.logger.debug('Starting trash cleanup job');
- const retentionDate = new Date();
- retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
-
- // Get all pages that were deleted more than 30 days ago
- const oldDeletedPages = await this.db
- .selectFrom('pages')
- .select(['id', 'spaceId', 'workspaceId'])
- .where('deletedAt', '<', retentionDate)
+ const workspaces = await this.db
+ .selectFrom('workspaces')
+ .select(['id', 'trashRetentionDays'])
+ .where('deletedAt', 'is', null)
.execute();
- if (oldDeletedPages.length === 0) {
- this.logger.debug('No old trash items to clean up');
- return;
- }
+ let totalCleaned = 0;
- this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
+ for (const workspace of workspaces) {
+ const retentionDays =
+ workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
- // Process each page
- for (const page of oldDeletedPages) {
- try {
- await this.cleanupPage(page.id);
- } catch (error) {
- this.logger.error(
- `Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
- error instanceof Error ? error.stack : undefined,
- );
+ const retentionDate = new Date();
+ retentionDate.setDate(retentionDate.getDate() - retentionDays);
+
+ const oldDeletedPages = await this.db
+ .selectFrom('pages')
+ .select(['id'])
+ .where('workspaceId', '=', workspace.id)
+ .where('deletedAt', '<', retentionDate)
+ .execute();
+
+ for (const page of oldDeletedPages) {
+ try {
+ await this.cleanupPage(page.id);
+ totalCleaned++;
+ } catch (error) {
+ this.logger.error(
+ `Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ error instanceof Error ? error.stack : undefined,
+ );
+ }
}
}
- this.logger.debug('Trash cleanup job completed');
+ this.logger.debug(
+ totalCleaned > 0
+ ? `Trash cleanup completed: ${totalCleaned} pages cleaned`
+ : 'No old trash items to clean up',
+ );
} catch (error) {
this.logger.error(
'Trash cleanup job failed',
diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts
index 6097f197..0598dbb0 100644
--- a/apps/server/src/core/share/share.controller.ts
+++ b/apps/server/src/core/share/share.controller.ts
@@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
+ Inject,
NotFoundException,
Post,
UseGuards,
@@ -29,6 +30,11 @@ 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 { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@UseGuards(JwtAuthGuard)
@Controller('shares')
@@ -40,6 +46,7 @@ export class ShareController {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -156,12 +163,25 @@ export class ShareController {
throw new ForbiddenException('Public sharing is disabled');
}
- return this.shareService.createShare({
+ const share = await this.shareService.createShare({
page,
authUserId: user.id,
workspaceId: workspace.id,
createShareDto,
});
+
+ this.auditService.log({
+ event: AuditEvent.SHARE_CREATED,
+ resourceType: AuditResource.SHARE,
+ resourceId: share.id,
+ spaceId: page.spaceId,
+ metadata: {
+ pageId: page.id,
+ spaceId: page.spaceId,
+ },
+ });
+
+ return share;
}
@HttpCode(HttpStatus.OK)
@@ -202,6 +222,19 @@ export class ShareController {
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id);
+
+ this.auditService.log({
+ event: AuditEvent.SHARE_DELETED,
+ resourceType: AuditResource.SHARE,
+ resourceId: share.id,
+ spaceId: share.spaceId,
+ changes: {
+ before: {
+ pageId: share.pageId,
+ spaceId: share.spaceId,
+ },
+ },
+ });
}
@Public()
diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts
index f4f4aa17..0fbab02e 100644
--- a/apps/server/src/core/space/services/space-member.service.ts
+++ b/apps/server/src/core/space/services/space-member.service.ts
@@ -1,5 +1,6 @@
import {
BadRequestException,
+ Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,6 +18,11 @@ 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 { executeTx } from '@docmost/db/utils';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceMemberService {
@@ -26,6 +32,7 @@ export class SpaceMemberService {
private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async addUserToSpace(
@@ -90,7 +97,6 @@ export class SpaceMemberService {
authUser: User,
workspaceId: string,
): Promise {
- // await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
@@ -164,8 +170,45 @@ export class SpaceMemberService {
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
- } else {
- // either they are already members or do not exist on the workspace
+
+ // Audit log for each member added
+ for (const user of validUsers) {
+ this.auditService.log({
+ event: AuditEvent.SPACE_MEMBER_ADDED,
+ resourceType: AuditResource.SPACE_MEMBER,
+ resourceId: dto.spaceId,
+ spaceId: dto.spaceId,
+ changes: {
+ after: { role: dto.role },
+ },
+ metadata: {
+ spaceId: dto.spaceId,
+ spaceName: space.name,
+ userId: user.id,
+ userName: user.name,
+ memberType: 'user',
+ },
+ });
+ }
+
+ for (const group of validGroups) {
+ this.auditService.log({
+ event: AuditEvent.SPACE_MEMBER_ADDED,
+ resourceType: AuditResource.SPACE_MEMBER,
+ resourceId: dto.spaceId,
+ spaceId: dto.spaceId,
+ changes: {
+ after: { role: dto.role },
+ },
+ metadata: {
+ spaceId: dto.spaceId,
+ spaceName: space.name,
+ groupId: group.id,
+ groupName: group.name,
+ memberType: 'group',
+ },
+ });
+ }
}
}
@@ -230,6 +273,23 @@ export class SpaceMemberService {
{ trx },
);
});
+
+ this.auditService.log({
+ event: AuditEvent.SPACE_MEMBER_REMOVED,
+ resourceType: AuditResource.SPACE_MEMBER,
+ resourceId: dto.spaceId,
+ spaceId: dto.spaceId,
+ changes: {
+ before: { role: spaceMember.role },
+ },
+ metadata: {
+ spaceId: dto.spaceId,
+ spaceName: space.name,
+ userId: spaceMember.userId,
+ groupId: spaceMember.groupId,
+ memberType: spaceMember.userId ? 'user' : 'group',
+ },
+ });
}
async updateSpaceMemberRole(
@@ -280,6 +340,24 @@ export class SpaceMemberService {
spaceMember.id,
dto.spaceId,
);
+
+ this.auditService.log({
+ event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
+ resourceType: AuditResource.SPACE_MEMBER,
+ resourceId: dto.spaceId,
+ spaceId: dto.spaceId,
+ changes: {
+ before: { role: spaceMember.role },
+ after: { role: dto.role },
+ },
+ metadata: {
+ spaceId: dto.spaceId,
+ spaceName: space.name,
+ userId: spaceMember.userId,
+ groupId: spaceMember.groupId,
+ memberType: spaceMember.userId ? 'user' : 'group',
+ },
+ });
}
async validateLastAdmin(spaceId: string): Promise {
diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts
index 7e8e99d9..12f61299 100644
--- a/apps/server/src/core/space/services/space.service.ts
+++ b/apps/server/src/core/space/services/space.service.ts
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
+ Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import { diffAuditTrackedFields } from '../../../common/helpers';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceService {
@@ -32,6 +39,7 @@ export class SpaceService {
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async createSpace(
@@ -63,6 +71,19 @@ export class SpaceService {
trx,
);
+ this.auditService.log({
+ event: AuditEvent.SPACE_CREATED,
+ resourceType: AuditResource.SPACE,
+ resourceId: space.id,
+ spaceId: space.id,
+ changes: {
+ after: {
+ name: space.name,
+ slug: space.slug,
+ },
+ },
+ });
+
return { ...space, memberCount: 1 };
}
@@ -124,28 +145,74 @@ export class SpaceService {
'This feature requires a valid enterprise license',
);
}
-
- await this.spaceRepo.updateSharingSettings(
- updateSpaceDto.spaceId,
- workspaceId,
- 'disabled',
- updateSpaceDto.disablePublicSharing,
- );
-
- if (updateSpaceDto.disablePublicSharing) {
- await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
- }
}
- return await this.spaceRepo.updateSpace(
- {
- name: updateSpaceDto.name,
- description: updateSpaceDto.description,
- slug: updateSpaceDto.slug,
- },
+ const spaceBefore = await this.spaceRepo.findById(
updateSpaceDto.spaceId,
workspaceId,
);
+ const settingsBefore = (spaceBefore?.settings ?? {}) as Record;
+
+ const before: Record = {};
+ const after: Record = {};
+
+ let updatedSpace: Space;
+
+ await executeTx(this.db, async (trx) => {
+ if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
+ const prev = settingsBefore?.sharing?.disabled ?? false;
+ if (prev !== updateSpaceDto.disablePublicSharing) {
+ before.disablePublicSharing = prev;
+ after.disablePublicSharing = updateSpaceDto.disablePublicSharing;
+ }
+
+ await this.spaceRepo.updateSharingSettings(
+ updateSpaceDto.spaceId,
+ workspaceId,
+ 'disabled',
+ updateSpaceDto.disablePublicSharing,
+ trx,
+ );
+
+ if (updateSpaceDto.disablePublicSharing) {
+ await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx);
+ }
+ }
+
+ updatedSpace = await this.spaceRepo.updateSpace(
+ {
+ name: updateSpaceDto.name,
+ description: updateSpaceDto.description,
+ slug: updateSpaceDto.slug,
+ },
+ updateSpaceDto.spaceId,
+ workspaceId,
+ trx,
+ );
+ });
+
+ const columnChanges = diffAuditTrackedFields(
+ ['name', 'slug', 'description'],
+ updateSpaceDto,
+ spaceBefore,
+ updatedSpace,
+ );
+ if (columnChanges) {
+ Object.assign(before, columnChanges.before);
+ Object.assign(after, columnChanges.after);
+ }
+
+ if (Object.keys(after).length > 0) {
+ this.auditService.log({
+ event: AuditEvent.SPACE_UPDATED,
+ resourceType: AuditResource.SPACE,
+ resourceId: updateSpaceDto.spaceId,
+ spaceId: updateSpaceDto.spaceId,
+ changes: { before, after },
+ });
+ }
+
+ return updatedSpace;
}
async getSpaceInfo(spaceId: string, workspaceId: string): Promise {
@@ -174,5 +241,19 @@ export class SpaceService {
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
+
+ this.auditService.log({
+ event: AuditEvent.SPACE_DELETED,
+ resourceType: AuditResource.SPACE,
+ resourceId: spaceId,
+ spaceId: spaceId,
+ changes: {
+ before: {
+ name: space.name,
+ slug: space.slug,
+ description: space.description,
+ },
+ },
+ });
}
}
diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts
index f71c85f1..59bc08ec 100644
--- a/apps/server/src/core/user/user.service.ts
+++ b/apps/server/src/core/user/user.service.ts
@@ -1,18 +1,27 @@
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
BadRequestException,
+ Inject,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
-import { comparePasswordHash } from 'src/common/helpers/utils';
+import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@Injectable()
export class UserService {
- constructor(private userRepo: UserRepo) {}
+ constructor(
+ private userRepo: UserRepo,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
+ ) {}
async findById(userId: string, workspaceId: string) {
return this.userRepo.findById(userId, workspaceId);
@@ -51,6 +60,8 @@ export class UserService {
);
}
+ const userBefore = { name: user.name, email: user.email, locale: user.locale };
+
if (updateUserDto.name) {
user.name = updateUserDto.name;
}
@@ -91,6 +102,23 @@ export class UserService {
delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
+
+ const changes = diffAuditTrackedFields(
+ ['name', 'email'],
+ updateUserDto,
+ userBefore,
+ user,
+ );
+
+ if (changes) {
+ this.auditService.log({
+ event: AuditEvent.USER_UPDATED,
+ resourceType: AuditResource.USER,
+ resourceId: userId,
+ changes,
+ });
+ }
+
return user;
}
}
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 7b4f31eb..0e805db7 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -1,6 +1,13 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from './create-workspace.dto';
-import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
+import {
+ IsArray,
+ IsBoolean,
+ IsInt,
+ IsOptional,
+ IsString,
+ Min,
+} from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@@ -34,4 +41,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ trashRetentionDays: number;
}
diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts
index 90d5f7b4..e6ebe7ff 100644
--- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts
+++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts
@@ -1,5 +1,6 @@
import {
BadRequestException,
+ Inject,
Injectable,
Logger,
NotFoundException,
@@ -33,6 +34,11 @@ import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceInvitationService {
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
@@ -180,6 +187,24 @@ export class WorkspaceInvitationService {
workspace.hostname,
);
});
+
+ // Audit log for each invitation created
+ for (const invitation of invites) {
+ this.auditService.log({
+ event: AuditEvent.WORKSPACE_INVITE_CREATED,
+ resourceType: AuditResource.WORKSPACE_INVITATION,
+ resourceId: invitation.id,
+ changes: {
+ after: {
+ email: invitation.email,
+ role: invitation.role,
+ },
+ },
+ metadata: {
+ groupIds: invitation.groupIds,
+ },
+ });
+ }
}
}
@@ -296,6 +321,23 @@ export class WorkspaceInvitationService {
});
}
+ this.auditService.log({
+ event: AuditEvent.USER_CREATED,
+ resourceType: AuditResource.USER,
+ resourceId: newUser.id,
+ changes: {
+ after: {
+ name: newUser.name,
+ email: newUser.email,
+ role: invitation.role,
+ },
+ },
+ metadata: {
+ source: 'invitation',
+ invitationId: invitation.id,
+ },
+ });
+
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
@@ -339,17 +381,48 @@ export class WorkspaceInvitationService {
invitedByUser.name,
workspace.hostname,
);
+
+ this.auditService.log({
+ event: AuditEvent.WORKSPACE_INVITE_RESENT,
+ resourceType: AuditResource.WORKSPACE_INVITATION,
+ resourceId: invitation.id,
+ metadata: {
+ email: invitation.email,
+ role: invitation.role,
+ },
+ });
}
async revokeInvitation(
invitationId: string,
workspaceId: string,
): Promise {
+ const invitation = await this.db
+ .selectFrom('workspaceInvitations')
+ .select(['id', 'email', 'role'])
+ .where('id', '=', invitationId)
+ .where('workspaceId', '=', workspaceId)
+ .executeTakeFirst();
+
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
+
+ if (invitation) {
+ this.auditService.log({
+ event: AuditEvent.WORKSPACE_INVITE_REVOKED,
+ resourceType: AuditResource.WORKSPACE_INVITATION,
+ resourceId: invitation.id,
+ changes: {
+ before: {
+ email: invitation.email,
+ role: invitation.role,
+ },
+ },
+ });
+ }
}
async getInvitationLinkById(
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index ed1a3424..d7f61b49 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
+ Inject,
Injectable,
Logger,
NotFoundException,
@@ -31,11 +32,19 @@ import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
-import { generateRandomSuffixNumbers } from '../../../common/helpers';
+import {
+ generateRandomSuffixNumbers,
+ diffAuditTrackedFields,
+} from '../../../common/helpers';
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 { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceService {
@@ -57,6 +66,7 @@ export class WorkspaceService {
@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,
) {}
async findById(workspaceId: string) {
@@ -280,7 +290,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.enforceSso) {
const sso = await this.db
.selectFrom('authProviders')
- .selectAll()
+ .select(['id'])
.where('isEnabled', '=', true)
.where('workspaceId', '=', workspaceId)
.execute();
@@ -295,9 +305,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.emailDomains) {
const regex =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
-
const emailDomains = updateWorkspaceDto.emailDomains || [];
-
updateWorkspaceDto.emailDomains = emailDomains
.map((domain) => regex.exec(domain)?.[0])
.filter(Boolean);
@@ -313,93 +321,170 @@ export class WorkspaceService {
}
}
- if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
- await this.workspaceRepo.updateApiSettings(
- workspaceId,
- 'restrictToAdmins',
- updateWorkspaceDto.restrictApiToAdmins,
- );
- delete updateWorkspaceDto.restrictApiToAdmins;
- }
+ const before: Record = {};
+ const after: Record = {};
- if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
- await this.workspaceRepo.updateAiSettings(
- workspaceId,
- 'search',
- updateWorkspaceDto.aiSearch,
- );
+ if (
+ typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
+ typeof updateWorkspaceDto.trashRetentionDays !== 'undefined'
+ ) {
+ const ws = await this.db
+ .selectFrom('workspaces')
+ .select(['id', 'licenseKey', 'trashRetentionDays'])
+ .where('id', '=', workspaceId)
+ .executeTakeFirst();
- if (updateWorkspaceDto.aiSearch) {
- const tableExists = await isPageEmbeddingsTableExists(this.db);
- if (!tableExists) {
- throw new BadRequestException(
- 'Failed to activate. Make sure pgvector postgres extension is installed.',
- );
- }
-
- await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
- workspaceId,
- });
- } else {
- // Schedule deletion after 24 hours
- const deleteJobId = `ai-search-disabled-${workspaceId}`;
- await this.aiQueue.add(
- QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
- { workspaceId },
- {
- jobId: deleteJobId,
- delay: 24 * 60 * 60 * 1000,
- removeOnComplete: true,
- removeOnFail: true,
- },
- );
- }
-
- delete updateWorkspaceDto.aiSearch;
- }
-
- if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
- await this.workspaceRepo.updateAiSettings(
- workspaceId,
- 'generative',
- updateWorkspaceDto.generativeAi,
- );
- delete updateWorkspaceDto.generativeAi;
- }
-
- if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
- const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
- withLicenseKey: true,
- });
-
- if (
- !this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
- ) {
+ if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
- await this.workspaceRepo.updateSharingSettings(
- workspaceId,
- 'disabled',
- updateWorkspaceDto.disablePublicSharing,
- );
-
- if (updateWorkspaceDto.disablePublicSharing) {
- await this.shareRepo.deleteByWorkspaceId(workspaceId);
+ if (
+ typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
+ updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
+ ) {
+ before.trashRetentionDays = ws.trashRetentionDays;
+ after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
}
-
- delete updateWorkspaceDto.disablePublicSharing;
}
- await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
+ if (updateWorkspaceDto.aiSearch) {
+ const tableExists = await isPageEmbeddingsTableExists(this.db);
+ if (!tableExists) {
+ throw new BadRequestException(
+ 'Failed to activate. Make sure pgvector postgres extension is installed.',
+ );
+ }
+ }
+
+ const workspaceBefore = await this.workspaceRepo.findById(workspaceId);
+ const settingsBefore = (workspaceBefore?.settings ?? {}) as Record<
+ string,
+ any
+ >;
+
+ await executeTx(this.db, async (trx) => {
+ if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
+ const prev = settingsBefore?.api?.restrictToAdmins ?? false;
+ if (prev !== updateWorkspaceDto.restrictApiToAdmins) {
+ before.restrictApiToAdmins = prev;
+ after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins;
+ }
+ await this.workspaceRepo.updateApiSettings(
+ workspaceId,
+ 'restrictToAdmins',
+ updateWorkspaceDto.restrictApiToAdmins,
+ trx,
+ );
+ }
+
+ if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
+ const prev = settingsBefore?.ai?.search ?? false;
+ if (prev !== updateWorkspaceDto.aiSearch) {
+ before.aiSearch = prev;
+ after.aiSearch = updateWorkspaceDto.aiSearch;
+ }
+ await this.workspaceRepo.updateAiSettings(
+ workspaceId,
+ 'search',
+ updateWorkspaceDto.aiSearch,
+ trx,
+ );
+ }
+
+ if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
+ const prev = settingsBefore?.ai?.generative ?? false;
+ if (prev !== updateWorkspaceDto.generativeAi) {
+ before.generativeAi = prev;
+ after.generativeAi = updateWorkspaceDto.generativeAi;
+ }
+ await this.workspaceRepo.updateAiSettings(
+ workspaceId,
+ 'generative',
+ updateWorkspaceDto.generativeAi,
+ trx,
+ );
+ }
+
+ if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
+ const prev = settingsBefore?.sharing?.disabled ?? false;
+ if (prev !== updateWorkspaceDto.disablePublicSharing) {
+ before.disablePublicSharing = prev;
+ after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing;
+ }
+ await this.workspaceRepo.updateSharingSettings(
+ workspaceId,
+ 'disabled',
+ updateWorkspaceDto.disablePublicSharing,
+ trx,
+ );
+ if (updateWorkspaceDto.disablePublicSharing) {
+ await this.shareRepo.deleteByWorkspaceId(workspaceId, trx);
+ }
+ }
+
+ delete updateWorkspaceDto.restrictApiToAdmins;
+ delete updateWorkspaceDto.aiSearch;
+ delete updateWorkspaceDto.generativeAi;
+ delete updateWorkspaceDto.disablePublicSharing;
+
+ await this.workspaceRepo.updateWorkspace(
+ updateWorkspaceDto,
+ workspaceId,
+ trx,
+ );
+ });
+
+ if (after.aiSearch === true) {
+ await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
+ workspaceId,
+ });
+ } else if (after.aiSearch === false) {
+ const deleteJobId = `ai-search-disabled-${workspaceId}`;
+ await this.aiQueue.add(
+ QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
+ { workspaceId },
+ {
+ jobId: deleteJobId,
+ delay: 24 * 60 * 60 * 1000,
+ removeOnComplete: true,
+ removeOnFail: true,
+ },
+ );
+ }
const workspace = await this.workspaceRepo.findById(workspaceId, {
withMemberCount: true,
withLicenseKey: true,
});
+ const columnChanges = diffAuditTrackedFields(
+ [
+ 'name',
+ 'logo',
+ 'enforceSso',
+ 'enforceMfa',
+ 'emailDomains',
+ ],
+ updateWorkspaceDto,
+ workspaceBefore,
+ workspace,
+ );
+ if (columnChanges) {
+ Object.assign(before, columnChanges.before);
+ Object.assign(after, columnChanges.after);
+ }
+
+ if (Object.keys(after).length > 0) {
+ this.auditService.log({
+ event: AuditEvent.WORKSPACE_UPDATED,
+ resourceType: AuditResource.WORKSPACE,
+ resourceId: workspaceId,
+ changes: { before, after },
+ });
+ }
+
const { licenseKey, ...rest } = workspace;
return {
...rest,
@@ -457,6 +542,16 @@ export class WorkspaceService {
user.id,
workspaceId,
);
+
+ this.auditService.log({
+ event: AuditEvent.USER_ROLE_CHANGED,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ changes: {
+ before: { role: user.role },
+ after: { role: newRole },
+ },
+ });
}
async generateHostname(
@@ -564,6 +659,19 @@ export class WorkspaceService {
});
});
+ this.auditService.log({
+ event: AuditEvent.USER_DELETED,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ changes: {
+ before: {
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ },
+ },
+ });
+
try {
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
} catch (err) {
diff --git a/apps/server/src/database/migrations/20260228T223532-audit.ts b/apps/server/src/database/migrations/20260228T223532-audit.ts
new file mode 100644
index 00000000..e5a4d7d6
--- /dev/null
+++ b/apps/server/src/database/migrations/20260228T223532-audit.ts
@@ -0,0 +1,60 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('audit')
+ .ifNotExists()
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('actor_id', 'uuid')
+ .addColumn('actor_type', 'varchar', (col) =>
+ col.notNull().defaultTo('user'),
+ )
+ .addColumn('event', 'varchar', (col) => col.notNull())
+ .addColumn('resource_type', 'varchar', (col) => col.notNull())
+ .addColumn('resource_id', 'uuid')
+ .addColumn('space_id', 'uuid')
+ .addColumn('changes', 'jsonb')
+ .addColumn('metadata', 'jsonb')
+ .addColumn('ip_address', sql`inet`)
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_audit_workspace_id')
+ .ifNotExists()
+ .on('audit')
+ .columns(['workspace_id', 'id desc'])
+ .execute();
+
+ // add new workspace columns
+ await db.schema
+ .alterTable('workspaces')
+ .addColumn('audit_retention_days', 'int8', (col) => col)
+ .execute();
+
+ await db.schema
+ .alterTable('workspaces')
+ .addColumn('trash_retention_days', 'int8', (col) => col)
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema
+ .alterTable('workspaces')
+ .dropColumn('audit_retention_days')
+ .execute();
+
+ await db.schema
+ .alterTable('workspaces')
+ .dropColumn('trash_retention_days')
+ .execute();
+
+ await db.schema.dropTable('audit').execute();
+}
diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts
index 631e0697..22ea4d8c 100644
--- a/apps/server/src/database/repos/share/share.repo.ts
+++ b/apps/server/src/database/repos/share/share.repo.ts
@@ -136,15 +136,23 @@ export class ShareRepo {
await query.execute();
}
- async deleteBySpaceId(spaceId: string): Promise {
- await this.db
+ async deleteBySpaceId(
+ spaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
- async deleteByWorkspaceId(workspaceId: string): Promise {
- await this.db
+ async deleteByWorkspaceId(
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts
index 0e2bd2b7..8344a557 100644
--- a/apps/server/src/database/repos/space/space.repo.ts
+++ b/apps/server/src/database/repos/space/space.repo.ts
@@ -94,8 +94,10 @@ export class SpaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
+ trx?: KyselyTransaction,
) {
- return this.db
+ const db = dbOrTx(this.db, trx);
+ return db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
diff --git a/apps/server/src/database/repos/user-token/user-token.repo.ts b/apps/server/src/database/repos/user-token/user-token.repo.ts
index 0137cb0a..edb5414d 100644
--- a/apps/server/src/database/repos/user-token/user-token.repo.ts
+++ b/apps/server/src/database/repos/user-token/user-token.repo.ts
@@ -38,9 +38,9 @@ export class UserTokenRepo {
async insertUserToken(
insertableUserToken: InsertableUserToken,
- trx?: KyselyTransaction,
+ opts?: { trx?: KyselyTransaction },
) {
- const db = dbOrTx(this.db, trx);
+ const db = dbOrTx(this.db, opts?.trx);
return db
.insertInto('userTokens')
.values(insertableUserToken)
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index 5e054650..e4e6d342 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -33,6 +33,7 @@ export class WorkspaceRepo {
'enforceSso',
'plan',
'enforceMfa',
+ 'trashRetentionDays',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
@@ -162,8 +163,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
+ trx?: KyselyTransaction,
) {
- return this.db
+ const db = dbOrTx(this.db, trx);
+ return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -180,8 +183,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
+ trx?: KyselyTransaction,
) {
- return this.db
+ const db = dbOrTx(this.db, trx);
+ return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -198,8 +203,10 @@ export class WorkspaceRepo {
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
+ trx?: KyselyTransaction,
) {
- return this.db
+ const db = dbOrTx(this.db, trx);
+ return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
@@ -211,4 +218,5 @@ export class WorkspaceRepo {
.returning(this.baseFields)
.executeTakeFirst();
}
+
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 01c290a3..ed166b75 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -61,6 +61,21 @@ export interface Attachments {
workspaceId: string;
}
+export interface Audit {
+ actorId: string | null;
+ actorType: Generated;
+ changes: Json | null;
+ createdAt: Generated;
+ event: string;
+ id: Generated;
+ ipAddress: string | null;
+ metadata: Json | null;
+ resourceId: string | null;
+ resourceType: string;
+ spaceId: string | null;
+ workspaceId: string;
+}
+
export interface AuthAccounts {
authProviderId: string | null;
createdAt: Generated;
@@ -339,6 +354,8 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
+ auditRetentionDays: Generated;
+ trashRetentionDays: Generated;
billingEmail: string | null;
createdAt: Generated;
customDomain: string | null;
@@ -415,6 +432,7 @@ export interface PagePermissions {
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
+ audit: Audit;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
@@ -425,9 +443,8 @@ export interface DB {
groupUsers: GroupUsers;
notifications: Notifications;
pageAccess: PageAccess;
- pageHierarchy: PageHierarchy;
- pageHistory: PageHistory;
pagePermissions: PagePermissions;
+ pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index 43e9a241..f8bf9ff7 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -24,6 +24,7 @@ import {
UserMfa as _UserMFA,
ApiKeys,
Watchers,
+ Audit as _Audit,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -155,3 +156,8 @@ export type UpdatablePageAccess = Updateable>;
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable>;
+
+// Audit
+export type Audit = Selectable<_Audit>;
+export type InsertableAudit = Insertable<_Audit>;
+export type UpdatableAudit = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 9e493d75..9157ff1e 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 9e493d75f5435415a1ded7b4d9faef58da06b043
+Subproject commit 9157ff1e6d6ab41fdabba332a66ae9638bf833a0
diff --git a/apps/server/src/integrations/audit/audit.service.ts b/apps/server/src/integrations/audit/audit.service.ts
new file mode 100644
index 00000000..58e9ef9b
--- /dev/null
+++ b/apps/server/src/integrations/audit/audit.service.ts
@@ -0,0 +1,63 @@
+import { Injectable } from '@nestjs/common';
+import { AuditLogPayload, ActorType } from '../../common/events/audit-events';
+
+export type AuditLogContext = {
+ workspaceId: string;
+ actorId?: string;
+ actorType?: ActorType;
+ ipAddress?: string;
+ userAgent?: string;
+};
+
+export type IAuditService = {
+ log(payload: AuditLogPayload): void | Promise;
+ logWithContext(
+ payload: AuditLogPayload,
+ context: AuditLogContext,
+ ): void | Promise;
+ logBatchWithContext(
+ payloads: AuditLogPayload[],
+ context: AuditLogContext,
+ ): void | Promise;
+ setActorId(actorId: string): void;
+ setActorType(actorType: ActorType): void;
+ updateRetention(
+ workspaceId: string,
+ retentionDays: number,
+ ): void | Promise;
+};
+
+export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE');
+
+@Injectable()
+export class NoopAuditService implements IAuditService {
+ log(_payload: AuditLogPayload): void {
+ // No-op: swallow the log when EE module is not available
+ }
+
+ logWithContext(_payload: AuditLogPayload, _context: AuditLogContext): void {
+ // No-op: swallow the log when EE module is not available
+ }
+
+ logBatchWithContext(
+ _payloads: AuditLogPayload[],
+ _context: AuditLogContext,
+ ): void {
+ // No-op: swallow the log when EE module is not available
+ }
+
+ setActorId(_actorId: string): void {
+ // No-op
+ }
+
+ setActorType(_actorType: ActorType): void {
+ // No-op
+ }
+
+ updateRetention(
+ _workspaceId: string,
+ _retentionDays: number,
+ ): void {
+ // No-op
+ }
+}
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index 30624f58..89e4bb81 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -277,4 +277,14 @@ export class EnvironmentService {
'http://localhost:11434',
);
}
+
+ getEventStoreDriver(): string {
+ return this.configService
+ .get('EVENT_STORE_DRIVER', 'postgres')
+ .toLowerCase();
+ }
+
+ getClickHouseUrl(): string {
+ return this.configService.get('CLICKHOUSE_URL');
+ }
}
diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts
index 5f65d018..041d0f4c 100644
--- a/apps/server/src/integrations/environment/environment.validation.ts
+++ b/apps/server/src/integrations/environment/environment.validation.ts
@@ -148,6 +148,22 @@ export class EnvironmentVariables {
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;
+
+ @IsOptional()
+ @IsIn(['postgres', 'clickhouse'])
+ @IsString()
+ EVENT_STORE_DRIVER: string;
+
+ @ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
+ @IsNotEmpty()
+ @IsUrl(
+ { protocols: ['http', 'https'], require_tld: false },
+ {
+ message:
+ 'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
+ },
+ )
+ CLICKHOUSE_URL: string;
}
export function validate(config: Record) {
diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts
index 77e51b29..0fc5fb96 100644
--- a/apps/server/src/integrations/export/export.controller.ts
+++ b/apps/server/src/integrations/export/export.controller.ts
@@ -4,6 +4,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
+ Inject,
NotFoundException,
Post,
Res,
@@ -24,8 +25,13 @@ import {
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
-import { getMimeType } from '../../common/helpers';
+import { getMimeType, getPageTitle } from '../../common/helpers';
import * as path from 'path';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@Controller()
export class ExportController {
@@ -34,6 +40,7 @@ export class ExportController {
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@@ -62,6 +69,20 @@ export class ExportController {
user.id,
);
+ this.auditService.log({
+ event: AuditEvent.PAGE_EXPORTED,
+ resourceType: AuditResource.PAGE,
+ resourceId: page.id,
+ spaceId: page.spaceId,
+ metadata: {
+ title: getPageTitle(page.title),
+ format: dto.format,
+ includeChildren: dto.includeChildren,
+ includeAttachments: dto.includeAttachments,
+ spaceId: page.spaceId,
+ },
+ });
+
const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({
@@ -93,6 +114,18 @@ export class ExportController {
user.id,
);
+ this.auditService.log({
+ event: AuditEvent.SPACE_EXPORTED,
+ resourceType: AuditResource.SPACE,
+ resourceId: dto.spaceId,
+ spaceId: dto.spaceId,
+ metadata: {
+ format: dto.format,
+ includeAttachments: dto.includeAttachments ?? false,
+ spaceName: exportFile.spaceName,
+ },
+ });
+
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index 57fcb681..48be81ba 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -239,6 +239,7 @@ export class ExportService {
return {
fileStream: zipFile,
fileName,
+ spaceName: space.name,
};
}
diff --git a/apps/server/src/integrations/import/import.controller.ts b/apps/server/src/integrations/import/import.controller.ts
index 11842a51..e04e9301 100644
--- a/apps/server/src/integrations/import/import.controller.ts
+++ b/apps/server/src/integrations/import/import.controller.ts
@@ -4,6 +4,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
+ Inject,
Logger,
Post,
Req,
@@ -24,6 +25,11 @@ import * as path from 'path';
import { ImportService } from './services/import.service';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { EnvironmentService } from '../environment/environment.service';
+import { AuditEvent, AuditResource } from '../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../integrations/audit/audit.service';
@Controller()
export class ImportController {
@@ -33,6 +39,7 @@ export class ImportController {
private readonly importService: ImportService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly environmentService: EnvironmentService,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseInterceptors(FileInterceptor)
@@ -83,7 +90,34 @@ export class ImportController {
throw new ForbiddenException();
}
- return this.importService.importPage(file, user.id, spaceId, workspace.id);
+ const createdPage = await this.importService.importPage(
+ file,
+ user.id,
+ spaceId,
+ workspace.id,
+ );
+
+ const ext = path.extname(file.filename).toLowerCase();
+ const sourceMap: Record = {
+ '.md': 'markdown',
+ '.html': 'html',
+ '.docx': 'docx',
+ };
+
+ if (createdPage) {
+ this.auditService.log({
+ event: AuditEvent.PAGE_CREATED,
+ resourceType: AuditResource.PAGE,
+ resourceId: createdPage.id,
+ spaceId,
+ metadata: {
+ source: sourceMap[ext],
+ fileName: file.filename,
+ },
+ });
+ }
+
+ return createdPage;
}
@UseInterceptors(FileInterceptor)
@@ -142,6 +176,18 @@ export class ImportController {
throw new ForbiddenException();
}
+ this.auditService.log({
+ event: AuditEvent.PAGE_IMPORTED,
+ resourceType: AuditResource.PAGE,
+ resourceId: spaceId,
+ spaceId,
+ metadata: {
+ fileName: file.filename,
+ source,
+ spaceId,
+ },
+ });
+
return this.importService.importZip(
file,
source,
diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts
index 8ae79598..6fc223a8 100644
--- a/apps/server/src/integrations/import/services/file-import-task.service.ts
+++ b/apps/server/src/integrations/import/services/file-import-task.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, Logger } from '@nestjs/common';
+import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import { jsonToText } from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
@@ -36,6 +36,11 @@ import { PageService } from '../../../core/page/services/page.service';
import { ImportPageNode } from '../dto/file-task-dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
+import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
+import {
+ AUDIT_SERVICE,
+ IAuditService,
+} from '../../../integrations/audit/audit.service';
@Injectable()
export class FileImportTaskService {
@@ -50,6 +55,7 @@ export class FileImportTaskService {
private readonly importAttachmentService: ImportAttachmentService,
private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
+ @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async processZIpImport(fileTaskId: string): Promise {
@@ -402,6 +408,7 @@ export class FileImportTaskService {
// Process pages level by level sequentially to respect foreign key constraints
const allBacklinks: any[] = [];
const validPageIds = new Set();
+ const pageTitles = new Map();
let totalPagesProcessed = 0;
// Sort levels to process in order
@@ -478,8 +485,9 @@ export class FileImportTaskService {
await trx.insertInto('pages').values(insertablePage).execute();
- // Track valid page IDs and collect backlinks
+ // Track valid page IDs, titles, and collect backlinks
validPageIds.add(insertablePage.id);
+ pageTitles.set(insertablePage.id, insertablePage.title);
allBacklinks.push(...backlinks);
totalPagesProcessed++;
@@ -522,6 +530,26 @@ export class FileImportTaskService {
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
);
});
+
+ if (validPageIds.size > 0) {
+ const auditPayloads = Array.from(validPageIds).map((pageId) => ({
+ event: AuditEvent.PAGE_CREATED,
+ resourceType: AuditResource.PAGE,
+ resourceId: pageId,
+ spaceId: fileTask.spaceId,
+ metadata: {
+ source: fileTask.source,
+ fileTaskId: fileTask.id,
+ title: pageTitles.get(pageId),
+ },
+ }));
+
+ this.auditService.logBatchWithContext(auditPayloads, {
+ workspaceId: fileTask.workspaceId,
+ actorId: fileTask.creatorId,
+ actorType: 'user',
+ });
+ }
} catch (error) {
this.logger.error('Failed to import files:', error);
throw new Error(`File import failed: ${error?.['message']}`);
diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts
index a6aec5c5..231a6c89 100644
--- a/apps/server/src/integrations/import/services/import.service.ts
+++ b/apps/server/src/integrations/import/services/import.service.ts
@@ -49,7 +49,7 @@ export class ImportService {
userId: string,
spaceId: string,
workspaceId: string,
- ): Promise {
+ ) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index c4d47947..1c66a5f3 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -8,6 +8,7 @@ export enum QueueName {
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
+ AUDIT_QUEUE = '{audit-queue}',
}
export enum QueueJob {
@@ -68,4 +69,7 @@ export enum QueueJob {
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
+
+ AUDIT_LOG = 'audit-log',
+ AUDIT_CLEANUP = 'audit-cleanup',
}
diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts
index 6268977f..a7b83c3f 100644
--- a/apps/server/src/integrations/queue/queue.module.ts
+++ b/apps/server/src/integrations/queue/queue.module.ts
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
+ BullModule.registerQueue({
+ name: QueueName.AUDIT_QUEUE,
+ defaultJobOptions: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ attempts: 3,
+ },
+ }),
],
exports: [BullModule],
providers: [GeneralQueueProcessor],
diff --git a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx
index 7fb6cb89..59270e5e 100644
--- a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx
+++ b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx
@@ -17,6 +17,9 @@ export const ForgotPasswordEmail = ({ username, resetLink }: Props) => {
We received a request from you to reset your password.
Click here to set a new password
+
+ This link is valid for 30 minutes.
+
If you did not request a password reset, please ignore this email.
diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts
index e2bf4807..5476664a 100644
--- a/apps/server/src/ws/ws.service.ts
+++ b/apps/server/src/ws/ws.service.ts
@@ -35,7 +35,6 @@ export class WsService {
const pageId = this.extractPageId(data);
if (!pageId) {
- client.broadcast.to(room).emit('message', data);
return;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1ef0abf..bfd70602 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -468,6 +468,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: 3.982.0
version: 3.982.0
+ '@clickhouse/client':
+ specifier: ^1.17.0
+ version: 1.17.0
'@fastify/cookie':
specifier: ^11.0.2
version: 11.0.2
@@ -609,6 +612,9 @@ importers:
nanoid:
specifier: 3.3.11
version: 3.3.11
+ nestjs-cls:
+ specifier: ^6.2.0
+ version: 6.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
nestjs-kysely:
specifier: ^1.2.0
version: 1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2)
@@ -1761,6 +1767,13 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
+ '@clickhouse/client-common@1.17.0':
+ resolution: {integrity: sha512-MiwwgXViFAQA2YZkN4ymF1ynzG0K49KeSX9/iOcmJetWkxqSekDdpyp1GjwATWa9R215uQ+hGzJtJujeQVZZIw==}
+
+ '@clickhouse/client@1.17.0':
+ resolution: {integrity: sha512-Y3DQoamKZ/Iyosoq7Lj7lqpDkQDK4R/5mI52yJs4ZLPIO+d6/CYDqTbFBIb4No3C/AlXUYE4TKhj/kXDpe6rOA==}
+ engines: {node: '>=16'}
+
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -8281,6 +8294,15 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
+ nestjs-cls@6.2.0:
+ resolution: {integrity: sha512-b2Remha7gV5gId3ezjr2tupjqqgYK7/JqjqX6oZ0ZIDFATUggKH1/32+ul2lOe7FepnHasDONDoePuWEE64cug==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@nestjs/common': '>= 10 < 12'
+ '@nestjs/core': '>= 10 < 12'
+ reflect-metadata: '*'
+ rxjs: '>= 7'
+
nestjs-kysely@1.2.0:
resolution: {integrity: sha512-KseCGb0SXCzIYC+Hx3Z3d+kPAfSZCSK6j9UoqUV/gcBCPad9utC7itmoUw0/w5sV+Jf9pc1DKpgClP1IkflA4w==}
peerDependencies:
@@ -11974,6 +11996,12 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
+ '@clickhouse/client-common@1.17.0': {}
+
+ '@clickhouse/client@1.17.0':
+ dependencies:
+ '@clickhouse/client-common': 1.17.0
+
'@colors/colors@1.5.0':
optional: true
@@ -19236,6 +19264,13 @@ snapshots:
neo-async@2.6.2: {}
+ nestjs-cls@6.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2):
+ dependencies:
+ '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.13(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ reflect-metadata: 0.2.2
+ rxjs: 7.8.2
+
nestjs-kysely@1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2):
dependencies:
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)