diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 67713f28..b83b3548 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -678,6 +678,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.", @@ -691,6 +693,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 ( <> - + } + onClick={handleToggleWatch} + leftSection={ + isWatching ? : + } > - {t("Import pages")} + {isWatching ? t("Stop watching space") : t("Watch space")} - } - > - {t("Export space")} - + {canManagePages && ( + <> + - + } + > + {t("Import pages")} + - } - > - {t("Space settings")} - + } + > + {t("Export space")} + - } - > - {t("Trash")} - + + + } + > + {t("Space settings")} + + + } + > + {t("Trash")} + + + )} - + {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