8d8ecaed82
Agent suggestion-edits (comments with suggestedText, #315) piled up: Apply auto-resolved the thread, cluttering the resolved tab, and the anchors stayed in the document. Make them ephemeral: resolving (Apply OR the new Dismiss) makes the comment DISAPPEAR — hard-delete + remove the Yjs `comment` mark — UNLESS the thread has replies, in which case resolve it (preserve the discussion). Manual Resolve is unchanged. Scope: only comments with `suggestedText`. Server: - New collab event `deleteCommentMark` (collaboration.handler) mirroring resolveCommentMark, wiring the existing removeYjsMarkByAttribute to strip the anchor from the doc. - `finalizeAppliedSuggestion` forks on `hasChildren`: replies → apply + resolve (outcome 'resolved'); none → apply + hard-delete + mark removal (outcome 'deleted'). - New `dismissSuggestion` (validates top-level + suggestedText + not applied/not resolved) with the same fork; permission `canComment` (NOT canEdit — dismiss doesn't change page text); audit COMMENT_SUGGESTION_DISMISSED. New POST /comments/dismiss-suggestion; apply stays canEdit. - Both return `{ outcome: 'deleted' | 'resolved' }` so the client picks the optimistic action. Data-integrity (review F1): the shared `deleteEphemeralSuggestion` removes the anchor mark FIRST and FATALLY, then deletes the DB row only on success. The row delete is irreversible, so a mark-removal failure — including the COLLAB_DISABLE_REDIS "no live instance" hard-error — must abort the whole operation (→ 5xx, repeatable) rather than swallow the error and leave a permanent orphan anchor pointing at a deleted comment. `deleteCommentMark` is no longer best-effort (unlike resolve, where the row is kept and a failed mark is recoverable). Client: - `canShowDismiss` (canComment) alongside `canShowApply` (canEdit); a "Dismiss" button next to Apply in the suggestion block. - `useApplySuggestionMutation`/`useDismissSuggestionMutation` reconcile the cache on `outcome` ('deleted' → remove; 'resolved' → relocate to the resolved tab). - Idempotent races (review F2): BOTH apply and dismiss onError reduce 404/400 to success (comment already gone/resolved), dropping it from the cache instead of a red error — restores the #315 apply idempotency the ephemeral delete would otherwise break. - i18n Dismiss / "Не применять" (ru/en). Not done (flagged): deleteCommentMark on the normal /comments/delete path — left out (would change every non-suggestion delete + needs gateway injection; the interactive client already strips the mark via unsetComment). Out of scope per the issue. Tests: server — apply/dismiss delete-vs-resolve fork, all four dismiss state guards, the deleteCommentMark handler, controller authz (dismiss=canComment, apply=canEdit), AND a mark-removal-failure test proving the row is NOT deleted + the error propagates (F1). client — Dismiss show-conditions, outcome cache reconciliation, and 404 idempotent race for BOTH dismiss and apply (F2). Verified: server tsc clean; comment+collaboration jest 144 passed. client tsc clean; vitest 905 passed | 1 expected-fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Hocuspocus, Document } from '@hocuspocus/server';
|
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
|
import {
|
|
prosemirrorNodeToYElement,
|
|
tiptapExtensions,
|
|
} from './collaboration.util';
|
|
import {
|
|
removeYjsMarkByAttribute,
|
|
replaceYjsMarkedText,
|
|
setYjsMark,
|
|
updateYjsMarkAttribute,
|
|
YjsSelection,
|
|
} from './yjs.util';
|
|
import * as Y from 'yjs';
|
|
import { User } from '@docmost/db/types/entity.types';
|
|
|
|
export type CollabEventHandlers = ReturnType<
|
|
CollaborationHandler['getHandlers']
|
|
>;
|
|
|
|
@Injectable()
|
|
export class CollaborationHandler {
|
|
private readonly logger = new Logger(CollaborationHandler.name);
|
|
|
|
getHandlers(hocuspocus: Hocuspocus) {
|
|
return {
|
|
alterState: async (documentName: string, payload: { pageId: string }) => {
|
|
// dummy
|
|
// this.logger.log('Processing', documentName, payload);
|
|
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
|
|
// const fragment = doc.getXmlFragment('default');
|
|
//});
|
|
},
|
|
setCommentMark: async (
|
|
documentName: string,
|
|
payload: {
|
|
yjsSelection: YjsSelection;
|
|
commentId: string;
|
|
resolved: boolean;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { yjsSelection, commentId, resolved, user } = payload;
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
setYjsMark(doc, fragment, yjsSelection, 'comment', {
|
|
commentId,
|
|
resolved,
|
|
});
|
|
},
|
|
);
|
|
},
|
|
resolveCommentMark: async (
|
|
documentName: string,
|
|
payload: {
|
|
commentId: string;
|
|
resolved: boolean;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { commentId, resolved, user } = payload;
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
updateYjsMarkAttribute(
|
|
fragment,
|
|
'comment',
|
|
{ name: 'commentId', value: commentId },
|
|
{ resolved },
|
|
);
|
|
},
|
|
);
|
|
},
|
|
deleteCommentMark: async (
|
|
documentName: string,
|
|
payload: {
|
|
commentId: string;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { commentId, user } = payload;
|
|
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
|
// applied one has no replies, the comment is hard-deleted and its inline
|
|
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
|
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
|
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
|
// the collaborative document.
|
|
//
|
|
// Routing this through collaboration.gateway's handleYjsEvent means the
|
|
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
|
// no-op) and a missing live instance is a hard error — the same guarantee
|
|
// applyCommentSuggestion/resolveCommentMark rely on.
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
removeYjsMarkByAttribute(
|
|
fragment,
|
|
'comment',
|
|
'commentId',
|
|
commentId,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
applyCommentSuggestion: async (
|
|
documentName: string,
|
|
payload: {
|
|
commentId: string;
|
|
expectedText: string;
|
|
newText: string;
|
|
user: User;
|
|
},
|
|
): Promise<{ applied: boolean; currentText: string | null }> => {
|
|
const { commentId, expectedText, newText, user } = payload;
|
|
// Run the check-and-replace inside the owning instance's Y transaction so
|
|
// the delete+insert are atomic. The verdict from replaceYjsMarkedText is
|
|
// returned to the API-server caller (cross-process via the Redis bridge,
|
|
// or locally when Redis is disabled — see collaboration.gateway.ts).
|
|
return this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
return replaceYjsMarkedText(
|
|
fragment,
|
|
commentId,
|
|
expectedText,
|
|
newText,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
updatePageContent: async (
|
|
documentName: string,
|
|
payload: {
|
|
prosemirrorJson: any;
|
|
operation: string;
|
|
user: User;
|
|
},
|
|
) => {
|
|
const { operation, user } = payload;
|
|
const { prosemirrorJson } = payload;
|
|
this.logger.debug('Updating page content via yjs', documentName);
|
|
|
|
await this.withYdocConnection(
|
|
hocuspocus,
|
|
documentName,
|
|
{ user },
|
|
(doc) => {
|
|
const fragment = doc.getXmlFragment('default');
|
|
|
|
if (operation === 'replace') {
|
|
if (fragment.length > 0) {
|
|
fragment.delete(0, fragment.length);
|
|
}
|
|
|
|
const newDoc = TiptapTransformer.toYdoc(
|
|
prosemirrorJson,
|
|
'default',
|
|
tiptapExtensions,
|
|
);
|
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
|
|
} else {
|
|
const newContent = prosemirrorJson.content || [];
|
|
const yElements = newContent.map(prosemirrorNodeToYElement);
|
|
const position = operation === 'prepend' ? 0 : fragment.length;
|
|
fragment.insert(position, yElements);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
async withYdocConnection<T>(
|
|
hocuspocus: Hocuspocus,
|
|
documentName: string,
|
|
context: any = {},
|
|
// `fn` MUST be synchronous: hocuspocus `connection.transact(fn)` runs fn
|
|
// synchronously and does NOT await it, so any mutations after an `await`
|
|
// inside fn would execute OUTSIDE the Yjs transaction and lose atomicity.
|
|
fn: (doc: Document) => T,
|
|
): Promise<T> {
|
|
const connection = await hocuspocus.openDirectConnection(
|
|
documentName,
|
|
context,
|
|
);
|
|
try {
|
|
// hocuspocus `connection.transact(fn)` invokes fn(document) but does NOT
|
|
// forward fn's return value, so we capture it in a closure and return it
|
|
// after the transaction (and its storeDocument hooks) resolve.
|
|
let result: T;
|
|
await connection.transact((doc) => {
|
|
result = fn(doc);
|
|
});
|
|
return result!;
|
|
} finally {
|
|
await connection.disconnect();
|
|
}
|
|
}
|
|
}
|