diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 3ecf103b..5db91598 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -677,6 +677,8 @@
"{{name}} updated a page": "{{name}} updated a page",
"Watch page": "Watch page",
"Stop watching": "Stop watching",
+ "Watch space": "Watch space",
+ "Stop watching space": "Stop watching space",
"Email notifications": "Email notifications",
"Page updates": "Page updates",
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
@@ -690,6 +692,8 @@
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
"You are now watching this page": "You’re now watching this page",
"You are no longer watching this page": "You’re no longer watching this page",
+ "You are now watching this space": "You’re now watching this space",
+ "You are no longer watching this space": "You’re no longer watching this space",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Today",
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
index ced237be..d8032212 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
@@ -9,6 +9,8 @@ import {
import {
IconArrowDown,
IconDots,
+ IconEye,
+ IconEyeOff,
IconFileExport,
IconHome,
IconPlus,
@@ -16,6 +18,11 @@ import {
IconSettings,
IconTrash,
} from "@tabler/icons-react";
+import {
+ useSpaceWatchStatusQuery,
+ useWatchSpaceMutation,
+ useUnwatchSpaceMutation,
+} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React from "react";
import { useAtom } from "jotai";
@@ -160,13 +167,20 @@ export function SpaceSidebar() {
{t("Pages")}
- {spaceAbility.can(
- SpaceCaslAction.Manage,
- SpaceCaslSubject.Page,
- ) && (
-
-
+
+
+ {spaceAbility.can(
+ SpaceCaslAction.Manage,
+ SpaceCaslSubject.Page,
+ ) && (
-
- )}
+ )}
+
@@ -204,9 +218,14 @@ export function SpaceSidebar() {
interface SpaceMenuProps {
spaceId: string;
+ canManagePages: boolean;
onSpaceSettings: () => void;
}
-function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
+function SpaceMenu({
+ spaceId,
+ canManagePages,
+ onSpaceSettings,
+}: SpaceMenuProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
@@ -214,15 +233,24 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
+ const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
+ const watchMutation = useWatchSpaceMutation();
+ const unwatchMutation = useUnwatchSpaceMutation();
+ const isWatching = watchStatus?.watching ?? false;
+
+ const handleToggleWatch = () => {
+ if (isWatching) {
+ unwatchMutation.mutate(spaceId);
+ } else {
+ watchMutation.mutate(spaceId);
+ }
+ };
+
return (
<>
-
+ {canManagePages && (
+ <>
+
-
+
+ >
+ )}
>
);
}
diff --git a/apps/client/src/features/space/queries/space-watcher-query.ts b/apps/client/src/features/space/queries/space-watcher-query.ts
new file mode 100644
index 00000000..ae4d5696
--- /dev/null
+++ b/apps/client/src/features/space/queries/space-watcher-query.ts
@@ -0,0 +1,49 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ watchSpace,
+ unwatchSpace,
+ getSpaceWatchStatus,
+} from "@/features/space/services/space-watcher-service";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+const SPACE_WATCHER_KEY = "space-watcher";
+
+export function useSpaceWatchStatusQuery(spaceId: string) {
+ return useQuery({
+ queryKey: [SPACE_WATCHER_KEY, spaceId],
+ queryFn: () => getSpaceWatchStatus(spaceId),
+ enabled: !!spaceId,
+ staleTime: 60_000,
+ });
+}
+
+export function useWatchSpaceMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (spaceId: string) => watchSpace(spaceId),
+ onSuccess: (_data, spaceId) => {
+ queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
+ watching: true,
+ });
+ notifications.show({ message: t("You are now watching this space") });
+ },
+ });
+}
+
+export function useUnwatchSpaceMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (spaceId: string) => unwatchSpace(spaceId),
+ onSuccess: (_data, spaceId) => {
+ queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], {
+ watching: false,
+ });
+ notifications.show({
+ message: t("You are no longer watching this space"),
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/space/services/space-watcher-service.ts b/apps/client/src/features/space/services/space-watcher-service.ts
new file mode 100644
index 00000000..bcbeccc9
--- /dev/null
+++ b/apps/client/src/features/space/services/space-watcher-service.ts
@@ -0,0 +1,28 @@
+import api from "@/lib/api-client";
+
+export async function watchSpace(
+ spaceId: string,
+): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/spaces/watch", {
+ spaceId,
+ });
+ return req.data;
+}
+
+export async function unwatchSpace(
+ spaceId: string,
+): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/spaces/unwatch", {
+ spaceId,
+ });
+ return req.data;
+}
+
+export async function getSpaceWatchStatus(
+ spaceId: string,
+): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/spaces/watch-status", {
+ spaceId,
+ });
+ return req.data;
+}
diff --git a/apps/server/src/core/notification/services/page.notification.ts b/apps/server/src/core/notification/services/page.notification.ts
index 9e5c75dd..77ab967a 100644
--- a/apps/server/src/core/notification/services/page.notification.ts
+++ b/apps/server/src/core/notification/services/page.notification.ts
@@ -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,
+ };
}
}
diff --git a/apps/server/src/core/watcher/dto/space-watcher.dto.ts b/apps/server/src/core/watcher/dto/space-watcher.dto.ts
new file mode 100644
index 00000000..1df06010
--- /dev/null
+++ b/apps/server/src/core/watcher/dto/space-watcher.dto.ts
@@ -0,0 +1,7 @@
+import { IsString, IsNotEmpty } from 'class-validator';
+
+export class SpaceWatcherDto {
+ @IsString()
+ @IsNotEmpty()
+ spaceId: string;
+}
diff --git a/apps/server/src/core/watcher/space-watcher.controller.ts b/apps/server/src/core/watcher/space-watcher.controller.ts
new file mode 100644
index 00000000..455c7d0d
--- /dev/null
+++ b/apps/server/src/core/watcher/space-watcher.controller.ts
@@ -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 };
+ }
+}
diff --git a/apps/server/src/core/watcher/watcher.controller.ts b/apps/server/src/core/watcher/watcher.controller.ts
index cd10fa37..24c317f6 100644
--- a/apps/server/src/core/watcher/watcher.controller.ts
+++ b/apps/server/src/core/watcher/watcher.controller.ts
@@ -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 };
}
diff --git a/apps/server/src/core/watcher/watcher.module.ts b/apps/server/src/core/watcher/watcher.module.ts
index 76267b5a..357b8352 100644
--- a/apps/server/src/core/watcher/watcher.module.ts
+++ b/apps/server/src/core/watcher/watcher.module.ts
@@ -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],
})
diff --git a/apps/server/src/core/watcher/watcher.service.ts b/apps/server/src/core/watcher/watcher.service.ts
index 384a0787..3c5fe621 100644
--- a/apps/server/src/core/watcher/watcher.service.ts
+++ b/apps/server/src/core/watcher/watcher.service.ts
@@ -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
{
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 {
+ return this.watcherRepo.isWatchingSpace(userId, spaceId);
+ }
+
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
return this.watcherRepo.findPageWatchers(pageId, pagination);
}
diff --git a/apps/server/src/database/repos/watcher/watcher.repo.ts b/apps/server/src/database/repos/watcher/watcher.repo.ts
index 9739b4de..f1506ff9 100644
--- a/apps/server/src/database/repos/watcher/watcher.repo.ts
+++ b/apps/server/src/database/repos/watcher/watcher.repo.ts
@@ -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 {
- 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 {
+ 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 {
+ 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 {
+ 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 {
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 {
+ 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 {
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)
diff --git a/apps/server/src/integrations/transactional/emails/page-update-email.tsx b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
index 188d8a34..c4c85769 100644
--- a/apps/server/src/integrations/transactional/emails/page-update-email.tsx
+++ b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
@@ -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 (
@@ -24,8 +26,8 @@ export const PageUpdateEmail = ({
{actorName} updated{' '}
{pageTitle}
-
- .
+ {' '}
+ in {spaceName}.
View page