feat: notifications (#1947)

* feat: notifications
* feat: watchers

* improvements

* handle page move for watchers

* make watchers non-blocking

* more
This commit is contained in:
Philip Okugbe
2026-02-14 20:00:38 -08:00
committed by GitHub
parent e0ab9d9b5e
commit 05b3c65b0f
80 changed files with 3071 additions and 238 deletions
@@ -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,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)
@@ -152,8 +152,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');
}
}
@@ -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')
@@ -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');
}
}
+30
View File
@@ -362,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;
@@ -373,6 +401,7 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
@@ -381,6 +410,7 @@ export interface DB {
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
}
@@ -9,6 +9,7 @@ import {
FileTasks,
Groups,
GroupUsers,
Notifications,
PageHistory,
Pages,
Shares,
@@ -17,6 +18,7 @@ import {
UserMfa,
Users,
UserTokens,
Watchers,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
@@ -32,6 +34,7 @@ export interface DbInterface {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pages: Pages;
@@ -41,6 +44,7 @@ export interface DbInterface {
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
watchers: Watchers;
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'>>;