import { Injectable } from '@nestjs/common'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { WsService } from './ws.service'; import { PageMovedEvent, TreeNodeSnapshot, TreeUpdateSnapshot, toTreeNodeSnapshot, } from '../database/listeners/page.listener'; @Injectable() export class WsTreeService { constructor( private readonly wsService: WsService, private readonly pagePermissionRepo: PagePermissionRepo, ) {} // Server-origin tree broadcasts. Built from thin node snapshots carried in the // domain events (variant A) so no DB read happens here — this avoids the // in-transaction visibility race. Payload shapes mirror what the client // receiver (`use-tree-socket.ts`) consumes. async broadcastPageCreated(page: TreeNodeSnapshot): Promise { await this.wsService.emitTreeEvent(page.spaceId, page.id, { operation: 'addTreeNode', spaceId: page.spaceId, payload: { parentId: page.parentPageId ?? null, // Receivers place by `position` among already-loaded siblings, not by // this absolute index (sender's loaded set differs from receivers'). index: 0, // Built via the shared snapshot helper (same one page.repo uses to fill // the event), then extended with the tree-only fields the client // receiver consumes. The helper carries the death-timer deadline // (normalised to null => permanent) so receivers — and the author, if // this broadcast wins the race against the optimistic insert — render // the temporary-note clock marker immediately, without it drifting from // the event literal. data: { ...toTreeNodeSnapshot(page), name: page.title ?? '', hasChildren: false, children: [], }, }, }); } // Rename / icon change: patch the in-tree node's title/icon on every client in // the space. Routed through the restriction-aware `emitTreeEvent` so a // restricted page's new title/icon never leaks to sockets that can't see it. // The payload mirrors the client `UpdateEvent` shape consumed by // `applyUpdateOne` (entity ["pages"], `id`, `payload.title` / `payload.icon`); // only the fields that actually changed are sent (the snapshot omits the rest). async broadcastPageUpdated(node: TreeUpdateSnapshot): Promise { await this.wsService.emitTreeEvent(node.spaceId, node.id, { operation: 'updateOne', spaceId: node.spaceId, entity: ['pages'], id: node.id, payload: { slugId: node.slugId, parentPageId: node.parentPageId, // Only include changed fields; an absent field leaves the client node // untouched (applyUpdateOne checks `!== undefined` per field). ...(node.title !== undefined ? { title: node.title } : {}), ...(node.icon !== undefined ? { icon: node.icon } : {}), }, }); } async broadcastPageDeleted(page: TreeNodeSnapshot): Promise { await this.wsService.emitTreeEvent(page.spaceId, page.id, { operation: 'deleteTreeNode', spaceId: page.spaceId, payload: { node: { id: page.id, slugId: page.slugId, parentPageId: page.parentPageId ?? null, }, }, }); } async broadcastPageMoved(event: PageMovedEvent): Promise { const { node } = event; const movePayload = { operation: 'moveTreeNode', spaceId: node.spaceId, payload: { id: node.id, parentId: node.parentPageId ?? null, oldParentId: event.oldParentId ?? null, // See broadcastPageCreated: receivers place by `position`, not index. index: 0, position: node.position, pageData: { id: node.id, slugId: node.slugId, title: node.title, icon: node.icon, position: node.position, spaceId: node.spaceId, parentPageId: node.parentPageId ?? null, hasChildren: event.hasChildren, }, }, }; // Decide the node's restricted state ONCE, fresh (uncached), and drive BOTH // the move broadcast and the compensating delete from this single decision. // // Why not just emitTreeEvent for the move? emitTreeEvent gates the move on // the CACHED spaceHasRestrictions (30s TTL, never invalidated). In the window // right after a space gets its FIRST restriction, that cache still says // "no restrictions" → emitTreeEvent would fan the move out to the WHOLE room // (including unauthorized users) while the delete below (computed from the // UNCACHED hasRestrictedAncestor) also fires. An unauthorized user then gets // BOTH, and if the delete lands first it is a no-op and the later move // renders the restricted node → leak. So when the node is known-restricted we // must NOT route the move through the cache-gated path. const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( node.id, ); if (!isRestricted) { // Normal case: not under a restricted ancestor. One moveTreeNode to the // whole space room (emitTreeEvent's open-space fast path), no delete. await this.wsService.emitTreeEvent(node.spaceId, node.id, movePayload); return; } // Restricted case: a move can push a previously-visible page UNDER a // restricted ancestor. The move (to authorized users) and the compensating // delete (to everyone else) are now driven from ONE socket/access snapshot: // emitMoveWithRestrictionSplit performs a single fetchSockets + a single // getUserIdsWithPageAccess and partitions the room from that one snapshot. // This eliminates the race window that existed when the move and the delete // each resolved the audience independently — a socket could otherwise have // landed in both sets (leaking the restricted node) or in neither (losing the // compensating delete). Authorized users get exactly the moveTreeNode, // everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode. // // Users who LOSE visibility need the delete because otherwise the node would // linger in their tree at its old parent with its real title/slugId/icon // (existence + metadata leak). await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, { operation: 'deleteTreeNode', spaceId: node.spaceId, payload: { node: { id: node.id, slugId: node.slugId, parentPageId: event.oldParentId ?? null, }, }, }); } // Used for restore (and other subtree re-attachments): rather than emitting N // pointwise addTreeNode events, ask clients in the space to refetch the root // tree. The client already understands `refetchRootTreeNodeEvent`. async broadcastRefetchRoot(spaceId: string): Promise { this.wsService.emitToSpaceRoom(spaceId, { operation: 'refetchRootTreeNodeEvent', spaceId, }); } }