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
@@ -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');
}
}