feat: watch space (#2096)

This commit is contained in:
Philip Okugbe
2026-04-09 00:37:51 +01:00
committed by GitHub
parent 4966f9b152
commit da9b43681e
12 changed files with 449 additions and 75 deletions
@@ -179,7 +179,11 @@ export class PageNotificationService {
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
const { pageId, spaceId, workspaceId, actorIds } = data;
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
const watcherIds = await this.watcherRepo.getPageUpdateRecipientIds(
pageId,
spaceId,
);
if (watcherIds.length === 0) return;
const actorSet = new Set(actorIds);
@@ -219,7 +223,7 @@ export class PageNotificationService {
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
const { actor, pageTitle, basePageUrl, spaceName } = context;
for (const userId of recipientIds) {
const notification = await this.notificationService.create({
@@ -243,6 +247,7 @@ export class PageNotificationService {
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
spaceName,
}),
NotificationType.PAGE_UPDATED,
);
@@ -421,7 +426,7 @@ export class PageNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -432,6 +437,11 @@ export class PageNotificationService {
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
basePageUrl,
spaceName: space.name,
};
}
}
@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class SpaceWatcherDto {
@IsString()
@IsNotEmpty()
spaceId: string;
}
@@ -0,0 +1,95 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { SpaceWatcherDto } from './dto/space-watcher.dto';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
export class SpaceWatcherController {
constructor(
private readonly watcherService: WatcherService,
private readonly spaceRepo: SpaceRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
private async loadSpaceAndAuthorize(
spaceId: string,
user: User,
workspace: Workspace,
) {
const space = await this.spaceRepo.findById(spaceId, workspace.id);
if (!space) {
throw new NotFoundException('Space not found');
}
const ability = await this.spaceAbility.createForUser(user, space.id);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return space;
}
@HttpCode(HttpStatus.OK)
@Post('watch')
async watchSpace(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
await this.watcherService.watchSpace(user.id, space.id, workspace.id);
return { watching: true };
}
@HttpCode(HttpStatus.OK)
@Post('unwatch')
async unwatchSpace(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
await this.watcherService.unwatchSpace(user.id, space.id);
return { watching: false };
}
@HttpCode(HttpStatus.OK)
@Post('watch-status')
async getWatchStatus(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
const watching = await this.watcherService.isWatchingSpace(
user.id,
space.id,
);
return { watching };
}
}
@@ -59,7 +59,12 @@ export class WatcherController {
await this.pageAccessService.validateCanView(page, user);
await this.watcherService.unwatchPage(user.id, page.id);
await this.watcherService.unwatchPage(
user.id,
page.id,
page.spaceId,
page.workspaceId,
);
return { watching: false };
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { WatcherController } from './watcher.controller';
import { SpaceWatcherController } from './space-watcher.controller';
import { PageAccessModule } from '../page/page-access/page-access.module';
@Module({
imports: [PageAccessModule],
controllers: [WatcherController],
controllers: [WatcherController, SpaceWatcherController],
providers: [WatcherService],
exports: [WatcherService],
})
@@ -50,14 +50,44 @@ export class WatcherService {
return this.watcherRepo.insertMany(watchers, trx);
}
async unwatchPage(userId: string, pageId: string) {
return this.watcherRepo.mute(userId, pageId);
async unwatchPage(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
) {
return this.watcherRepo.mute(userId, pageId, spaceId, workspaceId);
}
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
return this.watcherRepo.isWatching(userId, pageId);
}
async watchSpace(
userId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const watcher: InsertableWatcher = {
userId,
pageId: null,
spaceId,
workspaceId,
type: WatcherType.SPACE,
addedById: userId,
};
return this.watcherRepo.upsertSpace(watcher, trx);
}
async unwatchSpace(userId: string, spaceId: string) {
return this.watcherRepo.deleteSpaceWatch(userId, spaceId);
}
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
return this.watcherRepo.isWatchingSpace(userId, spaceId);
}
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
return this.watcherRepo.findPageWatchers(pageId, pagination);
}
@@ -20,18 +20,6 @@ export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
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')
@@ -66,6 +54,53 @@ export class WatcherRepo {
return watchers.map((w) => w.userId);
}
/**
* Recipients for a `page.updated` notification, combining:
* - Active page watchers on this page, AND
* - Active space watchers on this space, EXCLUDING any user who has a
* muted page watcher row for this page (per-page mute always wins).
*
* Deduplicated at the SQL level — a user watching both the page and the
* containing space appears once.
*/
async getPageUpdateRecipientIds(
pageId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const db = dbOrTx(this.db, trx);
const pageWatchers = db
.selectFrom('watchers')
.select('userId')
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null);
const spaceWatchers = db
.selectFrom('watchers as sw')
.select('sw.userId')
.where('sw.spaceId', '=', spaceId)
.where('sw.pageId', 'is', null)
.where('sw.type', '=', WatcherType.SPACE)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('watchers as pw')
.select('pw.id')
.whereRef('pw.userId', '=', 'sw.userId')
.where('pw.pageId', '=', pageId)
.where('pw.type', '=', WatcherType.PAGE)
.where('pw.mutedAt', 'is not', null),
),
),
);
const rows = await pageWatchers.union(spaceWatchers).execute();
return [...new Set(rows.map((r) => r.userId))];
}
async insert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
@@ -110,20 +145,81 @@ export class WatcherRepo {
.executeTakeFirst();
}
async upsertSpace(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
): Promise<Watcher | undefined> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('watchers')
.values(watcher)
.onConflict((oc) =>
oc
.columns(['userId', 'spaceId'])
.where('pageId', 'is', null)
.doNothing(),
)
.returningAll()
.executeTakeFirst();
}
async mute(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
const mutedAt = new Date();
await db
.insertInto('watchers')
.values({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
mutedAt,
})
.onConflict((oc) =>
oc
.columns(['userId', 'pageId'])
.where('pageId', 'is not', null)
.doUpdateSet({ mutedAt }),
)
.execute();
}
async deleteSpaceWatch(
userId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('watchers')
.set({ mutedAt: new Date() })
.deleteFrom('watchers')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.execute();
}
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
.select('id')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.executeTakeFirst();
return !!watcher;
}
async isWatching(userId: string, pageId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
@@ -164,14 +260,14 @@ export class WatcherRepo {
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
this.db
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await this.db
await db
.deleteFrom('watchers')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
@@ -8,6 +8,7 @@ interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
spaceName: string;
}
export const PageUpdateEmail = ({
@@ -15,6 +16,7 @@ export const PageUpdateEmail = ({
actorName,
pageTitle,
pageUrl,
spaceName,
}: Props) => {
return (
<MailBody>
@@ -24,8 +26,8 @@ export const PageUpdateEmail = ({
<strong>{actorName}</strong> updated{' '}
<Link href={pageUrl} style={link}>
<strong>{pageTitle}</strong>
</Link>
.
</Link>{' '}
in <strong>{spaceName}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View page</EmailButton>