Merge branch 'main' into feat/scim

This commit is contained in:
Philipinho
2026-02-21 00:38:44 +00:00
225 changed files with 13444 additions and 6465 deletions
+29 -30
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.0-beta.1",
"version": "0.25.3",
"description": "",
"author": "",
"private": true,
@@ -30,40 +30,40 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/core": "^11.1.13",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.13",
"@nestjs/platform-socket.io": "^11.1.13",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11",
"@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cache-manager": "^7.2.8",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
@@ -92,16 +92,15 @@
"pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5",
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"scimmy": "1.3.5",
"sharp": "0.34.3",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
@@ -112,32 +111,32 @@
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.4",
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
"eslint": "^9.20.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"react-email": "5.2.8",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
@@ -1,5 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
@@ -20,6 +27,44 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default');
//});
},
updatePageContent: async (
documentName: string,
payload: {
prosemirrorJson: any;
operation: string;
user: User;
},
) => {
const { prosemirrorJson, operation, user } = payload;
this.logger.debug('Updating page content via yjs', documentName);
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
if (operation === 'replace') {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const newDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
);
},
};
}
@@ -1,4 +1,10 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Global,
Logger,
Module,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@@ -7,9 +13,11 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener';
import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
import { WatcherModule } from '../core/watcher/watcher.module';
@Module({
providers: [
@@ -17,11 +25,12 @@ import { CollaborationHandler } from './collaboration.handler';
AuthenticationExtension,
PersistenceExtension,
LoggerExtension,
HistoryListener,
HistoryProcessor,
CollabHistoryService,
CollaborationHandler,
],
exports: [CollaborationGateway],
imports: [TokenModule],
imports: [TokenModule, WatcherModule],
})
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CollaborationModule.name);
@@ -34,6 +34,7 @@ import {
Highlight,
UniqueID,
addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -42,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
@@ -161,3 +163,37 @@ function stripUnknownNodes(
return json;
}
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
if (node.type === 'text') {
const ytext = new Y.XmlText();
ytext.insert(0, node.text || '');
if (node.marks?.length > 0) {
const attrs: Record<string, any> = {};
for (const mark of node.marks) {
attrs[mark.type] = mark.attrs || true;
}
ytext.format(0, node.text?.length || 0, attrs);
}
return ytext;
}
const element = new Y.XmlElement(node.type);
if (node.attrs) {
for (const [key, value] of Object.entries(node.attrs)) {
if (value !== null && value !== undefined) {
element.setAttribute(key, value as any);
}
}
}
if (node.content?.length > 0) {
const children = node.content.map(prosemirrorNodeToYElement);
element.insert(0, children);
}
return element;
}
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return htmlToMarkdown(html);
}
@@ -0,0 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -13,17 +13,27 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -33,9 +43,11 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@@ -101,6 +113,7 @@ export class PersistenceExtension implements Extension {
}
let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try {
await executeTx(this.db, async (trx) => {
@@ -123,13 +136,9 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]),
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
);
this.contributors.delete(documentName);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
@@ -153,13 +162,7 @@ export class PersistenceExtension implements Extension {
}
if (page) {
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
@@ -170,16 +173,37 @@ export class PersistenceExtension implements Extension {
mentions: pageMentions,
} as IPageBacklinkJob);
const userMentions = extractUserMentions(mentions);
const oldMentions = page.content ? extractMentions(page.content) : [];
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
if (userMentions.length > 0) {
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
userMentions: userMentions.map((m) => ({
userId: m.entityId,
mentionId: m.id,
creatorId: m.creatorId,
})),
oldMentionedUserIds,
pageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
} as IPageMentionNotificationJob);
}
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
await this.enqueuePageHistory(page);
}
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user.id;
const userId = data.context?.user?.id;
if (!userId) return;
if (!this.contributors.has(documentName)) {
@@ -193,4 +217,26 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName;
this.contributors.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
}
@@ -1,44 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
) {
try {
await this.pageHistoryRepo.saveHistory(page);
this.logger.debug(`New history created for: ${page.id}`);
} catch (err) {
this.logger.error(`Failed to create history for page: ${page.id}`, err);
}
}
}
}
@@ -0,0 +1,93 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
page.spaceId,
page.workspaceId,
);
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -9,6 +9,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({
imports: [
@@ -19,6 +21,9 @@ import { LoggerModule } from '../../common/logger/logger.module';
QueueModule,
HealthModule,
EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
],
controllers: [
AppController,
@@ -7,6 +7,7 @@ import {
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -19,7 +20,7 @@ async function bootstrap() {
},
}),
{
logger: false,
logger: new InternalLogFilter(),
bufferLogs: false,
},
);
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
+177
View File
@@ -0,0 +1,177 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
import { tiptapExtensions } from './collaboration.util';
export type YjsSelection = {
anchor: any;
head: any;
};
export function setYjsMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: YjsSelection,
markName: string,
markAttributes: Record<string, any>,
) {
const schema = getSchema(tiptapExtensions);
const { mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
if (anchor === null || head === null) {
throw new Error(
'Could not resolve Y.js relative positions to absolute positions',
);
}
const from = Math.min(anchor, head);
const to = Math.max(anchor, head);
// Apply mark directly to Y.js XmlText nodes
// This bypasses updateYFragment which has compatibility issues
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
}
function applyMarkToYFragment(
fragment: Y.XmlFragment,
from: number,
to: number,
markName: string,
markAttributes: Record<string, any>,
) {
let pos = 0;
const processItem = (item: any): boolean => {
if (pos >= to) return false;
if (item instanceof Y.XmlText) {
const textLength = item.length;
const itemEnd = pos + textLength;
if (itemEnd > from && pos < to) {
const formatFrom = Math.max(0, from - pos);
const formatTo = Math.min(textLength, to - pos);
const formatLength = formatTo - formatFrom;
if (formatLength > 0) {
item.format(formatFrom, formatLength, { [markName]: markAttributes });
}
}
pos = itemEnd;
} else if (item instanceof Y.XmlElement) {
pos++; // Opening tag
for (let i = 0; i < item.length; i++) {
if (!processItem(item.get(i))) return false;
}
pos++; // Closing tag
}
return true;
};
for (let i = 0; i < fragment.length; i++) {
if (!processItem(fragment.get(i))) break;
}
}
/**
* Removes a mark from all text in the fragment that has the specified attribute value.
* Useful for deleting comments by commentId.
*/
export function removeYjsMarkByAttribute(
fragment: Y.XmlFragment,
markName: string,
attributeName: string,
attributeValue: string,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
// Get all formatting deltas to find ranges with this mark
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[attributeName] === attributeValue) {
// Remove the mark by setting it to null
item.format(offset, length, { [markName]: null });
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
*/
export function updateYjsMarkAttribute(
fragment: Y.XmlFragment,
markName: string,
findByAttribute: { name: string; value: string },
newAttributes: Record<string, any>,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (
markAttr &&
markAttr[findByAttribute.name] === findByAttribute.value
) {
// Update the mark with new attributes (merge with existing)
item.format(offset, length, {
[markName]: { ...markAttr, ...newAttributes },
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
@@ -9,3 +9,7 @@ export const LOCAL_STORAGE_PATH = path.resolve(
'..',
LOCAL_STORAGE_DIR,
);
export function getPageTitle(title: string | null | undefined): string {
return title || 'untitled';
}
@@ -64,6 +64,30 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
return pageMentionList as MentionNode[];
}
export function extractUserMentionIdsFromJson(json: any): string[] {
const userIds: string[] = [];
function walk(node: any) {
if (!node) return;
if (
node.type === 'mention' &&
node.attrs?.entityType === 'user' &&
node.attrs?.entityId &&
!userIds.includes(node.attrs.entityId)
) {
userIds.push(node.attrs.entityId);
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return userIds;
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
@@ -1,7 +1,8 @@
import { ConsoleLogger } from '@nestjs/common';
import { ConsoleLogger, LogLevel } from '@nestjs/common';
export class InternalLogFilter extends ConsoleLogger {
static contextsToIgnore = [
'NestFactory',
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
@@ -11,14 +12,23 @@ export class InternalLogFilter extends ConsoleLogger {
private allowedLogLevels: string[];
constructor() {
super();
const isProduction = process.env.NODE_ENV === 'production';
super({
json: isProduction,
});
const isDebugMode = process.env.DEBUG_MODE === 'true';
if (isProduction && !isDebugMode) {
this.allowedLogLevels = ['log', 'error', 'fatal'];
this.allowedLogLevels = ['info', 'error', 'fatal'];
} else {
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
this.allowedLogLevels = [
'info',
'debug',
'verbose',
'warn',
'error',
'fatal',
];
}
}
@@ -28,9 +38,8 @@ export class InternalLogFilter extends ConsoleLogger {
log(_: any, context?: string): void {
if (
this.isLogLevelAllowed('log') &&
(process.env.NODE_ENV !== 'production' ||
!InternalLogFilter.contextsToIgnore.includes(context))
this.isLogLevelAllowed('info') &&
!InternalLogFilter.contextsToIgnore.includes(context)
) {
super.log.apply(this, arguments);
}
@@ -59,4 +68,15 @@ export class InternalLogFilter extends ConsoleLogger {
super.verbose.apply(this, arguments);
}
}
protected printMessages(
messages: unknown[],
context?: string,
logLevel?: LogLevel,
writeStreamType?: 'stdout' | 'stderr',
errorStack?: unknown,
): void {
const level = logLevel === 'log' ? ('info' as LogLevel) : logLevel;
super.printMessages(messages, context, level, writeStreamType, errorStack);
}
}
@@ -17,13 +17,13 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@@ -181,22 +182,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -205,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@@ -243,22 +230,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'public');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -433,4 +405,70 @@ export class AttachmentController {
return;
}
}
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
}
@@ -2,7 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
import { getMimeType } from '../../common/helpers/file.helper';
export interface PreparedFile {
buffer?: Buffer;
@@ -41,7 +41,7 @@ export async function prepareFile(
fileName,
fileSize,
fileExtension,
mimeType: file.mimetype,
mimeType: getMimeType(file.originalname),
multiPartFile: file,
};
} catch (error) {
@@ -77,51 +77,3 @@ export function getAttachmentFolderPath(
}
export const validAttachmentTypes = Object.values(AttachmentType);
export async function compressAndResizeIcon(
buffer: Buffer,
attachmentType?: AttachmentType,
): Promise<Buffer> {
try {
let sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
const targetWidth = 300;
const targetHeight = 300;
// Only resize if image is larger than target dimensions
if (metadata.width > targetWidth || metadata.height > targetHeight) {
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Handle based on original format
if (metadata.format === 'png') {
// Only flatten avatars to remove transparency
if (attachmentType === AttachmentType.Avatar) {
sharpInstance = sharpInstance.flatten({
background: { r: 255, g: 255, b: 255 },
});
}
return await sharpInstance
.png({
quality: 85,
compressionLevel: 6,
})
.toBuffer();
} else {
return await sharpInstance
.jpeg({
quality: 85,
progressive: true,
mozjpeg: true,
})
.toBuffer();
}
} catch (err) {
throw err;
}
}
@@ -8,7 +8,6 @@ import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
compressAndResizeIcon,
getAttachmentFolderPath,
PreparedFile,
prepareFile,
@@ -99,6 +98,7 @@ export class AttachmentService {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
fileSize: preparedFile.fileSize,
updatedAt: new Date(),
},
attachmentId,
@@ -153,12 +153,6 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
@@ -3,7 +3,6 @@ import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
@Module({
imports: [],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
@@ -2,8 +2,11 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
@@ -11,12 +14,21 @@ import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
@Injectable()
export class CommentService {
private readonly logger = new Logger(CommentService.name);
constructor(
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue,
) {}
async findById(commentId: string) {
@@ -51,7 +63,7 @@ export class CommentService {
}
}
return await this.commentRepo.insertComment({
const comment = await this.commentRepo.insertComment({
pageId: page.id,
content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250),
@@ -61,6 +73,33 @@ export class CommentService {
workspaceId: workspaceId,
spaceId: page.spaceId,
});
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
const isReply = !!createCommentDto.parentCommentId;
await this.queueCommentNotification(
commentContent,
[],
comment.id,
page.id,
page.spaceId,
workspaceId,
userId,
!isReply,
createCommentDto.parentCommentId,
);
return comment;
}
async findByPageId(
@@ -87,6 +126,8 @@ export class CommentService {
throw new ForbiddenException('You can only edit your own comments');
}
const oldMentionIds = extractUserMentionIdsFromJson(comment.content);
const editedAt = new Date();
await this.commentRepo.updateComment(
@@ -97,10 +138,57 @@ export class CommentService {
},
comment.id,
);
await this.queueCommentNotification(
commentContent,
oldMentionIds,
comment.id,
comment.pageId,
comment.spaceId,
comment.workspaceId,
authUser.id,
false,
);
comment.content = commentContent;
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
return comment;
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
commentId: string,
pageId: string,
spaceId: string,
workspaceId: string,
actorId: string,
notifyWatchers: boolean,
parentCommentId?: string,
) {
const mentionedUserIds = extractUserMentionIdsFromJson(content);
const newMentionIds = mentionedUserIds.filter(
(id) => id !== actorId && !oldMentionIds.includes(id),
);
if (newMentionIds.length === 0 && !notifyWatchers && !parentCommentId) return;
const jobData: ICommentNotificationJob = {
commentId,
parentCommentId,
pageId,
spaceId,
workspaceId,
actorId,
mentionedUserIds: newMentionIds,
notifyWatchers,
};
await this.notificationQueue.add(
QueueJob.COMMENT_NOTIFICATION,
jobData,
);
}
}
+4
View File
@@ -16,6 +16,8 @@ import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
@Module({
imports: [
@@ -30,6 +32,8 @@ import { ShareModule } from './share/share.module';
GroupModule,
CaslModule,
ShareModule,
NotificationModule,
WatcherModule,
],
})
export class CoreModule implements NestModule {
@@ -4,6 +4,7 @@ import { GroupController } from './group.controller';
import { GroupUserService } from './services/group-user.service';
@Module({
imports: [],
controllers: [GroupController],
providers: [GroupService, GroupUserService],
exports: [GroupService, GroupUserService],
@@ -10,16 +10,21 @@ import { GroupService } from './group.service';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
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 { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class GroupUserService {
constructor(
private groupUserRepo: GroupUserRepo,
private spaceMemberRepo: SpaceMemberRepo,
private userRepo: UserRepo,
@Inject(forwardRef(() => GroupService))
private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -107,6 +112,18 @@ export class GroupUserService {
throw new BadRequestException('Group member not found');
}
await this.groupUserRepo.delete(userId, groupId);
const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupId(groupId);
// TODO: use queue instead
await executeTx(this.db, async (trx) => {
await this.groupUserRepo.delete(userId, groupId, { trx });
for (const spaceId of spaceIds) {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
);
}
});
}
}
@@ -8,18 +8,27 @@ import {
import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { UpdateGroupDto } from '../dto/update-group.dto';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
@Injectable()
export class GroupService {
constructor(
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private spaceMemberRepo: SpaceMemberRepo,
@Inject(forwardRef(() => GroupUserService))
private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
@@ -68,20 +77,6 @@ export class GroupService {
return createdGroup;
}
async createDefaultGroup(
workspaceId: string,
userId?: string,
trx?: KyselyTransaction,
): Promise<Group> {
const insertableGroup: InsertableGroup = {
name: DefaultGroup.EVERYONE,
isDefault: true,
creatorId: userId ?? null,
workspaceId: workspaceId,
};
return await this.groupRepo.insertGroup(insertableGroup, trx);
}
async updateGroup(
workspaceId: string,
updateGroupDto: UpdateGroupDto,
@@ -141,7 +136,24 @@ export class GroupService {
if (group.isDefault) {
throw new BadRequestException('You cannot delete a default group');
}
await this.groupRepo.delete(groupId, workspaceId);
const [userIds, spaceIds] = await Promise.all([
this.groupUserRepo.getUserIdsByGroupId(groupId),
this.spaceMemberRepo.getSpaceIdsByGroupId(groupId),
]);
// TODO: use queue instead
await executeTx(this.db, async (trx) => {
await this.groupRepo.delete(groupId, workspaceId, { trx });
for (const spaceId of spaceIds) {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
userIds,
spaceId,
{ trx },
);
}
});
}
async findAndValidateGroup(
@@ -0,0 +1,13 @@
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class NotificationIdDto {
@IsUUID()
notificationId: string;
}
export class MarkNotificationsReadDto {
@IsArray()
@IsUUID(undefined, { each: true })
@IsOptional()
notificationIds?: string[];
}
@@ -0,0 +1,9 @@
export const NotificationType = {
COMMENT_USER_MENTION: 'comment.user_mention',
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
@@ -0,0 +1,56 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { NotificationService } from './notification.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User } from '@docmost/db/types/entity.types';
import { MarkNotificationsReadDto } from './dto/notification.dto';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
return this.notificationService.findByUserId(user.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('unread-count')
async getUnreadCount(@AuthUser() user: User) {
const count = await this.notificationService.getUnreadCount(user.id);
return { count };
}
@HttpCode(HttpStatus.OK)
@Post('mark-read')
async markAsRead(
@Body() dto: MarkNotificationsReadDto,
@AuthUser() user: User,
) {
if (dto.notificationIds?.length) {
await this.notificationService.markMultipleAsRead(
dto.notificationIds,
user.id,
);
}
}
@HttpCode(HttpStatus.OK)
@Post('mark-all-read')
async markAllAsRead(@AuthUser() user: User) {
await this.notificationService.markAllAsRead(user.id);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { WsModule } from '../../ws/ws.module';
@Module({
imports: [WsModule],
controllers: [NotificationController],
providers: [
NotificationService,
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -0,0 +1,101 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { DomainService } from '../../integrations/environment/domain.service';
@Processor(QueueName.NOTIFICATION_QUEUE)
export class NotificationProcessor
extends WorkerHost
implements OnModuleDestroy
{
private readonly logger = new Logger(NotificationProcessor.name);
constructor(
private readonly commentNotificationService: CommentNotificationService,
private readonly pageNotificationService: PageNotificationService,
private readonly domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
}
async process(
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
void
>,
): Promise<void> {
try {
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
const appUrl = await this.getWorkspaceUrl(workspaceId);
switch (job.name) {
case QueueJob.COMMENT_NOTIFICATION: {
await this.commentNotificationService.processComment(
job.data as ICommentNotificationJob,
appUrl,
);
break;
}
case QueueJob.COMMENT_RESOLVED_NOTIFICATION: {
await this.commentNotificationService.processResolved(
job.data as ICommentResolvedNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_MENTION_NOTIFICATION: {
await this.pageNotificationService.processPageMention(
job.data as IPageMentionNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
this.logger.error(`Failed to process ${job.name}: ${message}`);
throw err;
}
}
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
.select('hostname')
.where('id', '=', workspaceId)
.executeTakeFirst();
return this.domainService.getUrl(workspace?.hostname);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { InsertableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async create(data: InsertableNotification) {
const notification = await this.notificationRepo.insert(data);
this.wsGateway.server
.to(`user-${data.userId}`)
.emit('notification', { id: notification.id, type: notification.type });
return notification;
}
async findByUserId(userId: string, pagination: PaginationOptions) {
return this.notificationRepo.findByUserId(userId, pagination);
}
async getUnreadCount(userId: string) {
return this.notificationRepo.getUnreadCount(userId);
}
async markAsRead(notificationId: string, userId: string) {
return this.notificationRepo.markAsRead(notificationId, userId);
}
async markMultipleAsRead(notificationIds: string[], userId: string) {
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
}
async markAllAsRead(userId: string) {
return this.notificationRepo.markAllAsRead(userId);
}
async queueEmail(
userId: string,
notificationId: string,
subject: string,
template: any,
) {
try {
const user = await this.db
.selectFrom('users')
.select(['email'])
.where('id', '=', userId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
if (!user?.email) return;
await this.mailService.sendToQueue({
to: user.email,
subject,
template,
notificationId,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
this.logger.error(
`Failed to queue email for notification ${notificationId}: ${message}`,
);
}
}
}
@@ -0,0 +1,219 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
export class CommentNotificationService {
private readonly logger = new Logger(CommentNotificationService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly watcherRepo: WatcherRepo,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
const {
commentId,
parentCommentId,
pageId,
spaceId,
workspaceId,
actorId,
mentionedUserIds,
notifyWatchers,
} = data;
const context = await this.getCommentContext(
actorId,
pageId,
spaceId,
commentId,
appUrl,
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
const recipientIds = parentCommentId
? await this.getThreadParticipantIds(parentCommentId)
: notifyWatchers
? await this.watcherRepo.getPageWatcherIds(pageId)
: [];
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.COMMENT_USER_MENTION,
actorId,
pageId,
spaceId,
commentId,
});
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
notifiedUserIds.add(userId);
}
for (const recipientId of recipientIds) {
if (notifiedUserIds.has(recipientId)) continue;
if (!usersWithAccess.has(recipientId)) continue;
const notification = await this.notificationService.create({
userId: recipientId,
workspaceId,
type: NotificationType.COMMENT_CREATED,
actorId,
pageId,
spaceId,
commentId,
});
await this.notificationService.queueEmail(
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
}
async processResolved(data: ICommentResolvedNotificationJob, appUrl: string) {
const {
commentId,
commentCreatorId,
pageId,
spaceId,
workspaceId,
actorId,
} = data;
if (commentCreatorId === actorId) return;
const context = await this.getCommentContext(
actorId,
pageId,
spaceId,
commentId,
appUrl,
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
spaceId,
);
if (!roles) {
this.logger.debug(
`Skipping resolved notification for user ${commentCreatorId}: no access to space ${spaceId}`,
);
return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
type: NotificationType.COMMENT_RESOLVED,
actorId,
pageId,
spaceId,
commentId,
});
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
await this.notificationService.queueEmail(
commentCreatorId,
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
private async getThreadParticipantIds(
parentCommentId: string,
): Promise<string[]> {
const participants = await this.db
.selectFrom('comments')
.select('creatorId')
.where((eb) =>
eb.or([
eb('id', '=', parentCommentId),
eb('parentCommentId', '=', parentCommentId),
]),
)
.execute();
return [...new Set(participants.map((p) => p.creatorId))];
}
private async getCommentContext(
actorId: string,
pageId: string,
spaceId: string,
commentId: string,
appUrl: string,
) {
const [actor, page, space] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', actorId)
.executeTakeFirst(),
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!actor || !page || !space) {
return null;
}
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
}
}
@@ -0,0 +1,132 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
export class PageNotificationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
const { userMentions, oldMentionedUserIds, pageId, spaceId, workspaceId } =
data;
const oldIds = new Set(oldMentionedUserIds);
const newMentions = userMentions.filter(
(m) => !oldIds.has(m.userId) && m.creatorId !== m.userId,
);
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
if (accessibleMentions.length === 0) return;
const mentionsByCreator = new Map<
string,
{ userId: string; mentionId: string }[]
>();
for (const m of accessibleMentions) {
const list = mentionsByCreator.get(m.creatorId) || [];
list.push({ userId: m.userId, mentionId: m.mentionId });
mentionsByCreator.set(m.creatorId, list);
}
for (const [actorId, mentions] of mentionsByCreator) {
await this.notifyMentionedUsers(
mentions,
actorId,
pageId,
spaceId,
workspaceId,
appUrl,
);
}
}
private async notifyMentionedUsers(
mentions: { userId: string; mentionId: string }[],
actorId: string,
pageId: string,
spaceId: string,
workspaceId: string,
appUrl: string,
) {
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
for (const { userId, mentionId } of mentions) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_USER_MENTION,
actorId,
pageId,
spaceId,
data: { mentionId },
});
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
}
private async getPageContext(
actorId: string,
pageId: string,
spaceId: string,
appUrl: string,
) {
const [actor, page, space] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', actorId)
.executeTakeFirst(),
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!actor || !page || !space) {
return null;
}
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
}
}
@@ -1,4 +1,13 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentFormat = 'json' | 'markdown' | 'html';
export class CreatePageDto {
@IsOptional()
@@ -15,4 +24,12 @@ export class CreatePageDto {
@IsUUID()
spaceId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
}
@@ -1,10 +1,14 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ContentFormat } from './create-page.dto';
export class PageIdDto {
@IsString()
@@ -30,6 +34,11 @@ export class PageInfoDto extends PageIdDto {
@IsOptional()
@IsBoolean()
includeContent: boolean;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
}
export class DeletePageDto extends PageIdDto {
@@ -1,8 +1,24 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto';
import { IsString } from 'class-validator';
import { CreatePageDto, ContentFormat } from './create-page.dto';
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentOperation = 'append' | 'prepend' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString()
pageId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['append', 'prepend', 'replace'])
operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
}
+53 -3
View File
@@ -35,6 +35,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -66,6 +70,17 @@ export class PageController {
throw new ForbiddenException();
}
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
dto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
...page,
content: contentOutput,
};
}
return page;
}
@@ -84,7 +99,25 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.create(user.id, workspace.id, createPageDto);
const page = await this.pageService.create(
user.id,
workspace.id,
createPageDto,
);
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
page.content
) {
const contentOutput =
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
}
return page;
}
@HttpCode(HttpStatus.OK)
@@ -101,7 +134,25 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.update(page, updatePageDto, user.id);
const updatedPage = await this.pageService.update(
page,
updatePageDto,
user,
);
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
updatedPage.content
) {
const contentOutput =
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
}
return updatedPage;
}
@HttpCode(HttpStatus.OK)
@@ -215,7 +266,6 @@ export class PageController {
}
}
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
+3 -1
View File
@@ -4,11 +4,13 @@ import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule],
imports: [StorageModule, CollaborationModule, WatcherModule],
})
export class PageModule {}
@@ -9,7 +9,9 @@ export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId);
return await this.pageHistoryRepo.findById(historyId, {
includeContent: true,
});
}
async findHistoryByPageId(
@@ -4,8 +4,8 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -18,6 +18,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid';
@@ -28,7 +29,11 @@ import {
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
import {
htmlToJson,
jsonToNode,
jsonToText,
} from 'src/collaboration/collaboration.util';
import {
CopyPageMapEntry,
ICopyPageAttachment,
@@ -40,6 +45,9 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
@Injectable()
export class PageService {
@@ -52,7 +60,10 @@ export class PageService {
private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
) {}
async findById(
@@ -88,7 +99,22 @@ export class PageService {
parentPageId = parentPage.id;
}
const createdPage = await this.pageRepo.insertPage({
let content = undefined;
let textContent = undefined;
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.format) {
const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content,
createPageDto.format,
);
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson);
}
const page = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
@@ -101,9 +127,23 @@ export class PageService {
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
content,
textContent,
ydoc,
});
return createdPage;
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
pageId: page.id,
spaceId: createPageDto.spaceId,
workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
return page;
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -150,23 +190,48 @@ export class PageService {
async update(
page: Page,
updatePageDto: UpdatePageDto,
userId: string,
user: User,
): Promise<Page> {
const contributors = new Set<string>(page.contributorIds);
contributors.add(userId);
contributors.add(user.id);
const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage(
{
title: updatePageDto.title,
icon: updatePageDto.icon,
lastUpdatedById: userId,
lastUpdatedById: user.id,
updatedAt: new Date(),
contributorIds: contributorIds,
},
page.id,
);
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.format
) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.operation,
updatePageDto.format,
user,
);
}
return await this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
@@ -176,6 +241,23 @@ export class PageService {
});
}
async updatePageContent(
pageId: string,
content: string | object,
operation: ContentOperation,
format: ContentFormat,
user: User,
): Promise<void> {
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
const documentName = `page.${pageId}`;
await this.collaborationGateway.handleYjsEvent(
'updatePageContent',
documentName,
{ operation, prosemirrorJson, user },
);
}
async getSidebarPages(
spaceId: string,
pagination: PaginationOptions,
@@ -209,7 +291,11 @@ export class PageService {
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
{
expression: 'position',
direction: 'asc',
orderModifier: (ob) => ob.collate('C').asc(),
},
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
@@ -263,6 +349,11 @@ export class PageService {
trx,
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
workspaceId: rootPage.workspaceId,
@@ -376,7 +467,7 @@ export class PageService {
// Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title;
if (isDuplicateInSameSpace && page.id === rootPage.id) {
const originalTitle = page.title || 'Untitled';
const originalTitle = getPageTitle(page.title);
title = `Copy of ${originalTitle}`;
}
@@ -653,4 +744,36 @@ export class PageService {
): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId);
}
private async parseProsemirrorContent(
content: string | object,
format: ContentFormat,
): Promise<any> {
let prosemirrorJson: any;
switch (format) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
return prosemirrorJson;
}
}
+41 -2
View File
@@ -64,8 +64,18 @@ export class ShareController {
throw new BadRequestException();
}
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return {
...(await this.shareService.getSharedPage(dto, workspace.id)),
...shareData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -86,6 +96,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share;
}
@@ -127,6 +145,14 @@ export class ShareController {
throw new ForbiddenException();
}
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({
page,
authUserId: user.id,
@@ -176,8 +202,21 @@ export class ShareController {
@Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace,
) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return {
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
...treeData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -264,6 +264,31 @@ export class ShareService {
return ancestor;
}
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,10 +1,14 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString()
@IsNotEmpty()
@IsUUID()
spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -6,6 +6,7 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
@@ -14,12 +15,16 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
@Injectable()
export class SpaceMemberService {
constructor(
private spaceMemberRepo: SpaceMemberRepo,
private groupUserRepo: GroupUserRepo,
private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -203,10 +208,28 @@ export class SpaceMemberService {
await this.validateLastAdmin(dto.spaceId);
}
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
);
let affectedUserIds: string[] = [];
if (dto.userId) {
affectedUserIds = [dto.userId];
} else if (dto.groupId) {
affectedUserIds = await this.groupUserRepo.getUserIdsByGroupId(
dto.groupId,
);
}
await executeTx(this.db, async (trx) => {
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
{ trx },
);
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
affectedUserIds,
dto.spaceId,
{ trx },
);
});
}
async updateSpaceMemberRole(
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,12 +18,18 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
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';
@Injectable()
export class SpaceService {
constructor(
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
private shareRepo: ShareRepo,
private workspaceRepo: WorkspaceRepo,
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
@@ -105,6 +112,31 @@ export class SpaceService {
}
}
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'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,
@@ -4,6 +4,7 @@ import { SpaceController } from './space.controller';
import { SpaceMemberService } from './services/space-member.service';
@Module({
imports: [],
controllers: [SpaceController],
providers: [SpaceService, SpaceMemberService],
exports: [SpaceService, SpaceMemberService],
@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class WatcherPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
@@ -0,0 +1,99 @@
/***
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WatcherPageDto } from './dto/watcher.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('pages')
export class WatcherController {
constructor(
private readonly watcherService: WatcherService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('watch')
async watchPage(
@Body() dto: WatcherPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.watcherService.watchPage(
user.id,
page.id,
page.spaceId,
workspace.id,
);
return { watching: true };
}
@HttpCode(HttpStatus.OK)
@Post('unwatch')
async unwatchPage(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.watcherService.unwatchPage(user.id, page.id);
return { watching: false };
}
@HttpCode(HttpStatus.OK)
@Post('watch-status')
async getWatchStatus(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
return { watching };
}
}
***/
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { CaslModule } from '../casl/casl.module';
@Module({
imports: [CaslModule],
controllers: [],
providers: [WatcherService],
exports: [WatcherService],
})
export class WatcherModule {}
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import {
WatcherRepo,
WatcherType,
} from '@docmost/db/repos/watcher/watcher.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InsertableWatcher } from '@docmost/db/types/entity.types';
@Injectable()
export class WatcherService {
constructor(private readonly watcherRepo: WatcherRepo) {}
async watchPage(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const watcher: InsertableWatcher = {
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
};
return this.watcherRepo.upsert(watcher, trx);
}
async addPageWatchers(
userIds: string[],
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
if (userIds.length === 0) return;
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
}));
return this.watcherRepo.insertMany(watchers, trx);
}
async unwatchPage(userId: string, pageId: string) {
return this.watcherRepo.mute(userId, pageId);
}
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
return this.watcherRepo.isWatching(userId, pageId);
}
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
return this.watcherRepo.findPageWatchers(pageId, pagination);
}
async getPageWatcherIds(
pageId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
return this.watcherRepo.getPageWatcherIds(pageId, trx);
}
async countPageWatchers(pageId: string): Promise<number> {
return this.watcherRepo.countPageWatchers(pageId);
}
async cleanupOnSpaceAccessChange(
userIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(userIds, spaceId, {
trx,
});
}
async movePageWatchersToSpace(
pageIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
await this.watcherRepo.updateSpaceIdByPageIds(spaceId, pageIds, opts);
await this.watcherRepo.deleteByPageIdsWithoutSpaceAccess(
pageIds,
spaceId,
opts,
);
}
}
@@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -5,6 +5,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
@@ -33,6 +34,8 @@ import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } 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';
@Injectable()
export class WorkspaceService {
@@ -47,6 +50,9 @@ export class WorkspaceService {
private userRepo: UserRepo,
private environmentService: EnvironmentService,
private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -112,6 +118,7 @@ export class WorkspaceService {
let status = undefined;
let plan = undefined;
let billingEmail = undefined;
let settings = undefined;
if (this.environmentService.isCloud()) {
// generate unique hostname
@@ -125,6 +132,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
settings = { ai: { generative: true } };
}
// create workspace
@@ -137,6 +145,7 @@ export class WorkspaceService {
trialEndAt,
plan,
billingEmail,
settings,
},
trx,
);
@@ -358,6 +367,32 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi;
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.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);
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -523,6 +558,10 @@ export class WorkspaceService {
.deleteFrom('authAccounts')
.where('userId', '=', userId)
.execute();
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
trx,
});
});
try {
@@ -24,6 +24,8 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
@@ -80,6 +82,8 @@ import { normalizePostgresUrl } from '../common/helpers';
UserTokenRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
WatcherRepo,
PageListener,
],
exports: [
@@ -96,6 +100,8 @@ import { normalizePostgresUrl } from '../common/helpers';
UserTokenRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
WatcherRepo,
],
})
export class DatabaseModule
@@ -0,0 +1,9 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').dropColumn('settings').execute();
}
@@ -0,0 +1,15 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -0,0 +1,53 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('notifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('actor_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade'),
)
.addColumn('comment_id', 'uuid', (col) =>
col.references('comments.id').onDelete('cascade'),
)
.addColumn('data', 'jsonb')
.addColumn('read_at', 'timestamptz')
.addColumn('emailed_at', 'timestamptz')
.addColumn('archived_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_notifications_user_id')
.on('notifications')
.columns(['user_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_notifications_user_unread')
.on('notifications')
.column('user_id')
.where(sql.ref('read_at'), 'is', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('notifications').execute();
}
@@ -0,0 +1,57 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('watchers')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('muted_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_watchers_user_page')
.on('watchers')
.columns(['user_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
await db.schema
.createIndex('idx_watchers_user_space')
.on('watchers')
.columns(['user_id', 'space_id'])
.unique()
.where(sql.ref('page_id'), 'is', null)
.execute();
// Query index for fetching watchers by page
await db.schema
.createIndex('idx_watchers_page_id')
.on('watchers')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('watchers').execute();
}
@@ -0,0 +1,29 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Backfill watchers from pages.contributorIds and pages.creatorId
// This inserts unique user-page combinations from both sources
await sql`
INSERT INTO watchers (user_id, page_id, space_id, workspace_id, type, added_by_id)
SELECT DISTINCT
u.user_id,
p.id as page_id,
p.space_id,
p.workspace_id,
'page' as type,
u.user_id as added_by_id
FROM pages p
CROSS JOIN LATERAL (
SELECT unnest(p.contributor_ids) as user_id
UNION
SELECT p.creator_id as user_id WHERE p.creator_id IS NOT NULL
) u
WHERE p.deleted_at IS NULL
AND u.user_id IS NOT NULL
ON CONFLICT DO NOTHING
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM watchers WHERE type = 'page'`.execute(db);
}
@@ -56,7 +56,11 @@ export class GroupUserRepo {
if (pagination.query) {
query = query.where((eb) =>
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
eb(
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
@@ -147,8 +151,25 @@ export class GroupUserRepo {
);
}
async delete(userId: string, groupId: string): Promise<void> {
await this.db
async getUserIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('groupUsers')
.select('userId')
.where('groupId', '=', groupId)
.execute();
return rows.map((r) => r.userId);
}
async delete(
userId: string,
groupId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('groupUsers')
.where('userId', '=', userId)
.where('groupId', '=', groupId)
@@ -155,8 +155,15 @@ export class GroupRepo {
.as('memberCount');
}
async delete(groupId: string, workspaceId: string): Promise<void> {
await this.db
async delete(
groupId: string,
workspaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('groups')
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '../../types/kysely.types';
import {
InsertableNotification,
Notification,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class NotificationRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(notificationId: string): Promise<Notification | undefined> {
return this.db
.selectFrom('notifications')
.selectAll('notifications')
.where('id', '=', notificationId)
.executeTakeFirst();
}
async findByUserId(userId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('notifications')
.selectAll('notifications')
.select((eb) => this.withActor(eb))
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb))
.where('userId', '=', userId)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
async getUnreadCount(userId: string): Promise<number> {
const result = await this.db
.selectFrom('notifications')
.select((eb) => eb.fn.count('id').as('count'))
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async insert(notification: InsertableNotification): Promise<Notification> {
return this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirst();
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ readAt: new Date() })
.where('id', '=', notificationId)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
async markMultipleAsRead(
notificationIds: string[],
userId: string,
): Promise<void> {
if (notificationIds.length === 0) {
return;
}
await this.db
.updateTable('notifications')
.set({ readAt: new Date() })
.where('id', 'in', notificationIds)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
async markAsEmailed(notificationId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ emailedAt: new Date() })
.where('id', '=', notificationId)
.where('emailedAt', 'is', null)
.execute();
}
async markAllAsRead(userId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ readAt: new Date() })
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'notifications.actorId'),
).as('actor');
}
withPage(eb: ExpressionBuilder<DB, 'notifications'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
.whereRef('pages.id', '=', 'notifications.pageId'),
).as('page');
}
withSpace(eb: ExpressionBuilder<DB, 'notifications'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
.whereRef('spaces.id', '=', 'notifications.spaceId'),
).as('space');
}
}
@@ -9,24 +9,43 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
@Injectable()
export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'createdAt',
];
async findById(
pageHistoryId: string,
trx?: KyselyTransaction,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> {
const db = dbOrTx(this.db, trx);
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId)
.executeTakeFirst();
}
@@ -43,7 +62,10 @@ export class PageHistoryRepo {
.executeTakeFirst();
}
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory(
{
pageId: page.id,
@@ -53,18 +75,20 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
},
trx,
opts?.trx,
);
}
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
@@ -76,12 +100,19 @@ export class PageHistoryRepo {
});
}
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
const db = dbOrTx(this.db, trx);
async findPageLastHistory(
pageId: string,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId)
.limit(1)
.orderBy('createdAt', 'desc')
@@ -96,4 +127,17 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
}
@@ -136,6 +136,20 @@ export class ShareRepo {
await query.execute();
}
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('shares')
@@ -73,8 +73,9 @@ export class SpaceMemberRepo {
async removeSpaceMemberById(
memberId: string,
spaceId: string,
trx?: KyselyTransaction,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('spaceMembers')
@@ -114,7 +115,11 @@ export class SpaceMemberRepo {
'spaceMembers.createdAt',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
.select(
sql<number>`case when groups.id is not null then 1 else 0 end`.as(
'isGroup',
),
)
.where('spaceId', '=', spaceId);
if (pagination.query) {
@@ -219,6 +224,40 @@ export class SpaceMemberRepo {
return roles;
}
async getUserIdsWithSpaceAccess(
userIds: string[],
spaceId: string,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const rows = await this.db
.selectFrom('spaceMembers')
.select('userId')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('groupUsers.userId', 'in', userIds)
.where('spaceMembers.spaceId', '=', spaceId),
)
.execute();
return new Set(rows.map((r) => r.userId));
}
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('spaceMembers')
.select('spaceId')
.where('groupId', '=', groupId)
.execute();
return rows.map((r) => r.spaceId);
}
getUserSpaceIdsQuery(userId: string) {
return this.db
.selectFrom('spaceMembers')
@@ -89,6 +89,26 @@ export class SpaceRepo {
.executeTakeFirst();
}
async updateSharingSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -0,0 +1,249 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { InsertableWatcher, Watcher } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { dbOrTx } from '@docmost/db/utils';
export const WatcherType = {
PAGE: 'page',
SPACE: 'space',
} as const;
export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
@Injectable()
export class WatcherRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findByUserAndPage(
userId: string,
pageId: string,
): Promise<Watcher | undefined> {
return this.db
.selectFrom('watchers')
.selectAll()
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('watchers')
.selectAll('watchers')
.select((eb) => this.withUser(eb))
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
async getPageWatcherIds(
pageId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const db = dbOrTx(this.db, trx);
const watchers = await db
.selectFrom('watchers')
.select('userId')
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null)
.execute();
return watchers.map((w) => w.userId);
}
async insert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
): Promise<Watcher | undefined> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('watchers')
.values(watcher)
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
}
async insertMany(
watchers: InsertableWatcher[],
trx?: KyselyTransaction,
): Promise<void> {
if (watchers.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.insertInto('watchers')
.values(watchers)
.onConflict((oc) => oc.doNothing())
.execute();
}
async upsert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
): Promise<Watcher | undefined> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('watchers')
.values(watcher)
.onConflict((oc) =>
oc
.columns(['userId', 'pageId'])
.where('pageId', 'is not', null)
.doUpdateSet({ mutedAt: null }),
)
.returningAll()
.executeTakeFirst();
}
async mute(
userId: string,
pageId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('watchers')
.set({ mutedAt: new Date() })
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.execute();
}
async isWatching(userId: string, pageId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
.select('id')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.where('mutedAt', 'is', null)
.executeTakeFirst();
return !!watcher;
}
async countPageWatchers(pageId: string): Promise<number> {
const result = await this.db
.selectFrom('watchers')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async deleteByUsersWithoutSpaceAccess(
userIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
if (userIds.length === 0) return;
const { trx } = opts;
const db = dbOrTx(this.db, trx);
const usersWithAccess = db
.selectFrom('spaceMembers')
.select('userId')
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await this.db
.deleteFrom('watchers')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('userId', 'not in', usersWithAccess)
.execute();
}
async updateSpaceIdByPageIds(
spaceId: string,
pageIds: string[],
opts?: { trx?: KyselyTransaction },
): Promise<void> {
if (pageIds.length === 0) return;
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.updateTable('watchers')
.set({ spaceId })
.where('pageId', 'in', pageIds)
.execute();
}
async deleteByPageIdsWithoutSpaceAccess(
pageIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
if (pageIds.length === 0) return;
const { trx } = opts;
const db = dbOrTx(this.db, trx);
const usersWithAccess = db
.selectFrom('spaceMembers')
.select('userId')
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await db
.deleteFrom('watchers')
.where('pageId', 'in', pageIds)
.where('userId', 'not in', usersWithAccess)
.execute();
}
async deleteByUserAndWorkspace(
userId: string,
workspaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('watchers')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
withUser(eb: ExpressionBuilder<DB, 'watchers'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl', 'users.email'])
.whereRef('users.id', '=', 'watchers.userId'),
).as('user');
}
}
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
@@ -185,7 +185,25 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
+32
View File
@@ -199,6 +199,7 @@ export interface GroupUsers {
export interface PageHistory {
content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
icon: string | null;
@@ -273,6 +274,7 @@ export interface Spaces {
id: Generated<string>;
logo: string | null;
name: string | null;
settings: Json | null;
slug: string;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
@@ -360,6 +362,34 @@ export interface Workspaces {
updatedAt: Generated<Timestamp>;
}
export interface Notifications {
id: Generated<string>;
userId: string;
workspaceId: string;
type: string;
actorId: string | null;
pageId: string | null;
spaceId: string | null;
commentId: string | null;
data: Json | null;
readAt: Timestamp | null;
emailedAt: Timestamp | null;
archivedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
}
export interface Watchers {
id: Generated<string>;
userId: string;
pageId: string | null;
spaceId: string;
workspaceId: string;
type: string;
addedById: string | null;
mutedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -371,6 +401,7 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
@@ -379,6 +410,7 @@ export interface DB {
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
}
+2 -43
View File
@@ -1,47 +1,6 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
PageHistory,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -3,6 +3,7 @@ import {
Attachments,
Comments,
Groups,
Notifications,
Pages,
Spaces,
Users,
@@ -20,6 +21,7 @@ import {
FileTasks,
UserMfa as _UserMFA,
ApiKeys,
Watchers,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -131,3 +133,13 @@ export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
// Notification
export type Notification = Selectable<Notifications>;
export type InsertableNotification = Insertable<Notifications>;
export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
// Watcher
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation';
import { envPath } from '../../common/helpers';
import { DomainService } from './domain.service';
import { LicenseCheckService } from './license-check.service';
@Global()
@Module({
@@ -15,7 +16,7 @@ import { DomainService } from './domain.service';
validate,
}),
],
providers: [EnvironmentService, DomainService],
exports: [EnvironmentService, DomainService],
providers: [EnvironmentService, DomainService, LicenseCheckService],
exports: [EnvironmentService, DomainService, LicenseCheckService],
})
export class EnvironmentModule {}
@@ -91,7 +91,6 @@ export class EnvironmentVariables {
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
TYPESENSE_URL: string;
@IsOptional()
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
@IsNotEmpty()
@IsString()
@@ -110,18 +109,14 @@ export class EnvironmentVariables {
AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
AI_EMBEDDING_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
@@ -145,13 +140,11 @@ export class EnvironmentVariables {
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
@IsString()
@IsNotEmpty()
GEMINI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
@Injectable()
export class LicenseCheckService {
constructor(
private moduleRef: ModuleRef,
private environmentService: EnvironmentService,
) {}
isValidEELicense(licenseKey: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.isValidEELicense(licenseKey);
} catch {
return false;
}
}
}
@@ -44,7 +44,7 @@ export class ImportController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const validFileExtensions = ['.md', '.html'];
const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb');
@@ -29,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class ImportService {
@@ -40,6 +41,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
) {}
async importPage(
@@ -59,11 +61,22 @@ export class ImportService {
let prosemirrorState = null;
let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
} catch (err) {
const message = 'Error processing file content';
@@ -87,6 +100,7 @@ export class ImportService {
const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
@@ -129,6 +143,42 @@ export class ImportService {
}
}
async processDocx(
fileBuffer: Buffer,
workspaceId: string,
spaceId: string,
pageId: string,
userId: string,
): Promise<any> {
let DocxImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
} catch (err) {
this.logger.error(
'DOCX import requested but EE module not bundled in this build',
);
throw new BadRequestException(
'This feature requires a valid enterprise license.',
);
}
const docxImportService = this.moduleRef.get(
DocxImportModule.DocxImportService,
{ strict: false },
);
const html = await docxImportService.convertDocxToHtml(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
return this.processHTML(html);
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`);
@@ -5,4 +5,5 @@ export interface MailMessage {
text?: string;
html?: string;
template?: any;
notificationId?: string;
}
@@ -4,11 +4,15 @@ import { QueueName } from '../../queue/constants';
import { Job } from 'bullmq';
import { MailService } from '../mail.service';
import { MailMessage } from '../interfaces/mail.message';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
@Processor(QueueName.EMAIL_QUEUE)
export class EmailProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(EmailProcessor.name);
constructor(private readonly mailService: MailService) {
constructor(
private readonly mailService: MailService,
private readonly notificationRepo: NotificationRepo,
) {
super();
}
@@ -18,6 +22,14 @@ export class EmailProcessor extends WorkerHost implements OnModuleDestroy {
} catch (err) {
throw err;
}
if (job.data.notificationId) {
try {
await this.notificationRepo.markAsEmailed(job.data.notificationId);
} catch (err) {
this.logger.warn(`Failed to mark notification ${job.data.notificationId} as emailed`);
}
}
}
@OnWorkerEvent('active')
@@ -6,6 +6,8 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
}
export enum QueueJob {
@@ -18,6 +20,7 @@ export enum QueueJob {
DELETE_USER_AVATARS = 'delete-user-avatars',
PAGE_BACKLINKS = 'page-backlinks',
ADD_PAGE_WATCHERS = 'add-page-watchers',
STRIPE_SEATS_SYNC = 'sync-stripe-seats',
TRIAL_ENDED = 'trial-ended',
@@ -58,4 +61,10 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
COMMENT_NOTIFICATION = 'comment-notification',
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
}
@@ -7,6 +7,56 @@ export interface IPageBacklinkJob {
mentions: MentionNode[];
}
export interface IAddPageWatchersJob {
userIds: string[];
pageId: string;
spaceId: string;
workspaceId: string;
}
export interface IStripeSeatsSyncJob {
workspaceId: string;
}
}
export interface IPageHistoryJob {
pageId: string;
}
export interface INotificationCreateJob {
userId: string;
workspaceId: string;
type: string;
actorId?: string;
pageId?: string;
spaceId?: string;
commentId?: string;
data?: Record<string, unknown>;
}
export interface ICommentNotificationJob {
commentId: string;
parentCommentId?: string;
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
mentionedUserIds: string[];
notifyWatchers: boolean;
}
export interface ICommentResolvedNotificationJob {
commentId: string;
commentCreatorId: string;
pageId: string;
spaceId: string;
workspaceId: string;
actorId: string;
}
export interface IPageMentionNotificationJob {
userMentions: { userId: string; mentionId: string; creatorId: string }[];
oldMentionedUserIds: string[];
pageId: string;
spaceId: string;
workspaceId: string;
}
@@ -1,135 +0,0 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../constants';
import { IPageBacklinkJob } from '../constants/queue.interface';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { executeTx } from '@docmost/db/utils';
@Processor(QueueName.GENERAL_QUEUE)
export class BacklinksProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(BacklinksProcessor.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly backlinkRepo: BacklinkRepo,
) {
super();
}
async process(job: Job<IPageBacklinkJob, void>): Promise<void> {
try {
const { pageId, mentions, workspaceId } = job.data;
switch (job.name) {
case QueueJob.PAGE_BACKLINKS:
{
await executeTx(this.db, async (trx) => {
const existingBacklinks = await trx
.selectFrom('backlinks')
.select('targetPageId')
.where('sourcePageId', '=', pageId)
.execute();
if (existingBacklinks.length === 0 && mentions.length === 0) {
return;
}
const existingTargetPageIds = existingBacklinks.map(
(backlink) => backlink.targetPageId,
);
const targetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
// make sure target pages belong to the same workspace
let validTargetPages = [];
if (targetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
.where('id', 'in', targetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
const validTargetPageIds = validTargetPages.map(
(page) => page.id,
);
// new backlinks
const backlinksToAdd = validTargetPageIds.filter(
(id) => !existingTargetPageIds.includes(id),
);
// stale backlinks
const backlinksToRemove = existingTargetPageIds.filter(
(existingId) => !validTargetPageIds.includes(existingId),
);
// add new backlinks
if (backlinksToAdd.length > 0) {
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
sourcePageId: pageId,
targetPageId: targetPageId,
workspaceId: workspaceId,
}));
await this.backlinkRepo.insertBacklink(newBacklinks, trx);
this.logger.debug(
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
);
}
// remove stale backlinks
if (backlinksToRemove.length > 0) {
await this.db
.deleteFrom('backlinks')
.where('sourcePageId', '=', pageId)
.where('targetPageId', 'in', backlinksToRemove)
.execute();
this.logger.debug(
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
);
}
});
}
break;
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
if (job.name === QueueJob.PAGE_BACKLINKS) {
this.logger.debug(`Processing ${job.name} job`);
}
}
@OnWorkerEvent('failed')
onError(job: Job) {
if (job.name === QueueJob.PAGE_BACKLINKS) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
if (job.name === QueueJob.PAGE_BACKLINKS) {
this.logger.debug(`Completed ${job.name} job`);
}
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -0,0 +1,87 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../constants';
import {
IAddPageWatchersJob,
IPageBacklinkJob,
} from '../constants/queue.interface';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import {
WatcherRepo,
WatcherType,
} from '@docmost/db/repos/watcher/watcher.repo';
import { InsertableWatcher } from '@docmost/db/types/entity.types';
import { processBacklinks } from '../tasks/backlinks.task';
@Processor(QueueName.GENERAL_QUEUE)
export class GeneralQueueProcessor
extends WorkerHost
implements OnModuleDestroy
{
private readonly logger = new Logger(GeneralQueueProcessor.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly backlinkRepo: BacklinkRepo,
private readonly watcherRepo: WatcherRepo,
) {
super();
}
async process(job: Job): Promise<void> {
try {
switch (job.name) {
case QueueJob.ADD_PAGE_WATCHERS: {
const { userIds, pageId, spaceId, workspaceId } =
job.data as IAddPageWatchersJob;
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
}));
await this.watcherRepo.insertMany(watchers);
break;
}
case QueueJob.PAGE_BACKLINKS: {
await processBacklinks(
this.db,
this.backlinkRepo,
job.data as IPageBacklinkJob,
);
break;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -3,7 +3,7 @@ import { BullModule } from '@nestjs/bullmq';
import { EnvironmentService } from '../environment/environment.service';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { QueueName } from './constants';
import { BacklinksProcessor } from './processors/backlinks.processor';
import { GeneralQueueProcessor } from './processors/general-queue.processor';
@Global()
@Module({
@@ -73,8 +73,19 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
],
exports: [BullModule],
providers: [BacklinksProcessor],
providers: [GeneralQueueProcessor],
})
export class QueueModule {}
@@ -0,0 +1,80 @@
import { Logger } from '@nestjs/common';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { IPageBacklinkJob } from '../constants/queue.interface';
import { executeTx } from '@docmost/db/utils';
const logger = new Logger('BacklinksTask');
export async function processBacklinks(
db: KyselyDB,
backlinkRepo: BacklinkRepo,
data: IPageBacklinkJob,
): Promise<void> {
const { pageId, mentions, workspaceId } = data;
await executeTx(db, async (trx) => {
const existingBacklinks = await trx
.selectFrom('backlinks')
.select('targetPageId')
.where('sourcePageId', '=', pageId)
.execute();
if (existingBacklinks.length === 0 && mentions.length === 0) {
return;
}
const existingTargetPageIds = existingBacklinks.map(
(backlink) => backlink.targetPageId,
);
const targetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
let validTargetPages = [];
if (targetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
.where('id', 'in', targetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
const validTargetPageIds = validTargetPages.map((page) => page.id);
const backlinksToAdd = validTargetPageIds.filter(
(id) => !existingTargetPageIds.includes(id),
);
const backlinksToRemove = existingTargetPageIds.filter(
(existingId) => !validTargetPageIds.includes(existingId),
);
if (backlinksToAdd.length > 0) {
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
sourcePageId: pageId,
targetPageId: targetPageId,
workspaceId: workspaceId,
}));
await backlinkRepo.insertBacklink(newBacklinks, trx);
logger.debug(
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
);
}
if (backlinksToRemove.length > 0) {
await db
.deleteFrom('backlinks')
.where('sourcePageId', '=', pageId)
.where('targetPageId', 'in', backlinksToRemove)
.execute();
logger.debug(
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
);
}
});
}
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
return await fs.pathExists(this._fullPath(filePath));
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client;
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally {
if (shouldDestroyClient && clientToUse) {
@@ -127,6 +130,25 @@ export class S3Driver implements StorageDriver {
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Range: `bytes=${range.start}-${range.end}`,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (err) {
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath);
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath);
}
@@ -0,0 +1,43 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
}
export const CommentCreateEmail = ({
actorName,
pageTitle,
pageUrl,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> commented on{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default CommentCreateEmail;
@@ -0,0 +1,43 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
}
export const CommentMentionEmail = ({
actorName,
pageTitle,
pageUrl,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> mentioned you in a comment on{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default CommentMentionEmail;
@@ -0,0 +1,43 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
}
export const CommentResolvedEmail = ({
actorName,
pageTitle,
pageUrl,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> resolved a comment on{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default CommentResolvedEmail;
@@ -0,0 +1,39 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
}
export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> mentioned you in{' '}
<strong>{pageTitle}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
export default PageMentionEmail;
+3 -2
View File
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import { InternalLogFilter } from './common/logger/internal-log-filter';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -24,10 +25,10 @@ async function bootstrap() {
}),
{
rawBody: true,
// disable Nest logger so pino handles all logs
// captures NestJS internal errors
logger: new InternalLogFilter(),
// bufferLogs must be false else pino will fail
// to log OnApplicationBootstrap logs
logger: false,
bufferLogs: false,
},
);
+2 -1
View File
@@ -37,10 +37,11 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const userRoom = `user-${userId}`;
const workspaceRoom = `workspace-${workspaceId}`;
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
client.join([userRoom, workspaceRoom, ...spaceRooms]);
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
+1
View File
@@ -5,5 +5,6 @@ import { TokenModule } from '../core/auth/token.module';
@Module({
imports: [TokenModule],
providers: [WsGateway],
exports: [WsGateway],
})
export class WsModule {}