diff --git a/apps/server/src/ws/ws-tree.service.spec.ts b/apps/server/src/ws/ws-tree.service.spec.ts index 3f212f5d..0c511223 100644 --- a/apps/server/src/ws/ws-tree.service.spec.ts +++ b/apps/server/src/ws/ws-tree.service.spec.ts @@ -7,7 +7,10 @@ import { PageMovedEvent, TreeNodeSnapshot, } from '../database/listeners/page.listener'; -import { getSpaceRoomName } from './ws.utils'; +import { + getSpaceRoomName, + WS_SPACE_RESTRICTION_CACHE_PREFIX, +} from './ws.utils'; const snapshot: TreeNodeSnapshot = { id: 'page-1', @@ -291,6 +294,15 @@ describe('WsService.emitTreeEvent', () => { expect(noEmit).not.toHaveBeenCalled(); }); + it('invalidateSpaceRestrictionCache deletes the cached restriction verdict for that space only', async () => { + await service.invalidateSpaceRestrictionCache('space-42'); + + expect(cache.del).toHaveBeenCalledTimes(1); + expect(cache.del).toHaveBeenCalledWith( + `${WS_SPACE_RESTRICTION_CACHE_PREFIX}space-42`, + ); + }); + it('emitDeleteToUnauthorized sends ONLY to sockets whose user lacks page access', async () => { pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']); diff --git a/apps/server/src/ws/ws-tree.service.ts b/apps/server/src/ws/ws-tree.service.ts index 98614219..5fa8ef5d 100644 --- a/apps/server/src/ws/ws-tree.service.ts +++ b/apps/server/src/ws/ws-tree.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Page } from '@docmost/db/types/entity.types'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { WsService } from './ws.service'; import { @@ -145,43 +144,4 @@ export class WsTreeService { spaceId, }); } - - async notifyPageRestricted(page: Page, excludeUserId: string): Promise { - await this.wsService.emitToSpaceExceptUsers(page.spaceId, [excludeUserId], { - operation: 'deleteTreeNode', - spaceId: page.spaceId, - payload: { - node: { - id: page.id, - slugId: page.slugId, - }, - }, - }); - } - - async notifyPermissionGranted(page: Page, userIds: string[]): Promise { - if (userIds.length === 0) return; - - await this.wsService.emitToUsers(userIds, { - operation: 'addTreeNode', - spaceId: page.spaceId, - payload: { - parentId: page.parentPageId ?? null, - index: 0, - data: { - id: page.id, - slugId: page.slugId, - name: page.title ?? '', - title: page.title, - icon: page.icon, - position: page.position, - spaceId: page.spaceId, - parentPageId: page.parentPageId, - creatorId: page.creatorId, - hasChildren: false, - children: [], - }, - }, - }); - } } diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 0c23bcef..3a8c8bc3 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -8,7 +8,6 @@ import { WS_SPACE_RESTRICTION_CACHE_PREFIX, WS_CACHE_TTL_MS, getSpaceRoomName, - getUserRoomName, } from './ws.utils'; @Injectable() @@ -57,6 +56,22 @@ export class WsService { await this.broadcastToAuthorizedUsers(room, client.id, pageId, data); } + // Drop the cached spaceHasRestrictions verdict for a space. spaceHasRestrictions + // caches "does this space have ANY restricted page" for WS_CACHE_TTL_MS (30s), + // and emitTreeEvent / emitCommentEvent take a room-wide fast path when it is + // false. The FIRST time a space gains a restriction (or loses its last one) + // this cached verdict goes stale for up to the TTL, during which a title/icon- + // bearing tree payload could fan out to the whole room. This MUST be called by + // whatever code creates or removes a page's restriction (the page-access / + // page-permission grant/revoke/restrict path), passing the affected page's + // spaceId, so the next emit re-reads hasRestrictedPagesInSpace. + // + // NOTE: on this branch there is no permission-mutation site to call this from — + // the page-access/page-permission repo mutators (insertPageAccess / + // insertPagePermissions / deletePagePermission* / updatePagePermissionRole) + // have ZERO callers in apps/server/src; PageAccessService only validates access. + // This primitive is kept (and tested) so that flow, when it lands, has the + // correct hook to invalidate the cache. async invalidateSpaceRestrictionCache(spaceId: string): Promise { await this.cacheManager.del( `${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`, @@ -183,29 +198,6 @@ export class WsService { await this.broadcastToAuthorizedUsers(room, null, pageId, data); } - async emitToUsers(userIds: string[], data: any): Promise { - if (userIds.length === 0) return; - const rooms = userIds.map((id) => getUserRoomName(id)); - this.server.to(rooms).emit('message', data); - } - - async emitToSpaceExceptUsers( - spaceId: string, - excludeUserIds: string[], - data: any, - ): Promise { - const room = getSpaceRoomName(spaceId); - const sockets = await this.server.in(room).fetchSockets(); - const excludeSet = new Set(excludeUserIds); - - for (const socket of sockets) { - const userId = socket.data.userId as string; - if (userId && !excludeSet.has(userId)) { - socket.emit('message', data); - } - } - } - isTreeEvent(data: any): boolean { return TREE_EVENTS.has(data?.operation) && !!data?.spaceId; }