From f65eeddc199f8e5664e5ac07552c283c01c2bec2 Mon Sep 17 00:00:00 2001 From: SvenAlHamad Date: Wed, 22 Jul 2026 11:41:39 +0200 Subject: [PATCH 1/6] feat(collaboration): threaded comments for Headless CMS entries and fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a collaboration feature that lets a team leave threaded comments on Headless CMS entries — either on the whole entry or anchored to a specific field — with replies, resolve/reopen, soft-delete, edit, and @mentions. New packages: - @webiny/api-collaboration: a private CMS model (wbyCollabThread) with nested messages (no raw DynamoDB), use cases (create/get/list/reply/resolve/reopen/ edit-message/delete-message/delete-thread), GraphQL under the `collaboration` namespace, and an app-agnostic LocatorResolver seam. Ships a CMS locator resolver that resolves field breadcrumbs / entry-level anchors and enforces read access via the existing entry use cases. Comment access == read access to the target; soft-delete only. - @webiny/app-collaboration: the admin UI — a header Comments toggle, an animated side panel, per-field markers, a composer, thread cards (reply/resolve/reopen/edit/delete), @mention autocomplete, entry-level vs field-anchored comments, and jump-to-field. Supporting changes: - app-admin FormModel: expose `qualifiedName` on IFieldVM and add a decoratable per-field wrapper (both additive) so a feature can anchor UI to a field. - Register createCollaboration() in the API graphql templates and mount in app-serverless-cms. Out of scope (follow-up PRs): tasks + assignee inbox, the APW publish gate, and the unified activity feed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/Collaboration.test.ts | 135 ++++++ .../__tests__/__helpers/graphql.ts | 161 +++++++ .../__tests__/__helpers/handler.ts | 72 ++++ .../__tests__/modelLocator.test.ts | 104 +++++ packages/api-collaboration/ci.config.json | 6 + packages/api-collaboration/package.json | 43 ++ packages/api-collaboration/src/constants.ts | 8 + .../src/domain/locator/abstractions.ts | 47 +++ .../src/domain/locator/errors.ts | 12 + .../src/domain/thread/CollabThreadMapper.ts | 43 ++ .../src/domain/thread/abstractions.ts | 74 ++++ .../src/domain/thread/errors.ts | 68 +++ .../src/domain/thread/threadModel.ts | 110 +++++ .../CmsLocatorResolver/CmsLocatorResolver.ts | 61 +++ .../cms/CmsLocatorResolver/feature.ts | 9 + .../cms/CmsLocatorResolver/modelLocator.ts | 81 ++++ .../ResolveLocator/ResolveLocatorUseCase.ts | 27 ++ .../locator/ResolveLocator/abstractions.ts | 30 ++ .../locator/ResolveLocator/feature.ts | 9 + .../features/locator/ResolveLocator/index.ts | 1 + .../CreateThread/CreateThreadRepository.ts | 39 ++ .../CreateThread/CreateThreadUseCase.ts | 95 +++++ .../thread/CreateThread/abstractions.ts | 80 ++++ .../features/thread/CreateThread/feature.ts | 11 + .../src/features/thread/CreateThread/index.ts | 1 + .../DeleteThread/DeleteThreadUseCase.ts | 47 +++ .../thread/DeleteThread/abstractions.ts | 27 ++ .../features/thread/DeleteThread/feature.ts | 9 + .../src/features/thread/DeleteThread/index.ts | 1 + .../thread/GetThread/GetThreadRepository.ts | 41 ++ .../thread/GetThread/GetThreadUseCase.ts | 49 +++ .../features/thread/GetThread/abstractions.ts | 54 +++ .../src/features/thread/GetThread/feature.ts | 11 + .../src/features/thread/GetThread/index.ts | 1 + .../ListThreads/ListThreadsRepository.ts | 51 +++ .../thread/ListThreads/ListThreadsUseCase.ts | 48 +++ .../thread/ListThreads/abstractions.ts | 69 +++ .../features/thread/ListThreads/feature.ts | 11 + .../src/features/thread/ListThreads/index.ts | 1 + .../MessageOperations/DeleteMessageUseCase.ts | 59 +++ .../MessageOperations/UpdateMessageUseCase.ts | 61 +++ .../thread/MessageOperations/abstractions.ts | 65 +++ .../thread/MessageOperations/feature.ts | 11 + .../thread/MessageOperations/index.ts | 1 + .../ReplyToThread/ReplyToThreadUseCase.ts | 62 +++ .../thread/ReplyToThread/abstractions.ts | 38 ++ .../features/thread/ReplyToThread/feature.ts | 9 + .../features/thread/ReplyToThread/index.ts | 1 + .../ThreadResolution/ReopenThreadUseCase.ts | 36 ++ .../ThreadResolution/ResolveThreadUseCase.ts | 39 ++ .../thread/ThreadResolution/abstractions.ts | 47 +++ .../thread/ThreadResolution/feature.ts | 11 + .../features/thread/ThreadResolution/index.ts | 1 + .../UpdateThread/UpdateThreadRepository.ts | 61 +++ .../thread/UpdateThread/abstractions.ts | 31 ++ .../features/thread/UpdateThread/feature.ts | 9 + .../src/features/thread/UpdateThread/index.ts | 1 + .../features/thread/shared/abstractions.ts | 11 + .../src/graphql/collaboration.ts | 371 +++++++++++++++++ .../src/graphql/validation.ts | 54 +++ packages/api-collaboration/src/index.ts | 70 ++++ packages/api-collaboration/src/types.ts | 5 + .../src/utils/cmsContentId.ts | 30 ++ .../api-collaboration/src/utils/identity.ts | 16 + .../api-collaboration/tsconfig.build.json | 41 ++ packages/api-collaboration/tsconfig.json | 41 ++ packages/api-collaboration/vitest.config.ts | 34 ++ packages/api-collaboration/webiny.config.js | 8 + .../app-admin/src/features/formModel/Field.ts | 1 + .../src/features/formModel/ObjectField.ts | 1 + .../src/features/formModel/abstractions.ts | 2 + .../formModel/createFieldRenderer.tsx | 19 +- packages/app-collaboration/package.json | 45 ++ packages/app-collaboration/src/app.tsx | 27 ++ .../src/cms/CommentFieldMarker.tsx | 49 +++ .../src/cms/CommentsHeaderButton.tsx | 17 + .../src/cms/CommentsSidePanelDecorator.tsx | 75 ++++ .../src/cms/FieldMarkerDecorator.tsx | 32 ++ .../app-collaboration/src/cms/fieldLabels.ts | 84 ++++ packages/app-collaboration/src/constants.ts | 12 + .../src/features/api/CollaborationApi.ts | 48 +++ .../src/features/api/CollaborationGateway.ts | 227 ++++++++++ .../src/features/api/abstractions.ts | 53 +++ .../src/features/api/feature.ts | 17 + .../src/features/api/graphqlFields.ts | 52 +++ packages/app-collaboration/src/index.tsx | 12 + .../comments/CommentsPresenter.ts | 193 +++++++++ .../src/presentation/comments/abstractions.ts | 56 +++ .../comments/components/AutoTextarea.tsx | 56 +++ .../comments/components/CommentsPanel.tsx | 135 ++++++ .../comments/components/CommentsToggle.tsx | 41 ++ .../comments/components/Composer.tsx | 116 ++++++ .../comments/components/MentionTextarea.tsx | 213 ++++++++++ .../comments/components/ThreadCard.tsx | 394 ++++++++++++++++++ .../src/presentation/comments/feature.ts | 15 + .../src/presentation/comments/styles.ts | 215 ++++++++++ .../src/presentation/comments/useComments.ts | 6 + packages/app-collaboration/src/types.ts | 73 ++++ .../app-collaboration/tsconfig.build.json | 41 ++ packages/app-collaboration/tsconfig.json | 41 ++ packages/app-collaboration/webiny.config.js | 8 + packages/app-serverless-cms/package.json | 1 + packages/app-serverless-cms/src/Admin.tsx | 2 + .../app-serverless-cms/tsconfig.build.json | 3 + packages/app-serverless-cms/tsconfig.json | 3 + packages/cli/files/references.json | 2 +- .../appTemplates/api/graphql/package.json | 1 + .../appTemplates/api/graphql/src/index.ts | 2 + .../OpenSearch/api/graphql/src/index.ts | 2 + .../sqlite/api/graphql/src/index.ts | 2 + yarn.lock | 47 +++ 111 files changed, 5410 insertions(+), 2 deletions(-) create mode 100644 packages/api-collaboration/__tests__/Collaboration.test.ts create mode 100644 packages/api-collaboration/__tests__/__helpers/graphql.ts create mode 100644 packages/api-collaboration/__tests__/__helpers/handler.ts create mode 100644 packages/api-collaboration/__tests__/modelLocator.test.ts create mode 100644 packages/api-collaboration/ci.config.json create mode 100644 packages/api-collaboration/package.json create mode 100644 packages/api-collaboration/src/constants.ts create mode 100644 packages/api-collaboration/src/domain/locator/abstractions.ts create mode 100644 packages/api-collaboration/src/domain/locator/errors.ts create mode 100644 packages/api-collaboration/src/domain/thread/CollabThreadMapper.ts create mode 100644 packages/api-collaboration/src/domain/thread/abstractions.ts create mode 100644 packages/api-collaboration/src/domain/thread/errors.ts create mode 100644 packages/api-collaboration/src/domain/thread/threadModel.ts create mode 100644 packages/api-collaboration/src/features/cms/CmsLocatorResolver/CmsLocatorResolver.ts create mode 100644 packages/api-collaboration/src/features/cms/CmsLocatorResolver/feature.ts create mode 100644 packages/api-collaboration/src/features/cms/CmsLocatorResolver/modelLocator.ts create mode 100644 packages/api-collaboration/src/features/locator/ResolveLocator/ResolveLocatorUseCase.ts create mode 100644 packages/api-collaboration/src/features/locator/ResolveLocator/abstractions.ts create mode 100644 packages/api-collaboration/src/features/locator/ResolveLocator/feature.ts create mode 100644 packages/api-collaboration/src/features/locator/ResolveLocator/index.ts create mode 100644 packages/api-collaboration/src/features/thread/CreateThread/CreateThreadRepository.ts create mode 100644 packages/api-collaboration/src/features/thread/CreateThread/CreateThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/CreateThread/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/CreateThread/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/CreateThread/index.ts create mode 100644 packages/api-collaboration/src/features/thread/DeleteThread/DeleteThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/DeleteThread/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/DeleteThread/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/DeleteThread/index.ts create mode 100644 packages/api-collaboration/src/features/thread/GetThread/GetThreadRepository.ts create mode 100644 packages/api-collaboration/src/features/thread/GetThread/GetThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/GetThread/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/GetThread/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/GetThread/index.ts create mode 100644 packages/api-collaboration/src/features/thread/ListThreads/ListThreadsRepository.ts create mode 100644 packages/api-collaboration/src/features/thread/ListThreads/ListThreadsUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/ListThreads/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/ListThreads/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/ListThreads/index.ts create mode 100644 packages/api-collaboration/src/features/thread/MessageOperations/DeleteMessageUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/MessageOperations/UpdateMessageUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/MessageOperations/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/MessageOperations/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/MessageOperations/index.ts create mode 100644 packages/api-collaboration/src/features/thread/ReplyToThread/ReplyToThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/ReplyToThread/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/ReplyToThread/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/ReplyToThread/index.ts create mode 100644 packages/api-collaboration/src/features/thread/ThreadResolution/ReopenThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/ThreadResolution/ResolveThreadUseCase.ts create mode 100644 packages/api-collaboration/src/features/thread/ThreadResolution/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/ThreadResolution/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/ThreadResolution/index.ts create mode 100644 packages/api-collaboration/src/features/thread/UpdateThread/UpdateThreadRepository.ts create mode 100644 packages/api-collaboration/src/features/thread/UpdateThread/abstractions.ts create mode 100644 packages/api-collaboration/src/features/thread/UpdateThread/feature.ts create mode 100644 packages/api-collaboration/src/features/thread/UpdateThread/index.ts create mode 100644 packages/api-collaboration/src/features/thread/shared/abstractions.ts create mode 100644 packages/api-collaboration/src/graphql/collaboration.ts create mode 100644 packages/api-collaboration/src/graphql/validation.ts create mode 100644 packages/api-collaboration/src/index.ts create mode 100644 packages/api-collaboration/src/types.ts create mode 100644 packages/api-collaboration/src/utils/cmsContentId.ts create mode 100644 packages/api-collaboration/src/utils/identity.ts create mode 100644 packages/api-collaboration/tsconfig.build.json create mode 100644 packages/api-collaboration/tsconfig.json create mode 100644 packages/api-collaboration/vitest.config.ts create mode 100644 packages/api-collaboration/webiny.config.js create mode 100644 packages/app-collaboration/package.json create mode 100644 packages/app-collaboration/src/app.tsx create mode 100644 packages/app-collaboration/src/cms/CommentFieldMarker.tsx create mode 100644 packages/app-collaboration/src/cms/CommentsHeaderButton.tsx create mode 100644 packages/app-collaboration/src/cms/CommentsSidePanelDecorator.tsx create mode 100644 packages/app-collaboration/src/cms/FieldMarkerDecorator.tsx create mode 100644 packages/app-collaboration/src/cms/fieldLabels.ts create mode 100644 packages/app-collaboration/src/constants.ts create mode 100644 packages/app-collaboration/src/features/api/CollaborationApi.ts create mode 100644 packages/app-collaboration/src/features/api/CollaborationGateway.ts create mode 100644 packages/app-collaboration/src/features/api/abstractions.ts create mode 100644 packages/app-collaboration/src/features/api/feature.ts create mode 100644 packages/app-collaboration/src/features/api/graphqlFields.ts create mode 100644 packages/app-collaboration/src/index.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/CommentsPresenter.ts create mode 100644 packages/app-collaboration/src/presentation/comments/abstractions.ts create mode 100644 packages/app-collaboration/src/presentation/comments/components/AutoTextarea.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/components/CommentsPanel.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/components/CommentsToggle.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/components/Composer.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/components/MentionTextarea.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/components/ThreadCard.tsx create mode 100644 packages/app-collaboration/src/presentation/comments/feature.ts create mode 100644 packages/app-collaboration/src/presentation/comments/styles.ts create mode 100644 packages/app-collaboration/src/presentation/comments/useComments.ts create mode 100644 packages/app-collaboration/src/types.ts create mode 100644 packages/app-collaboration/tsconfig.build.json create mode 100644 packages/app-collaboration/tsconfig.json create mode 100644 packages/app-collaboration/webiny.config.js diff --git a/packages/api-collaboration/__tests__/Collaboration.test.ts b/packages/api-collaboration/__tests__/Collaboration.test.ts new file mode 100644 index 00000000000..cbde57b3161 --- /dev/null +++ b/packages/api-collaboration/__tests__/Collaboration.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { createGraphQLHandler, TEST_CONTENT_TYPE } from "~tests/__helpers/handler.js"; + +const CONTENT_ID = "articleModel:entry-123"; + +describe("collaboration threads (graphql)", () => { + const handler = createGraphQLHandler(); + + it("runs the full thread lifecycle: create, list, get, reply, resolve, reopen, edit, delete", async () => { + // 1. Create a thread with its first message. + const [createResponse] = await handler.createCollabThread({ + input: { + contentType: TEST_CONTENT_TYPE, + contentId: CONTENT_ID, + locator: "title", + type: "note", + body: "Should this be the SEO title?", + mentions: ["user-marko"] + } + }); + + const created = createResponse.data.collaboration.createCollabThread; + expect(created.error).toBeNull(); + expect(created.data).toMatchObject({ + contentType: TEST_CONTENT_TYPE, + contentId: CONTENT_ID, + locator: "title", + type: "note", + resolved: false, + anchor: { exists: true, authorized: true, label: "Test Field", path: ["Tab 1"] } + }); + expect(created.data.messages).toHaveLength(1); + expect(created.data.messages[0]).toMatchObject({ + body: "Should this be the SEO title?", + mentions: ["user-marko"] + }); + expect(created.data.createdBy.id).toBeTruthy(); + + const threadId = created.data.id; + const firstMessageId = created.data.messages[0].id; + + // 2. List threads for the content target. + const [listResponse] = await handler.listCollabThreads({ + where: { contentType: TEST_CONTENT_TYPE, contentId: CONTENT_ID } + }); + const list = listResponse.data.collaboration.listCollabThreads; + expect(list.error).toBeNull(); + expect(list.data).toHaveLength(1); + expect(list.data[0].id).toBe(threadId); + expect(list.meta.totalCount).toBe(1); + + // 3. Get the single thread. + const [getResponse] = await handler.getCollabThread({ id: threadId }); + expect(getResponse.data.collaboration.getCollabThread.data.id).toBe(threadId); + + // 4. Reply, then confirm two messages. + const [replyResponse] = await handler.replyToCollabThread({ + threadId, + body: "Display title. SEO title lives in Tab 2." + }); + expect(replyResponse.data.collaboration.replyToCollabThread.error).toBeNull(); + const replyId = replyResponse.data.collaboration.replyToCollabThread.data.id; + expect(replyId).toBeTruthy(); + + const [afterReply] = await handler.getCollabThread({ id: threadId }); + expect(afterReply.data.collaboration.getCollabThread.data.messages).toHaveLength(2); + + // 5. Resolve — records resolvedBy. + const [resolveResponse] = await handler.resolveCollabThread({ id: threadId }); + const resolved = resolveResponse.data.collaboration.resolveCollabThread.data; + expect(resolved.resolved).toBe(true); + expect(resolved.resolvedBy.id).toBeTruthy(); + expect(resolved.resolvedOn).toBeTruthy(); + + // 6. Reopen — clears resolution. + const [reopenResponse] = await handler.reopenCollabThread({ id: threadId }); + const reopened = reopenResponse.data.collaboration.reopenCollabThread.data; + expect(reopened.resolved).toBe(false); + expect(reopened.resolvedBy).toBeNull(); + + // 7. Edit the first message. + const [editResponse] = await handler.updateCollabMessage({ + threadId, + messageId: firstMessageId, + body: "Edited: which title convention are we using?" + }); + expect(editResponse.data.collaboration.updateCollabMessage.data.body).toBe( + "Edited: which title convention are we using?" + ); + + // 8. Soft-delete the reply. + const [deleteMessageResponse] = await handler.deleteCollabMessage({ + threadId, + messageId: replyId + }); + expect(deleteMessageResponse.data.collaboration.deleteCollabMessage.data).toBe(true); + + const [afterMessageDelete] = await handler.getCollabThread({ id: threadId }); + const deletedMessage = + afterMessageDelete.data.collaboration.getCollabThread.data.messages.find( + (message: { id: string }) => message.id === replyId + ); + expect(deletedMessage.deleted).toBe(true); + + // 9. Soft-delete the thread; it disappears from reads. + const [deleteThreadResponse] = await handler.deleteCollabThread({ id: threadId }); + expect(deleteThreadResponse.data.collaboration.deleteCollabThread.data).toBe(true); + + const [afterThreadDelete] = await handler.getCollabThread({ id: threadId }); + expect(afterThreadDelete.data.collaboration.getCollabThread.data).toBeNull(); + expect(afterThreadDelete.data.collaboration.getCollabThread.error).not.toBeNull(); + + const [listAfterDelete] = await handler.listCollabThreads({ + where: { contentType: TEST_CONTENT_TYPE, contentId: CONTENT_ID } + }); + expect(listAfterDelete.data.collaboration.listCollabThreads.data).toHaveLength(0); + }); + + it("denies creating a thread when no resolver owns the content type", async () => { + const [response] = await handler.createCollabThread({ + input: { + contentType: "unknown.type", + contentId: "x:y", + locator: "title", + type: "note", + body: "Nobody can resolve this anchor." + } + }); + + const result = response.data.collaboration.createCollabThread; + expect(result.data).toBeNull(); + expect(result.error).not.toBeNull(); + expect(result.error.code).toBe("Collaboration/Thread/NotAuthorized"); + }); +}); diff --git a/packages/api-collaboration/__tests__/__helpers/graphql.ts b/packages/api-collaboration/__tests__/__helpers/graphql.ts new file mode 100644 index 00000000000..55195712d80 --- /dev/null +++ b/packages/api-collaboration/__tests__/__helpers/graphql.ts @@ -0,0 +1,161 @@ +import type { GenericRecord } from "@webiny/api/types.js"; + +export interface ICollabError { + code: string; + message: string; + data?: GenericRecord; +} + +const ERROR = /* GraphQL */ ` + error { + code + message + } +`; + +const IDENTITY = /* GraphQL */ ` + { + id + displayName + type + } +`; + +const MESSAGE = /* GraphQL */ ` + { + id + body + mentions + createdBy ${IDENTITY} + createdOn + deleted + } +`; + +const THREAD = /* GraphQL */ ` + { + id + contentType + contentId + locator + type + resolved + resolvedBy ${IDENTITY} + resolvedOn + assigneeId + dueDate + createdBy ${IDENTITY} + createdOn + messages ${MESSAGE} + anchor { + exists + authorized + label + path + } + } +`; + +export const CREATE_COLLAB_THREAD_MUTATION = /* GraphQL */ ` + mutation CreateCollabThread($input: CreateCollabThreadInput!) { + collaboration { + createCollabThread(input: $input) { + data ${THREAD} + ${ERROR} + } + } + } +`; + +export const LIST_COLLAB_THREADS_QUERY = /* GraphQL */ ` + query ListCollabThreads($where: ListCollabThreadsWhereInput!, $limit: Int, $after: String) { + collaboration { + listCollabThreads(where: $where, limit: $limit, after: $after) { + data ${THREAD} + meta { + totalCount + hasMoreItems + cursor + } + ${ERROR} + } + } + } +`; + +export const GET_COLLAB_THREAD_QUERY = /* GraphQL */ ` + query GetCollabThread($id: ID!) { + collaboration { + getCollabThread(id: $id) { + data ${THREAD} + ${ERROR} + } + } + } +`; + +export const REPLY_TO_COLLAB_THREAD_MUTATION = /* GraphQL */ ` + mutation ReplyToCollabThread($threadId: ID!, $body: String!, $mentions: [String!]) { + collaboration { + replyToCollabThread(threadId: $threadId, body: $body, mentions: $mentions) { + data ${MESSAGE} + ${ERROR} + } + } + } +`; + +export const RESOLVE_COLLAB_THREAD_MUTATION = /* GraphQL */ ` + mutation ResolveCollabThread($id: ID!) { + collaboration { + resolveCollabThread(id: $id) { + data ${THREAD} + ${ERROR} + } + } + } +`; + +export const REOPEN_COLLAB_THREAD_MUTATION = /* GraphQL */ ` + mutation ReopenCollabThread($id: ID!) { + collaboration { + reopenCollabThread(id: $id) { + data ${THREAD} + ${ERROR} + } + } + } +`; + +export const UPDATE_COLLAB_MESSAGE_MUTATION = /* GraphQL */ ` + mutation UpdateCollabMessage($threadId: ID!, $messageId: ID!, $body: String!) { + collaboration { + updateCollabMessage(threadId: $threadId, messageId: $messageId, body: $body) { + data ${MESSAGE} + ${ERROR} + } + } + } +`; + +export const DELETE_COLLAB_MESSAGE_MUTATION = /* GraphQL */ ` + mutation DeleteCollabMessage($threadId: ID!, $messageId: ID!) { + collaboration { + deleteCollabMessage(threadId: $threadId, messageId: $messageId) { + data + ${ERROR} + } + } + } +`; + +export const DELETE_COLLAB_THREAD_MUTATION = /* GraphQL */ ` + mutation DeleteCollabThread($id: ID!) { + collaboration { + deleteCollabThread(id: $id) { + data + ${ERROR} + } + } + } +`; diff --git a/packages/api-collaboration/__tests__/__helpers/handler.ts b/packages/api-collaboration/__tests__/__helpers/handler.ts new file mode 100644 index 00000000000..f8dad135231 --- /dev/null +++ b/packages/api-collaboration/__tests__/__helpers/handler.ts @@ -0,0 +1,72 @@ +import { useGraphQLHandler, type UseGraphQLHandlerParams } from "@webiny/testing"; +import { PluginsContainer } from "@webiny/plugins"; +import { ContextPlugin } from "@webiny/api"; +import { createCollaboration } from "~/index.js"; +import { CollabLocatorResolver } from "~/domain/locator/abstractions.js"; +import { + CREATE_COLLAB_THREAD_MUTATION, + DELETE_COLLAB_MESSAGE_MUTATION, + DELETE_COLLAB_THREAD_MUTATION, + GET_COLLAB_THREAD_QUERY, + LIST_COLLAB_THREADS_QUERY, + REOPEN_COLLAB_THREAD_MUTATION, + REPLY_TO_COLLAB_THREAD_MUTATION, + RESOLVE_COLLAB_THREAD_MUTATION, + UPDATE_COLLAB_MESSAGE_MUTATION +} from "./graphql.js"; + +/** + * A content type used only by tests. It stands in for a real content app (CMS/WB) so the + * collaboration core can be exercised without seeding a full CMS model + entry. + */ +export const TEST_CONTENT_TYPE = "test.entry"; + +/** + * Stub resolver: any anchor exists and is readable. The `label`/`path` mirror the shape a real + * resolver returns so assertions cover the anchor projection too. + */ +class TestLocatorResolverImpl implements CollabLocatorResolver.Interface { + public readonly contentType = TEST_CONTENT_TYPE; + + async resolve(): Promise { + return { exists: true, authorized: true, label: "Test Field", path: ["Tab 1"] }; + } +} + +const TestLocatorResolver = CollabLocatorResolver.createImplementation({ + implementation: TestLocatorResolverImpl, + dependencies: [] +}); + +export const createGraphQLHandler = (params: UseGraphQLHandlerParams = {}) => { + const plugins = new PluginsContainer(params.plugins || []); + + plugins.register(createCollaboration()); + + // Register the test stub resolver into the same container. + plugins.register( + new ContextPlugin(async context => { + context.container.register(TestLocatorResolver); + }) + ); + + const handler = useGraphQLHandler({ + ...params, + permissions: [{ name: "*" }], + debug: params.debug === undefined ? true : params.debug, + plugins: plugins.all() + }); + + return { + handler, + createCollabThread: handler.createMutation(CREATE_COLLAB_THREAD_MUTATION), + listCollabThreads: handler.createQuery(LIST_COLLAB_THREADS_QUERY), + getCollabThread: handler.createQuery(GET_COLLAB_THREAD_QUERY), + replyToCollabThread: handler.createMutation(REPLY_TO_COLLAB_THREAD_MUTATION), + resolveCollabThread: handler.createMutation(RESOLVE_COLLAB_THREAD_MUTATION), + reopenCollabThread: handler.createMutation(REOPEN_COLLAB_THREAD_MUTATION), + updateCollabMessage: handler.createMutation(UPDATE_COLLAB_MESSAGE_MUTATION), + deleteCollabMessage: handler.createMutation(DELETE_COLLAB_MESSAGE_MUTATION), + deleteCollabThread: handler.createMutation(DELETE_COLLAB_THREAD_MUTATION) + }; +}; diff --git a/packages/api-collaboration/__tests__/modelLocator.test.ts b/packages/api-collaboration/__tests__/modelLocator.test.ts new file mode 100644 index 00000000000..2b0b66d8600 --- /dev/null +++ b/packages/api-collaboration/__tests__/modelLocator.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; +import { walkModelLocator } from "~/features/cms/CmsLocatorResolver/modelLocator.js"; +import { formatCmsContentId, parseCmsContentId } from "~/utils/cmsContentId.js"; + +// A minimal model exercising top-level, nested object, list, and dynamic-zone fields. +const model = { + modelId: "article", + fields: [ + { fieldId: "title", label: "Title", type: "text" }, + { + fieldId: "author", + label: "Author", + type: "object", + settings: { + fields: [{ fieldId: "name", label: "Name", type: "text" }] + } + }, + { + fieldId: "gallery", + label: "Gallery", + type: "object", + settings: { + fields: [{ fieldId: "caption", label: "Caption", type: "text" }] + } + }, + { + fieldId: "content", + label: "Content", + type: "dynamicZone", + settings: { + templates: [ + { + id: "hero", + fields: [{ fieldId: "heading", label: "Heading", type: "text" }] + } + ] + } + } + ] +} as unknown as CmsModel; + +describe("walkModelLocator", () => { + it("resolves a top-level field with no breadcrumb", () => { + expect(walkModelLocator(model, "title")).toEqual({ + exists: true, + label: "Title", + path: undefined + }); + }); + + it("resolves a nested object field with a breadcrumb", () => { + expect(walkModelLocator(model, "author.name")).toEqual({ + exists: true, + label: "Name", + path: ["Author"] + }); + }); + + it("skips numeric list indices", () => { + expect(walkModelLocator(model, "gallery.2.caption")).toEqual({ + exists: true, + label: "Caption", + path: ["Gallery"] + }); + }); + + it("descends dynamic-zone templates", () => { + expect(walkModelLocator(model, "content.heading")).toEqual({ + exists: true, + label: "Heading", + path: ["Content"] + }); + }); + + it("reports a removed top-level field as non-existent", () => { + expect(walkModelLocator(model, "subtitle")).toEqual({ exists: false }); + }); + + it("reports a removed nested field as non-existent", () => { + expect(walkModelLocator(model, "author.unknown")).toEqual({ exists: false }); + }); +}); + +describe("cmsContentId", () => { + it("round-trips modelId and entryId", () => { + expect(parseCmsContentId(formatCmsContentId("article", "entry-1"))).toEqual({ + modelId: "article", + entryId: "entry-1" + }); + }); + + it("splits on the first separator only", () => { + expect(parseCmsContentId("article:a:b")).toEqual({ + modelId: "article", + entryId: "a:b" + }); + }); + + it("returns nulls when malformed", () => { + expect(parseCmsContentId("noseparator")).toEqual({ modelId: null, entryId: null }); + expect(parseCmsContentId("article:")).toEqual({ modelId: null, entryId: null }); + }); +}); diff --git a/packages/api-collaboration/ci.config.json b/packages/api-collaboration/ci.config.json new file mode 100644 index 00000000000..5a6d5348b40 --- /dev/null +++ b/packages/api-collaboration/ci.config.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../.github/workflows/ci.config.schema.json", + "vitest": { + "storageOps": ["ddb", "ddb-os,ddb"] + } +} diff --git a/packages/api-collaboration/package.json b/packages/api-collaboration/package.json new file mode 100644 index 00000000000..15f3c6f0d5b --- /dev/null +++ b/packages/api-collaboration/package.json @@ -0,0 +1,43 @@ +{ + "name": "@webiny/api-collaboration", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./*": "./*" + }, + "description": "Collaboration API (threaded comments and tasks on content)", + "keywords": [ + "api-collaboration:base" + ], + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/api-collaboration" + }, + "license": "MIT", + "dependencies": { + "@webiny/api-core": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/feature": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/utils": "0.0.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@webiny/build-tools": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/project-utils": "0.0.0", + "@webiny/testing": "0.0.0", + "type-fest": "^5.8.0", + "typescript": "7.0.2", + "vitest": "^4.1.10" + }, + "publishConfig": { + "access": "public" + }, + "webiny": { + "publishFrom": "dist" + } +} diff --git a/packages/api-collaboration/src/constants.ts b/packages/api-collaboration/src/constants.ts new file mode 100644 index 00000000000..4b9e7491c4a --- /dev/null +++ b/packages/api-collaboration/src/constants.ts @@ -0,0 +1,8 @@ +export const COLLAB_THREAD_MODEL_ID = "wbyCollabThread"; + +/** + * Known content types. The collaboration core is content-agnostic — each content app + * registers a locator resolver for its own content type. These constants exist only so + * the built-in integrations agree on a value. + */ +export const CONTENT_TYPE_CMS_ENTRY = "cms.entry"; diff --git a/packages/api-collaboration/src/domain/locator/abstractions.ts b/packages/api-collaboration/src/domain/locator/abstractions.ts new file mode 100644 index 00000000000..51634218115 --- /dev/null +++ b/packages/api-collaboration/src/domain/locator/abstractions.ts @@ -0,0 +1,47 @@ +import { createAbstraction } from "@webiny/feature/api"; + +export interface ICollabLocatorResolveParams { + contentType: string; + contentId: string; + locator: string; +} + +export interface ICollabLocatorResolution { + /** + * Whether the anchor still exists in the current revision of the target content. + */ + exists: boolean; + /** + * Whether the current identity is allowed to read the target content. Thread access + * equals read access to the target, so resolvers double as the permission gate. + */ + authorized: boolean; + /** + * Human label for the anchor (field display name, block type/name). + */ + label?: string; + /** + * Ancestor labels for display as a breadcrumb (e.g. ["Tab 1", "Field 1"]). + */ + path?: string[]; +} + +/** + * Registered (as a multi-injection) by each content app. The collaboration core treats the + * `locator` as an opaque string and delegates resolution + read-access checks to the resolver + * that owns a given `contentType`. + */ +export interface ICollabLocatorResolver { + // e.g. "cms.entry", "pb.page" + contentType: string; + resolve(params: ICollabLocatorResolveParams): Promise; +} + +export const CollabLocatorResolver = + createAbstraction("CollabLocatorResolver"); + +export namespace CollabLocatorResolver { + export type Interface = ICollabLocatorResolver; + export type Params = ICollabLocatorResolveParams; + export type Resolution = ICollabLocatorResolution; +} diff --git a/packages/api-collaboration/src/domain/locator/errors.ts b/packages/api-collaboration/src/domain/locator/errors.ts new file mode 100644 index 00000000000..3fdaaf0bb46 --- /dev/null +++ b/packages/api-collaboration/src/domain/locator/errors.ts @@ -0,0 +1,12 @@ +import { BaseError } from "@webiny/feature/api"; + +export class CollabResolverNotFoundError extends BaseError<{ contentType: string }> { + override readonly code = "Collaboration/Resolver/NotFound" as const; + + constructor(contentType: string) { + super({ + message: `No collaboration locator resolver is registered for content type "${contentType}".`, + data: { contentType } + }); + } +} diff --git a/packages/api-collaboration/src/domain/thread/CollabThreadMapper.ts b/packages/api-collaboration/src/domain/thread/CollabThreadMapper.ts new file mode 100644 index 00000000000..1a542cfc525 --- /dev/null +++ b/packages/api-collaboration/src/domain/thread/CollabThreadMapper.ts @@ -0,0 +1,43 @@ +import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; +import type { CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { + CollabThreadMapper as MapperAbstraction, + CollabThreadType, + type ICollabThread, + type ICollabThreadValues +} from "./abstractions.js"; + +class CollabThreadMapperImpl implements MapperAbstraction.Interface { + fromCmsEntry(entry: CmsEntry): ICollabThread { + const { id } = parseIdentifier(entry.id); + const values = entry.values; + + return { + id, + contentType: values.contentType, + contentId: values.contentId, + locator: values.locator, + type: values.type ?? CollabThreadType.note, + resolved: values.resolved ?? false, + resolvedBy: values.resolvedBy ?? null, + resolvedOn: values.resolvedOn ?? null, + assigneeId: values.assigneeId ?? null, + dueDate: values.dueDate ?? null, + messages: values.messages ?? [], + deleted: values.deleted ?? false, + deletedBy: values.deletedBy ?? null, + deletedOn: values.deletedOn ?? null, + createdBy: { + id: entry.createdBy.id, + displayName: entry.createdBy.displayName, + type: entry.createdBy.type + }, + createdOn: entry.createdOn + }; + } +} + +export const CollabThreadMapper = MapperAbstraction.createImplementation({ + implementation: CollabThreadMapperImpl, + dependencies: [] +}); diff --git a/packages/api-collaboration/src/domain/thread/abstractions.ts b/packages/api-collaboration/src/domain/thread/abstractions.ts new file mode 100644 index 00000000000..8cc6b561602 --- /dev/null +++ b/packages/api-collaboration/src/domain/thread/abstractions.ts @@ -0,0 +1,74 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "@webiny/api-headless-cms/types"; + +export enum CollabThreadType { + note = "note", + task = "task" +} + +/** + * The canonical Webiny identity projection stored on records and messages. + */ +export interface ICollabIdentity { + id: string; + displayName: string; + type: string; +} + +export interface ICollabMessage { + id: string; + body: string; + mentions: string[]; + createdBy: ICollabIdentity; + createdOn: string; + deleted?: boolean; + deletedBy?: ICollabIdentity | null; + deletedOn?: string | null; +} + +/** + * The values persisted on the `wbyCollabThread` CMS entry. `createdBy`/`createdOn` for the + * thread itself are the CMS entry system fields and are NOT part of the stored values. + */ +export interface ICollabThreadValues { + contentType: string; + contentId: string; + locator: string; + type: CollabThreadType; + resolved: boolean; + resolvedBy?: ICollabIdentity | null; + resolvedOn?: string | null; + // task variant only + assigneeId?: string | null; + dueDate?: string | null; + messages: ICollabMessage[]; + // soft-delete of the whole thread + deleted?: boolean; + deletedBy?: ICollabIdentity | null; + deletedOn?: string | null; +} + +export interface ICollabThread extends ICollabThreadValues { + id: string; + createdBy: ICollabIdentity; + createdOn: string; +} + +/** + * The `wbyCollabThread` private CMS model, resolved to a `CmsModel` instance at boot. + */ +export const CollabThreadModel = createAbstraction("CollabThreadModel"); + +export namespace CollabThreadModel { + export type Interface = CmsModel; +} + +export interface ICollabThreadMapper { + fromCmsEntry(entry: CmsEntry): ICollabThread; +} + +export const CollabThreadMapper = createAbstraction("CollabThreadMapper"); + +export namespace CollabThreadMapper { + export type Interface = ICollabThreadMapper; +} diff --git a/packages/api-collaboration/src/domain/thread/errors.ts b/packages/api-collaboration/src/domain/thread/errors.ts new file mode 100644 index 00000000000..48540e9b7d0 --- /dev/null +++ b/packages/api-collaboration/src/domain/thread/errors.ts @@ -0,0 +1,68 @@ +import { BaseError } from "@webiny/feature/api"; + +export class CollabThreadNotFoundError extends BaseError<{ id: string }> { + override readonly code = "Collaboration/Thread/NotFound" as const; + + constructor(data: { id: string }) { + super({ + message: `Collaboration thread with id "${data.id}" was not found!`, + data + }); + } +} + +export class CollabMessageNotFoundError extends BaseError<{ threadId: string; messageId: string }> { + override readonly code = "Collaboration/Message/NotFound" as const; + + constructor(data: { threadId: string; messageId: string }) { + super({ + message: `Message "${data.messageId}" was not found in thread "${data.threadId}"!`, + data + }); + } +} + +export class CollabThreadNotAuthorizedError extends BaseError { + override readonly code = "Collaboration/Thread/NotAuthorized" as const; + + constructor(message?: string) { + super({ + message: message || "Not authorized to access this collaboration thread." + }); + } +} + +export class CollabThreadPersistenceError extends BaseError { + override readonly code = "Collaboration/Thread/Persistence" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class CollabThreadValidationError extends BaseError { + override readonly code = "Collaboration/Thread/Validation" as const; + + constructor(message: string) { + super({ + message + }); + } +} + +/** + * The anchor (content + locator) could not be resolved — either no resolver is registered + * for the content type, or the resolver reported the anchor does not exist. + */ +export class CollabAnchorNotFoundError extends BaseError<{ contentType: string; locator: string }> { + override readonly code = "Collaboration/Anchor/NotFound" as const; + + constructor(data: { contentType: string; locator: string }) { + super({ + message: `Could not resolve anchor "${data.locator}" for content type "${data.contentType}".`, + data + }); + } +} diff --git a/packages/api-collaboration/src/domain/thread/threadModel.ts b/packages/api-collaboration/src/domain/thread/threadModel.ts new file mode 100644 index 00000000000..d8a1006fa4d --- /dev/null +++ b/packages/api-collaboration/src/domain/thread/threadModel.ts @@ -0,0 +1,110 @@ +import { ModelFactory } from "@webiny/api-headless-cms/features/modelBuilder/index.js"; +import { CollabThreadType } from "~/domain/thread/abstractions.js"; +import { COLLAB_THREAD_MODEL_ID } from "~/constants.js"; + +const types = [ + { + label: "Note", + value: CollabThreadType.note + }, + { + label: "Task", + value: CollabThreadType.task + } +]; + +class CollabThreadModelImpl implements ModelFactory.Interface { + public async execute(builder: ModelFactory.Builder) { + return [ + builder + .private({ + modelId: COLLAB_THREAD_MODEL_ID, + name: "Collaboration Thread" + }) + .fields(fields => ({ + contentType: fields + .text() + .label("Content Type") + .required("Content type is required."), + contentId: fields + .text() + .label("Content ID") + .required("Content ID is required."), + // Empty locator = an entry-level (unanchored) comment. + locator: fields.text().label("Locator"), + type: fields + .text() + .label("Type") + .required("Type is required.") + .predefinedValues(types), + resolved: fields.boolean().label("Resolved"), + resolvedBy: fields + .object() + .label("Resolved By") + .fields(identityFields => ({ + id: identityFields.text().label("ID"), + displayName: identityFields.text().label("Display Name"), + type: identityFields.text().label("Type") + })), + resolvedOn: fields.text().label("Resolved On"), + assigneeId: fields.text().label("Assignee ID"), + dueDate: fields.text().label("Due Date"), + deleted: fields.boolean().label("Deleted"), + deletedBy: fields + .object() + .label("Deleted By") + .fields(identityFields => ({ + id: identityFields.text().label("ID"), + displayName: identityFields.text().label("Display Name"), + type: identityFields.text().label("Type") + })), + deletedOn: fields.text().label("Deleted On"), + messages: fields + .object() + .label("Messages") + .list() + .fields(messageFields => ({ + id: messageFields.text().label("ID").required("ID is required."), + body: messageFields.text().label("Body").required("Body is required."), + mentions: messageFields.text().label("Mentions").list(), + createdBy: messageFields + .object() + .label("Created By") + .fields(identityFields => ({ + id: identityFields + .text() + .label("ID") + .required("ID is required."), + displayName: identityFields + .text() + .label("Display Name") + .required("Display name is required."), + type: identityFields + .text() + .label("Type") + .required("Type is required.") + })), + createdOn: messageFields + .text() + .label("Created On") + .required("Created on is required."), + deleted: messageFields.boolean().label("Deleted"), + deletedBy: messageFields + .object() + .label("Deleted By") + .fields(identityFields => ({ + id: identityFields.text().label("ID"), + displayName: identityFields.text().label("Display Name"), + type: identityFields.text().label("Type") + })), + deletedOn: messageFields.text().label("Deleted On") + })) + })) + ]; + } +} + +export const CollabThreadModel = ModelFactory.createImplementation({ + implementation: CollabThreadModelImpl, + dependencies: [] +}); diff --git a/packages/api-collaboration/src/features/cms/CmsLocatorResolver/CmsLocatorResolver.ts b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/CmsLocatorResolver.ts new file mode 100644 index 00000000000..7321ea2cc94 --- /dev/null +++ b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/CmsLocatorResolver.ts @@ -0,0 +1,61 @@ +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetLatestRevisionByEntryId/index.js"; +import { CollabLocatorResolver } from "~/domain/locator/abstractions.js"; +import { CONTENT_TYPE_CMS_ENTRY } from "~/constants.js"; +import { parseCmsContentId } from "~/utils/cmsContentId.js"; +import { walkModelLocator } from "./modelLocator.js"; + +class CmsLocatorResolverImpl implements CollabLocatorResolver.Interface { + public readonly contentType = CONTENT_TYPE_CMS_ENTRY; + + constructor( + private getModel: GetModelUseCase.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface + ) {} + + async resolve(params: CollabLocatorResolver.Params): Promise { + const { modelId, entryId } = parseCmsContentId(params.contentId); + if (!modelId || !entryId) { + return { exists: false, authorized: false }; + } + + const modelResult = await this.getModel.execute(modelId); + if (modelResult.isFail()) { + // Model missing or not readable — treat as no access. + return { exists: false, authorized: false }; + } + + const model = modelResult.value; + + // Loading the current revision is the read-access gate: it fails with + // "Cms/Entry/NotAuthorized" when the caller may not read the entry. + const entryResult = await this.getLatestRevision.execute(model, { id: entryId }); + if (entryResult.isFail()) { + if (entryResult.error.code === "Cms/Entry/NotAuthorized") { + return { exists: false, authorized: false }; + } + // Entry itself is gone (deleted / never existed) — authorized but orphaned. + return { exists: false, authorized: true }; + } + + // Empty locator = an entry-level (unanchored) comment. The entry read above is the + // access gate; the anchor is the entry itself. + if (!params.locator || params.locator.trim().length === 0) { + return { exists: true, authorized: true, label: "Entry", path: [] }; + } + + const walk = walkModelLocator(model, params.locator); + + return { + exists: walk.exists, + authorized: true, + label: walk.label, + path: walk.path + }; + } +} + +export const CmsLocatorResolver = CollabLocatorResolver.createImplementation({ + implementation: CmsLocatorResolverImpl, + dependencies: [GetModelUseCase, GetLatestRevisionByEntryIdUseCase] +}); diff --git a/packages/api-collaboration/src/features/cms/CmsLocatorResolver/feature.ts b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/feature.ts new file mode 100644 index 00000000000..30e9af193ab --- /dev/null +++ b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { CmsLocatorResolver } from "./CmsLocatorResolver.js"; + +export const CmsLocatorResolverFeature = createFeature({ + name: "Collaboration/CmsLocatorResolver", + register(container) { + container.register(CmsLocatorResolver).inSingletonScope(); + } +}); diff --git a/packages/api-collaboration/src/features/cms/CmsLocatorResolver/modelLocator.ts b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/modelLocator.ts new file mode 100644 index 00000000000..01a2be64503 --- /dev/null +++ b/packages/api-collaboration/src/features/cms/CmsLocatorResolver/modelLocator.ts @@ -0,0 +1,81 @@ +import type { CmsModel, CmsModelField } from "@webiny/api-headless-cms/types"; + +export interface ModelLocatorResult { + exists: boolean; + label?: string; + /** + * Ancestor field labels for a breadcrumb display (e.g. ["Address"] for an `address.street` + * locator). Reflects object / dynamic-zone nesting; excludes the target field itself. + */ + path?: string[]; +} + +const isListIndex = (segment: string): boolean => { + return /^\d+$/.test(segment); +}; + +/** + * Object fields expose children via `settings.fields`; dynamic-zone fields via + * `settings.templates[].fields`. We flatten both so a `fieldId` segment can be matched + * regardless of which template it belongs to. + */ +const childFieldsOf = (field: CmsModelField): CmsModelField[] => { + const children: CmsModelField[] = []; + const settings = field.settings; + if (settings?.fields) { + children.push(...settings.fields); + } + if (settings?.templates) { + for (const template of settings.templates) { + if (template.fields) { + children.push(...template.fields); + } + } + } + return children; +}; + +const findByFieldId = (fields: CmsModelField[], fieldId: string): CmsModelField | undefined => { + return fields.find(field => field.fieldId === fieldId); +}; + +/** + * Walks a `fieldId`-dotted locator (e.g. `author.address.2.street`) against a model definition. + * Numeric segments are list indices and are skipped for structural matching. Returns whether the + * field still exists in the model, its label, and the ancestor breadcrumb. + */ +export const walkModelLocator = (model: CmsModel, locator: string): ModelLocatorResult => { + const segments = locator + .split(".") + .filter(segment => segment.length > 0 && !isListIndex(segment)); + + if (segments.length === 0) { + return { exists: false }; + } + + let currentFields: CmsModelField[] = model.fields; + let field: CmsModelField | undefined; + const labels: string[] = []; + + for (const segment of segments) { + field = findByFieldId(currentFields, segment); + if (!field) { + return { exists: false }; + } + labels.push(field.label || field.fieldId); + currentFields = childFieldsOf(field); + } + + if (!field) { + return { exists: false }; + } + + const label = field.label || field.fieldId; + const ancestors = labels.slice(0, -1); + + return { + exists: true, + label, + path: ancestors.length > 0 ? ancestors : undefined + }; +}; diff --git a/packages/api-collaboration/src/features/locator/ResolveLocator/ResolveLocatorUseCase.ts b/packages/api-collaboration/src/features/locator/ResolveLocator/ResolveLocatorUseCase.ts new file mode 100644 index 00000000000..c29a78627e2 --- /dev/null +++ b/packages/api-collaboration/src/features/locator/ResolveLocator/ResolveLocatorUseCase.ts @@ -0,0 +1,27 @@ +import { Result } from "@webiny/feature/api"; +import { CollabLocatorResolver } from "~/domain/locator/abstractions.js"; +import { CollabResolverNotFoundError } from "~/domain/locator/errors.js"; +import { ResolveLocatorUseCase as UseCase } from "./abstractions.js"; + +class ResolveLocatorUseCaseImpl implements UseCase.Interface { + private readonly resolvers; + + constructor(resolvers: CollabLocatorResolver.Interface[]) { + this.resolvers = resolvers; + } + + async execute(params: UseCase.Params): UseCase.Return { + const resolver = this.resolvers.find(item => item.contentType === params.contentType); + if (!resolver) { + return Result.fail(new CollabResolverNotFoundError(params.contentType)); + } + + const resolution = await resolver.resolve(params); + return Result.ok(resolution); + } +} + +export const ResolveLocatorUseCase = UseCase.createImplementation({ + implementation: ResolveLocatorUseCaseImpl, + dependencies: [[CollabLocatorResolver, { multiple: true }]] +}); diff --git a/packages/api-collaboration/src/features/locator/ResolveLocator/abstractions.ts b/packages/api-collaboration/src/features/locator/ResolveLocator/abstractions.ts new file mode 100644 index 00000000000..ccf50c6b3fd --- /dev/null +++ b/packages/api-collaboration/src/features/locator/ResolveLocator/abstractions.ts @@ -0,0 +1,30 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { + ICollabLocatorResolution, + ICollabLocatorResolveParams +} from "~/domain/locator/abstractions.js"; +import type { CollabResolverNotFoundError } from "~/domain/locator/errors.js"; + +export interface IResolveLocatorUseCaseErrors { + resolverNotFound: CollabResolverNotFoundError; +} + +type UseCaseError = IResolveLocatorUseCaseErrors[keyof IResolveLocatorUseCaseErrors]; + +export interface IResolveLocatorUseCase { + execute( + params: ICollabLocatorResolveParams + ): Promise>; +} + +export const ResolveLocatorUseCase = + createAbstraction("ResolveLocatorUseCase"); + +export namespace ResolveLocatorUseCase { + export type Interface = IResolveLocatorUseCase; + export type Params = ICollabLocatorResolveParams; + export type Resolution = ICollabLocatorResolution; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/locator/ResolveLocator/feature.ts b/packages/api-collaboration/src/features/locator/ResolveLocator/feature.ts new file mode 100644 index 00000000000..6a69c3c52cd --- /dev/null +++ b/packages/api-collaboration/src/features/locator/ResolveLocator/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { ResolveLocatorUseCase } from "./ResolveLocatorUseCase.js"; + +export const ResolveLocatorFeature = createFeature({ + name: "Collaboration/ResolveLocator", + register(container) { + container.register(ResolveLocatorUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/locator/ResolveLocator/index.ts b/packages/api-collaboration/src/features/locator/ResolveLocator/index.ts new file mode 100644 index 00000000000..e85d2cd9e0d --- /dev/null +++ b/packages/api-collaboration/src/features/locator/ResolveLocator/index.ts @@ -0,0 +1 @@ +export { ResolveLocatorUseCase } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadRepository.ts b/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadRepository.ts new file mode 100644 index 00000000000..312ac0041eb --- /dev/null +++ b/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadRepository.ts @@ -0,0 +1,39 @@ +import { Result } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import { + CollabThreadMapper, + CollabThreadModel, + type ICollabThreadValues +} from "~/domain/thread/abstractions.js"; +import { CollabThreadPersistenceError } from "~/domain/thread/errors.js"; +import { CreateThreadRepository as Repository } from "./abstractions.js"; + +class CreateThreadRepositoryImpl implements Repository.Interface { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private model: CollabThreadModel.Interface, + private mapper: CollabThreadMapper.Interface + ) {} + + async execute(params: Repository.Params): Repository.Return { + try { + const createResult = await this.createEntry.execute(this.model, { + id: params.id, + values: params.values + }); + + if (createResult.isFail()) { + return Result.fail(new CollabThreadPersistenceError(createResult.error)); + } + + return Result.ok(this.mapper.fromCmsEntry(createResult.value)); + } catch (error) { + return Result.fail(new CollabThreadPersistenceError(error as Error)); + } + } +} + +export const CreateThreadRepository = Repository.createImplementation({ + implementation: CreateThreadRepositoryImpl, + dependencies: [CreateEntryUseCase, CollabThreadModel, CollabThreadMapper] +}); diff --git a/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadUseCase.ts b/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadUseCase.ts new file mode 100644 index 00000000000..ea07ce97253 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/CreateThread/CreateThreadUseCase.ts @@ -0,0 +1,95 @@ +import { Result } from "@webiny/feature/api"; +import { mdbid } from "@webiny/utils"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { CreateThreadRepository, CreateThreadUseCase as UseCase } from "./abstractions.js"; +import { ResolveLocatorUseCase } from "~/features/locator/ResolveLocator/index.js"; +import { type ICollabMessage, type ICollabThreadValues } from "~/domain/thread/abstractions.js"; +import { + CollabAnchorNotFoundError, + CollabThreadNotAuthorizedError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; +import { toCollabIdentity } from "~/utils/identity.js"; + +class CreateThreadUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private resolveLocator: ResolveLocatorUseCase.Interface, + private repository: CreateThreadRepository.Interface + ) {} + + async execute(input: UseCase.Input): UseCase.Return { + const identity = this.identityContext.getIdentity(); + if (identity.isAnonymous()) { + return Result.fail( + new CollabThreadNotAuthorizedError("You must be signed in to comment.") + ); + } + + if (!input.body || input.body.trim().length === 0) { + return Result.fail(new CollabThreadValidationError("Message body cannot be empty.")); + } + + const resolution = await this.resolveLocator.execute({ + contentType: input.contentType, + contentId: input.contentId, + locator: input.locator + }); + + if (resolution.isFail() || !resolution.value.authorized) { + return Result.fail( + new CollabThreadNotAuthorizedError( + "You do not have access to comment on this content." + ) + ); + } + + if (!resolution.value.exists) { + return Result.fail( + new CollabAnchorNotFoundError({ + contentType: input.contentType, + locator: input.locator + }) + ); + } + + const now = new Date().toISOString(); + const author = toCollabIdentity(identity); + + const message: ICollabMessage = { + id: mdbid(), + body: input.body, + mentions: input.mentions ?? [], + createdBy: author, + createdOn: now + }; + + const values: ICollabThreadValues = { + contentType: input.contentType, + contentId: input.contentId, + locator: input.locator, + type: input.type, + resolved: false, + resolvedBy: null, + resolvedOn: null, + assigneeId: input.assigneeId ?? null, + dueDate: input.dueDate ?? null, + messages: [message], + deleted: false, + deletedBy: null, + deletedOn: null + }; + + const createResult = await this.repository.execute({ id: mdbid(), values }); + if (createResult.isFail()) { + return Result.fail(createResult.error); + } + + return Result.ok({ thread: createResult.value, anchor: resolution.value }); + } +} + +export const CreateThreadUseCase = UseCase.createImplementation({ + implementation: CreateThreadUseCaseImpl, + dependencies: [IdentityContext, ResolveLocatorUseCase, CreateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/CreateThread/abstractions.ts b/packages/api-collaboration/src/features/thread/CreateThread/abstractions.ts new file mode 100644 index 00000000000..a0b79809c86 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/CreateThread/abstractions.ts @@ -0,0 +1,80 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import { + type CollabThreadType, + type ICollabThread, + type ICollabThreadValues +} from "~/domain/thread/abstractions.js"; +import type { + CollabAnchorNotFoundError, + CollabThreadNotAuthorizedError, + CollabThreadPersistenceError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; + +export interface ICreateThreadInput { + contentType: string; + contentId: string; + locator: string; + type: CollabThreadType; + // first message + body: string; + mentions?: string[]; + // task variant only + assigneeId?: string | null; + dueDate?: string | null; +} + +/** + * CreateThread use case interface. + */ +export interface ICreateThreadUseCase { + execute(input: ICreateThreadInput): Promise>; +} + +export interface ICreateThreadUseCaseErrors { + notAuthorized: CollabThreadNotAuthorizedError; + anchorNotFound: CollabAnchorNotFoundError; + validation: CollabThreadValidationError; + persistence: CollabThreadPersistenceError; +} + +type UseCaseError = ICreateThreadUseCaseErrors[keyof ICreateThreadUseCaseErrors]; + +export const CreateThreadUseCase = createAbstraction("CreateThreadUseCase"); + +export namespace CreateThreadUseCase { + export type Interface = ICreateThreadUseCase; + export type Input = ICreateThreadInput; + export type Return = Promise>; + export type Error = UseCaseError; +} + +/** + * CreateThread repository interface. + */ +export interface ICreateThreadRepositoryParams { + id: string; + values: ICollabThreadValues; +} + +export interface ICreateThreadRepository { + execute(params: ICreateThreadRepositoryParams): Promise>; +} + +export interface ICreateThreadRepositoryErrors { + persistence: CollabThreadPersistenceError; +} + +type RepositoryError = ICreateThreadRepositoryErrors[keyof ICreateThreadRepositoryErrors]; + +export const CreateThreadRepository = + createAbstraction("CreateThreadRepository"); + +export namespace CreateThreadRepository { + export type Interface = ICreateThreadRepository; + export type Params = ICreateThreadRepositoryParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-collaboration/src/features/thread/CreateThread/feature.ts b/packages/api-collaboration/src/features/thread/CreateThread/feature.ts new file mode 100644 index 00000000000..58993b5a654 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/CreateThread/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateThreadUseCase } from "./CreateThreadUseCase.js"; +import { CreateThreadRepository } from "./CreateThreadRepository.js"; + +export const CreateThreadFeature = createFeature({ + name: "Collaboration/CreateThread", + register(container) { + container.register(CreateThreadRepository).inSingletonScope(); + container.register(CreateThreadUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/CreateThread/index.ts b/packages/api-collaboration/src/features/thread/CreateThread/index.ts new file mode 100644 index 00000000000..61d0fc19043 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/CreateThread/index.ts @@ -0,0 +1 @@ +export { CreateThreadUseCase, CreateThreadRepository } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/DeleteThread/DeleteThreadUseCase.ts b/packages/api-collaboration/src/features/thread/DeleteThread/DeleteThreadUseCase.ts new file mode 100644 index 00000000000..c4c5a9112d0 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/DeleteThread/DeleteThreadUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { DeleteThreadUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; +import { CollabThreadNotAuthorizedError } from "~/domain/thread/errors.js"; +import { toCollabIdentity } from "~/utils/identity.js"; + +class DeleteThreadUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(id: string): UseCase.Return { + const loaded = await this.getThread.execute(id); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread } = loaded.value; + + const identity = this.identityContext.getIdentity(); + if (!identity.isAdmin() && identity.id !== thread.createdBy.id) { + return Result.fail( + new CollabThreadNotAuthorizedError("You can only delete your own threads.") + ); + } + + thread.deleted = true; + thread.deletedBy = toCollabIdentity(identity); + thread.deletedOn = new Date().toISOString(); + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok(true); + } +} + +export const DeleteThreadUseCase = UseCase.createImplementation({ + implementation: DeleteThreadUseCaseImpl, + dependencies: [IdentityContext, GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/DeleteThread/abstractions.ts b/packages/api-collaboration/src/features/thread/DeleteThread/abstractions.ts new file mode 100644 index 00000000000..8f7555e82ae --- /dev/null +++ b/packages/api-collaboration/src/features/thread/DeleteThread/abstractions.ts @@ -0,0 +1,27 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError, + CollabThreadPersistenceError +} from "~/domain/thread/errors.js"; + +export interface IDeleteThreadUseCase { + execute(id: string): Promise>; +} + +export interface IDeleteThreadUseCaseErrors { + notFound: CollabThreadNotFoundError; + notAuthorized: CollabThreadNotAuthorizedError; + persistence: CollabThreadPersistenceError; +} + +type UseCaseError = IDeleteThreadUseCaseErrors[keyof IDeleteThreadUseCaseErrors]; + +export const DeleteThreadUseCase = createAbstraction("DeleteThreadUseCase"); + +export namespace DeleteThreadUseCase { + export type Interface = IDeleteThreadUseCase; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/thread/DeleteThread/feature.ts b/packages/api-collaboration/src/features/thread/DeleteThread/feature.ts new file mode 100644 index 00000000000..32839f014a9 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/DeleteThread/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteThreadUseCase } from "./DeleteThreadUseCase.js"; + +export const DeleteThreadFeature = createFeature({ + name: "Collaboration/DeleteThread", + register(container) { + container.register(DeleteThreadUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/DeleteThread/index.ts b/packages/api-collaboration/src/features/thread/DeleteThread/index.ts new file mode 100644 index 00000000000..823b62efb2e --- /dev/null +++ b/packages/api-collaboration/src/features/thread/DeleteThread/index.ts @@ -0,0 +1 @@ +export { DeleteThreadUseCase } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/GetThread/GetThreadRepository.ts b/packages/api-collaboration/src/features/thread/GetThread/GetThreadRepository.ts new file mode 100644 index 00000000000..30bdf383274 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/GetThread/GetThreadRepository.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { createIdentifier } from "@webiny/utils"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { + CollabThreadMapper, + CollabThreadModel, + type ICollabThreadValues +} from "~/domain/thread/abstractions.js"; +import { CollabThreadNotFoundError, CollabThreadPersistenceError } from "~/domain/thread/errors.js"; +import { GetThreadRepository as Repository } from "./abstractions.js"; + +class GetThreadRepositoryImpl implements Repository.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private model: CollabThreadModel.Interface, + private mapper: CollabThreadMapper.Interface + ) {} + + async execute(id: string): Repository.Return { + const revisionId = createIdentifier({ id, version: 1 }); + + const entryResult = await this.getEntryById.execute( + this.model, + revisionId + ); + + if (entryResult.isFail()) { + if (entryResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new CollabThreadNotFoundError({ id })); + } + return Result.fail(new CollabThreadPersistenceError(entryResult.error)); + } + + return Result.ok(this.mapper.fromCmsEntry(entryResult.value)); + } +} + +export const GetThreadRepository = Repository.createImplementation({ + implementation: GetThreadRepositoryImpl, + dependencies: [GetEntryByIdUseCase, CollabThreadModel, CollabThreadMapper] +}); diff --git a/packages/api-collaboration/src/features/thread/GetThread/GetThreadUseCase.ts b/packages/api-collaboration/src/features/thread/GetThread/GetThreadUseCase.ts new file mode 100644 index 00000000000..f885e4bdcf5 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/GetThread/GetThreadUseCase.ts @@ -0,0 +1,49 @@ +import { Result } from "@webiny/feature/api"; +import { GetThreadRepository, GetThreadUseCase as UseCase } from "./abstractions.js"; +import { ResolveLocatorUseCase } from "~/features/locator/ResolveLocator/index.js"; +import { + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError +} from "~/domain/thread/errors.js"; + +class GetThreadUseCaseImpl implements UseCase.Interface { + constructor( + private repository: GetThreadRepository.Interface, + private resolveLocator: ResolveLocatorUseCase.Interface + ) {} + + async execute(id: string): UseCase.Return { + const threadResult = await this.repository.execute(id); + if (threadResult.isFail()) { + return Result.fail(threadResult.error); + } + + const thread = threadResult.value; + if (thread.deleted) { + return Result.fail(new CollabThreadNotFoundError({ id })); + } + + const resolution = await this.resolveLocator.execute({ + contentType: thread.contentType, + contentId: thread.contentId, + locator: thread.locator + }); + + // Access to a thread equals read access to its target content. Without a resolver we + // cannot verify access, so we deny. + if (resolution.isFail() || !resolution.value.authorized) { + return Result.fail( + new CollabThreadNotAuthorizedError( + "You do not have access to this collaboration thread." + ) + ); + } + + return Result.ok({ thread, anchor: resolution.value }); + } +} + +export const GetThreadUseCase = UseCase.createImplementation({ + implementation: GetThreadUseCaseImpl, + dependencies: [GetThreadRepository, ResolveLocatorUseCase] +}); diff --git a/packages/api-collaboration/src/features/thread/GetThread/abstractions.ts b/packages/api-collaboration/src/features/thread/GetThread/abstractions.ts new file mode 100644 index 00000000000..841c99ab470 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/GetThread/abstractions.ts @@ -0,0 +1,54 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { ICollabThread } from "~/domain/thread/abstractions.js"; +import type { + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError, + CollabThreadPersistenceError +} from "~/domain/thread/errors.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; + +/** + * GetThread repository — pure load by id. + */ +export interface IGetThreadRepository { + execute(id: string): Promise>; +} + +export interface IGetThreadRepositoryErrors { + notFound: CollabThreadNotFoundError; + persistence: CollabThreadPersistenceError; +} + +type RepositoryError = IGetThreadRepositoryErrors[keyof IGetThreadRepositoryErrors]; + +export const GetThreadRepository = createAbstraction("GetThreadRepository"); + +export namespace GetThreadRepository { + export type Interface = IGetThreadRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} + +/** + * GetThread use case — loads a thread and ensures the caller may read the target content. + */ +export interface IGetThreadUseCase { + execute(id: string): Promise>; +} + +export interface IGetThreadUseCaseErrors { + notFound: CollabThreadNotFoundError; + persistence: CollabThreadPersistenceError; + notAuthorized: CollabThreadNotAuthorizedError; +} + +type UseCaseError = IGetThreadUseCaseErrors[keyof IGetThreadUseCaseErrors]; + +export const GetThreadUseCase = createAbstraction("GetThreadUseCase"); + +export namespace GetThreadUseCase { + export type Interface = IGetThreadUseCase; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/thread/GetThread/feature.ts b/packages/api-collaboration/src/features/thread/GetThread/feature.ts new file mode 100644 index 00000000000..a9096fa9bc2 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/GetThread/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetThreadRepository } from "./GetThreadRepository.js"; +import { GetThreadUseCase } from "./GetThreadUseCase.js"; + +export const GetThreadFeature = createFeature({ + name: "Collaboration/GetThread", + register(container) { + container.register(GetThreadRepository).inSingletonScope(); + container.register(GetThreadUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/GetThread/index.ts b/packages/api-collaboration/src/features/thread/GetThread/index.ts new file mode 100644 index 00000000000..1501b6a130a --- /dev/null +++ b/packages/api-collaboration/src/features/thread/GetThread/index.ts @@ -0,0 +1 @@ +export { GetThreadUseCase, GetThreadRepository } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsRepository.ts b/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsRepository.ts new file mode 100644 index 00000000000..2c4093a74b9 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsRepository.ts @@ -0,0 +1,51 @@ +import { Result } from "@webiny/feature/api"; +import { CmsWhereMapper } from "@webiny/api-headless-cms"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/index.js"; +import { + CollabThreadMapper, + CollabThreadModel, + type ICollabThreadValues +} from "~/domain/thread/abstractions.js"; +import { CollabThreadPersistenceError } from "~/domain/thread/errors.js"; +import { ListThreadsRepository as Repository } from "./abstractions.js"; + +class ListThreadsRepositoryImpl implements Repository.Interface { + constructor( + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private model: CollabThreadModel.Interface, + private mapper: CollabThreadMapper.Interface, + private cmsWhereMapper: CmsWhereMapper.Interface + ) {} + + async execute(params: Repository.Params): Repository.Return { + const where = this.cmsWhereMapper.map({ + input: { ...params.where }, + fields: this.model.fields + }); + + const sort = (params.sort ?? ["createdOn_DESC"]) as (`${string}_ASC` | `${string}_DESC`)[]; + + const listResult = await this.listLatestEntries.execute(this.model, { + sort, + limit: params.limit ?? 100, + after: params.after ?? undefined, + where + }); + + if (listResult.isFail()) { + return Result.fail(new CollabThreadPersistenceError(listResult.error)); + } + + const { entries, meta } = listResult.value; + + return Result.ok({ + items: entries.map(entry => this.mapper.fromCmsEntry(entry)), + meta + }); + } +} + +export const ListThreadsRepository = Repository.createImplementation({ + implementation: ListThreadsRepositoryImpl, + dependencies: [ListLatestEntriesUseCase, CollabThreadModel, CollabThreadMapper, CmsWhereMapper] +}); diff --git a/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsUseCase.ts b/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsUseCase.ts new file mode 100644 index 00000000000..f5e5e7937ce --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ListThreads/ListThreadsUseCase.ts @@ -0,0 +1,48 @@ +import { Result } from "@webiny/feature/api"; +import { ListThreadsRepository, ListThreadsUseCase as UseCase } from "./abstractions.js"; +import { ResolveLocatorUseCase } from "~/features/locator/ResolveLocator/index.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; + +class ListThreadsUseCaseImpl implements UseCase.Interface { + constructor( + private repository: ListThreadsRepository.Interface, + private resolveLocator: ResolveLocatorUseCase.Interface + ) {} + + async execute(params: UseCase.Params): UseCase.Return { + const listResult = await this.repository.execute(params); + if (listResult.isFail()) { + return Result.fail(listResult.error); + } + + const { items, meta } = listResult.value; + const views: ICollabThreadView[] = []; + + for (const thread of items) { + // Soft-deleted threads never surface in the list. + if (thread.deleted) { + continue; + } + + const resolution = await this.resolveLocator.execute({ + contentType: thread.contentType, + contentId: thread.contentId, + locator: thread.locator + }); + + // Without read access to the target we omit the thread entirely. + if (resolution.isFail() || !resolution.value.authorized) { + continue; + } + + views.push({ thread, anchor: resolution.value }); + } + + return Result.ok({ items: views, meta }); + } +} + +export const ListThreadsUseCase = UseCase.createImplementation({ + implementation: ListThreadsUseCaseImpl, + dependencies: [ListThreadsRepository, ResolveLocatorUseCase] +}); diff --git a/packages/api-collaboration/src/features/thread/ListThreads/abstractions.ts b/packages/api-collaboration/src/features/thread/ListThreads/abstractions.ts new file mode 100644 index 00000000000..b010a10cc4c --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ListThreads/abstractions.ts @@ -0,0 +1,69 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { CollabThreadType, ICollabThread } from "~/domain/thread/abstractions.js"; +import type { CollabThreadPersistenceError } from "~/domain/thread/errors.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; +import type { IMeta } from "~/types.js"; + +export interface IListThreadsWhere { + contentType: string; + contentId: string; + type?: CollabThreadType; + resolved?: boolean; +} + +export interface IListThreadsParams { + where: IListThreadsWhere; + limit?: number; + after?: string | null; + sort?: string[]; +} + +/** + * ListThreads repository — lists raw threads for a content target. + */ +export interface IListThreadsRepository { + execute(params: IListThreadsParams): Promise>; +} + +export interface IListThreadsResult { + items: ICollabThread[]; + meta: IMeta; +} + +export interface IListThreadsRepositoryErrors { + persistence: CollabThreadPersistenceError; +} + +type RepositoryError = IListThreadsRepositoryErrors[keyof IListThreadsRepositoryErrors]; + +export const ListThreadsRepository = + createAbstraction("ListThreadsRepository"); + +export namespace ListThreadsRepository { + export type Interface = IListThreadsRepository; + export type Params = IListThreadsParams; + export type Return = Promise>; + export type Error = RepositoryError; +} + +/** + * ListThreads use case — returns each thread paired with its resolved anchor. + */ +export interface IListThreadsViewResult { + items: ICollabThreadView[]; + meta: IMeta; +} + +export interface IListThreadsUseCase { + execute(params: IListThreadsParams): Promise>; +} + +export const ListThreadsUseCase = createAbstraction("ListThreadsUseCase"); + +export namespace ListThreadsUseCase { + export type Interface = IListThreadsUseCase; + export type Params = IListThreadsParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-collaboration/src/features/thread/ListThreads/feature.ts b/packages/api-collaboration/src/features/thread/ListThreads/feature.ts new file mode 100644 index 00000000000..cd310d8b27d --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ListThreads/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListThreadsUseCase } from "./ListThreadsUseCase.js"; +import { ListThreadsRepository } from "./ListThreadsRepository.js"; + +export const ListThreadsFeature = createFeature({ + name: "Collaboration/ListThreads", + register(container) { + container.register(ListThreadsRepository).inSingletonScope(); + container.register(ListThreadsUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/ListThreads/index.ts b/packages/api-collaboration/src/features/thread/ListThreads/index.ts new file mode 100644 index 00000000000..a6db621d0ab --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ListThreads/index.ts @@ -0,0 +1 @@ +export { ListThreadsUseCase, ListThreadsRepository } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/MessageOperations/DeleteMessageUseCase.ts b/packages/api-collaboration/src/features/thread/MessageOperations/DeleteMessageUseCase.ts new file mode 100644 index 00000000000..0c0b1bc56ee --- /dev/null +++ b/packages/api-collaboration/src/features/thread/MessageOperations/DeleteMessageUseCase.ts @@ -0,0 +1,59 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { DeleteMessageUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; +import { + CollabMessageNotFoundError, + CollabThreadNotAuthorizedError +} from "~/domain/thread/errors.js"; +import { toCollabIdentity } from "~/utils/identity.js"; + +class DeleteMessageUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(input: UseCase.Input): UseCase.Return { + const loaded = await this.getThread.execute(input.threadId); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread } = loaded.value; + const message = thread.messages.find(item => item.id === input.messageId); + if (!message || message.deleted) { + return Result.fail( + new CollabMessageNotFoundError({ + threadId: input.threadId, + messageId: input.messageId + }) + ); + } + + const identity = this.identityContext.getIdentity(); + if (!identity.isAdmin() && identity.id !== message.createdBy.id) { + return Result.fail( + new CollabThreadNotAuthorizedError("You can only delete your own messages.") + ); + } + + message.deleted = true; + message.deletedBy = toCollabIdentity(identity); + message.deletedOn = new Date().toISOString(); + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok(true); + } +} + +export const DeleteMessageUseCase = UseCase.createImplementation({ + implementation: DeleteMessageUseCaseImpl, + dependencies: [IdentityContext, GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/MessageOperations/UpdateMessageUseCase.ts b/packages/api-collaboration/src/features/thread/MessageOperations/UpdateMessageUseCase.ts new file mode 100644 index 00000000000..eb41bf22d91 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/MessageOperations/UpdateMessageUseCase.ts @@ -0,0 +1,61 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { UpdateMessageUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; +import { + CollabMessageNotFoundError, + CollabThreadNotAuthorizedError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; + +class UpdateMessageUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(input: UseCase.Input): UseCase.Return { + if (!input.body || input.body.trim().length === 0) { + return Result.fail(new CollabThreadValidationError("Message body cannot be empty.")); + } + + const loaded = await this.getThread.execute(input.threadId); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread } = loaded.value; + const message = thread.messages.find(item => item.id === input.messageId); + if (!message || message.deleted) { + return Result.fail( + new CollabMessageNotFoundError({ + threadId: input.threadId, + messageId: input.messageId + }) + ); + } + + const identity = this.identityContext.getIdentity(); + if (!identity.isAdmin() && identity.id !== message.createdBy.id) { + return Result.fail( + new CollabThreadNotAuthorizedError("You can only edit your own messages.") + ); + } + + message.body = input.body; + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok(message); + } +} + +export const UpdateMessageUseCase = UseCase.createImplementation({ + implementation: UpdateMessageUseCaseImpl, + dependencies: [IdentityContext, GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/MessageOperations/abstractions.ts b/packages/api-collaboration/src/features/thread/MessageOperations/abstractions.ts new file mode 100644 index 00000000000..5bffb23521b --- /dev/null +++ b/packages/api-collaboration/src/features/thread/MessageOperations/abstractions.ts @@ -0,0 +1,65 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { ICollabMessage } from "~/domain/thread/abstractions.js"; +import type { + CollabMessageNotFoundError, + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError, + CollabThreadPersistenceError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; + +export interface IMessageOperationErrors { + threadNotFound: CollabThreadNotFoundError; + messageNotFound: CollabMessageNotFoundError; + notAuthorized: CollabThreadNotAuthorizedError; + validation: CollabThreadValidationError; + persistence: CollabThreadPersistenceError; +} + +type UseCaseError = IMessageOperationErrors[keyof IMessageOperationErrors]; + +/** + * Edit a message body — author or admin only. + */ +export interface IUpdateMessageInput { + threadId: string; + messageId: string; + body: string; +} + +export interface IUpdateMessageUseCase { + execute(input: IUpdateMessageInput): Promise>; +} + +export const UpdateMessageUseCase = + createAbstraction("UpdateMessageUseCase"); + +export namespace UpdateMessageUseCase { + export type Interface = IUpdateMessageUseCase; + export type Input = IUpdateMessageInput; + export type Return = Promise>; + export type Error = UseCaseError; +} + +/** + * Soft-delete a message — author or admin only. + */ +export interface IDeleteMessageInput { + threadId: string; + messageId: string; +} + +export interface IDeleteMessageUseCase { + execute(input: IDeleteMessageInput): Promise>; +} + +export const DeleteMessageUseCase = + createAbstraction("DeleteMessageUseCase"); + +export namespace DeleteMessageUseCase { + export type Interface = IDeleteMessageUseCase; + export type Input = IDeleteMessageInput; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/thread/MessageOperations/feature.ts b/packages/api-collaboration/src/features/thread/MessageOperations/feature.ts new file mode 100644 index 00000000000..8e290eba0d0 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/MessageOperations/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateMessageUseCase } from "./UpdateMessageUseCase.js"; +import { DeleteMessageUseCase } from "./DeleteMessageUseCase.js"; + +export const MessageOperationsFeature = createFeature({ + name: "Collaboration/MessageOperations", + register(container) { + container.register(UpdateMessageUseCase); + container.register(DeleteMessageUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/MessageOperations/index.ts b/packages/api-collaboration/src/features/thread/MessageOperations/index.ts new file mode 100644 index 00000000000..27ab7835c27 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/MessageOperations/index.ts @@ -0,0 +1 @@ +export { UpdateMessageUseCase, DeleteMessageUseCase } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/ReplyToThread/ReplyToThreadUseCase.ts b/packages/api-collaboration/src/features/thread/ReplyToThread/ReplyToThreadUseCase.ts new file mode 100644 index 00000000000..e32a3c37a0d --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ReplyToThread/ReplyToThreadUseCase.ts @@ -0,0 +1,62 @@ +import { Result } from "@webiny/feature/api"; +import { mdbid } from "@webiny/utils"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { ReplyToThreadUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; +import type { ICollabMessage } from "~/domain/thread/abstractions.js"; +import { + CollabThreadNotAuthorizedError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; +import { toCollabIdentity } from "~/utils/identity.js"; + +class ReplyToThreadUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(input: UseCase.Input): UseCase.Return { + const identity = this.identityContext.getIdentity(); + if (identity.isAnonymous()) { + return Result.fail( + new CollabThreadNotAuthorizedError("You must be signed in to reply.") + ); + } + + if (!input.body || input.body.trim().length === 0) { + return Result.fail(new CollabThreadValidationError("Reply body cannot be empty.")); + } + + const loaded = await this.getThread.execute(input.threadId); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread } = loaded.value; + + const message: ICollabMessage = { + id: mdbid(), + body: input.body, + mentions: input.mentions ?? [], + createdBy: toCollabIdentity(identity), + createdOn: new Date().toISOString() + }; + + thread.messages = [...thread.messages, message]; + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok(message); + } +} + +export const ReplyToThreadUseCase = UseCase.createImplementation({ + implementation: ReplyToThreadUseCaseImpl, + dependencies: [IdentityContext, GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/ReplyToThread/abstractions.ts b/packages/api-collaboration/src/features/thread/ReplyToThread/abstractions.ts new file mode 100644 index 00000000000..baa5087876c --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ReplyToThread/abstractions.ts @@ -0,0 +1,38 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { ICollabMessage } from "~/domain/thread/abstractions.js"; +import type { + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError, + CollabThreadPersistenceError, + CollabThreadValidationError +} from "~/domain/thread/errors.js"; + +export interface IReplyToThreadInput { + threadId: string; + body: string; + mentions?: string[]; +} + +export interface IReplyToThreadUseCase { + execute(input: IReplyToThreadInput): Promise>; +} + +export interface IReplyToThreadUseCaseErrors { + notFound: CollabThreadNotFoundError; + notAuthorized: CollabThreadNotAuthorizedError; + validation: CollabThreadValidationError; + persistence: CollabThreadPersistenceError; +} + +type UseCaseError = IReplyToThreadUseCaseErrors[keyof IReplyToThreadUseCaseErrors]; + +export const ReplyToThreadUseCase = + createAbstraction("ReplyToThreadUseCase"); + +export namespace ReplyToThreadUseCase { + export type Interface = IReplyToThreadUseCase; + export type Input = IReplyToThreadInput; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/thread/ReplyToThread/feature.ts b/packages/api-collaboration/src/features/thread/ReplyToThread/feature.ts new file mode 100644 index 00000000000..2c1b2f218e9 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ReplyToThread/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { ReplyToThreadUseCase } from "./ReplyToThreadUseCase.js"; + +export const ReplyToThreadFeature = createFeature({ + name: "Collaboration/ReplyToThread", + register(container) { + container.register(ReplyToThreadUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/ReplyToThread/index.ts b/packages/api-collaboration/src/features/thread/ReplyToThread/index.ts new file mode 100644 index 00000000000..d776743578d --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ReplyToThread/index.ts @@ -0,0 +1 @@ +export { ReplyToThreadUseCase } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/ThreadResolution/ReopenThreadUseCase.ts b/packages/api-collaboration/src/features/thread/ThreadResolution/ReopenThreadUseCase.ts new file mode 100644 index 00000000000..a44d11db173 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ThreadResolution/ReopenThreadUseCase.ts @@ -0,0 +1,36 @@ +import { Result } from "@webiny/feature/api"; +import { ReopenThreadUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; + +class ReopenThreadUseCaseImpl implements UseCase.Interface { + constructor( + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(id: string): UseCase.Return { + const loaded = await this.getThread.execute(id); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread, anchor } = loaded.value; + + thread.resolved = false; + thread.resolvedBy = null; + thread.resolvedOn = null; + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok({ thread: updateResult.value, anchor }); + } +} + +export const ReopenThreadUseCase = UseCase.createImplementation({ + implementation: ReopenThreadUseCaseImpl, + dependencies: [GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/ThreadResolution/ResolveThreadUseCase.ts b/packages/api-collaboration/src/features/thread/ThreadResolution/ResolveThreadUseCase.ts new file mode 100644 index 00000000000..d719ac12534 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ThreadResolution/ResolveThreadUseCase.ts @@ -0,0 +1,39 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { ResolveThreadUseCase as UseCase } from "./abstractions.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { UpdateThreadRepository } from "~/features/thread/UpdateThread/index.js"; +import { toCollabIdentity } from "~/utils/identity.js"; + +class ResolveThreadUseCaseImpl implements UseCase.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private getThread: GetThreadUseCase.Interface, + private updateThread: UpdateThreadRepository.Interface + ) {} + + async execute(id: string): UseCase.Return { + const loaded = await this.getThread.execute(id); + if (loaded.isFail()) { + return Result.fail(loaded.error); + } + + const { thread, anchor } = loaded.value; + + thread.resolved = true; + thread.resolvedBy = toCollabIdentity(this.identityContext.getIdentity()); + thread.resolvedOn = new Date().toISOString(); + + const updateResult = await this.updateThread.execute(thread); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok({ thread: updateResult.value, anchor }); + } +} + +export const ResolveThreadUseCase = UseCase.createImplementation({ + implementation: ResolveThreadUseCaseImpl, + dependencies: [IdentityContext, GetThreadUseCase, UpdateThreadRepository] +}); diff --git a/packages/api-collaboration/src/features/thread/ThreadResolution/abstractions.ts b/packages/api-collaboration/src/features/thread/ThreadResolution/abstractions.ts new file mode 100644 index 00000000000..72542f4ed4e --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ThreadResolution/abstractions.ts @@ -0,0 +1,47 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { + CollabThreadNotAuthorizedError, + CollabThreadNotFoundError, + CollabThreadPersistenceError +} from "~/domain/thread/errors.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; + +export interface IThreadResolutionErrors { + notFound: CollabThreadNotFoundError; + notAuthorized: CollabThreadNotAuthorizedError; + persistence: CollabThreadPersistenceError; +} + +type UseCaseError = IThreadResolutionErrors[keyof IThreadResolutionErrors]; + +/** + * Resolve a thread — open to anyone with access. Records `resolvedBy`/`resolvedOn`. + */ +export interface IResolveThreadUseCase { + execute(id: string): Promise>; +} + +export const ResolveThreadUseCase = + createAbstraction("ResolveThreadUseCase"); + +export namespace ResolveThreadUseCase { + export type Interface = IResolveThreadUseCase; + export type Return = Promise>; + export type Error = UseCaseError; +} + +/** + * Reopen a resolved thread — open to anyone with access. Clears the resolution. + */ +export interface IReopenThreadUseCase { + execute(id: string): Promise>; +} + +export const ReopenThreadUseCase = createAbstraction("ReopenThreadUseCase"); + +export namespace ReopenThreadUseCase { + export type Interface = IReopenThreadUseCase; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-collaboration/src/features/thread/ThreadResolution/feature.ts b/packages/api-collaboration/src/features/thread/ThreadResolution/feature.ts new file mode 100644 index 00000000000..120668dcc9b --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ThreadResolution/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ResolveThreadUseCase } from "./ResolveThreadUseCase.js"; +import { ReopenThreadUseCase } from "./ReopenThreadUseCase.js"; + +export const ThreadResolutionFeature = createFeature({ + name: "Collaboration/ThreadResolution", + register(container) { + container.register(ResolveThreadUseCase); + container.register(ReopenThreadUseCase); + } +}); diff --git a/packages/api-collaboration/src/features/thread/ThreadResolution/index.ts b/packages/api-collaboration/src/features/thread/ThreadResolution/index.ts new file mode 100644 index 00000000000..69072c075d4 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/ThreadResolution/index.ts @@ -0,0 +1 @@ +export { ResolveThreadUseCase, ReopenThreadUseCase } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/UpdateThread/UpdateThreadRepository.ts b/packages/api-collaboration/src/features/thread/UpdateThread/UpdateThreadRepository.ts new file mode 100644 index 00000000000..0871d907cfe --- /dev/null +++ b/packages/api-collaboration/src/features/thread/UpdateThread/UpdateThreadRepository.ts @@ -0,0 +1,61 @@ +import { Result } from "@webiny/feature/api"; +import { createIdentifier } from "@webiny/utils"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; +import { + CollabThreadModel, + type ICollabThread, + type ICollabThreadValues +} from "~/domain/thread/abstractions.js"; +import { CollabThreadNotFoundError, CollabThreadPersistenceError } from "~/domain/thread/errors.js"; +import { UpdateThreadRepository as Repository } from "./abstractions.js"; + +class UpdateThreadRepositoryImpl implements Repository.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private model: CollabThreadModel.Interface + ) {} + + async execute(thread: ICollabThread): Repository.Return { + const revisionId = createIdentifier({ id: thread.id, version: 1 }); + + const values: ICollabThreadValues = { + contentType: thread.contentType, + contentId: thread.contentId, + locator: thread.locator, + type: thread.type, + resolved: thread.resolved, + resolvedBy: thread.resolvedBy ?? null, + resolvedOn: thread.resolvedOn ?? null, + assigneeId: thread.assigneeId ?? null, + dueDate: thread.dueDate ?? null, + messages: thread.messages, + deleted: thread.deleted ?? false, + deletedBy: thread.deletedBy ?? null, + deletedOn: thread.deletedOn ?? null + }; + + try { + const updateResult = await this.updateEntry.execute( + this.model, + revisionId, + { values } + ); + + if (updateResult.isFail()) { + if (updateResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new CollabThreadNotFoundError({ id: thread.id })); + } + return Result.fail(new CollabThreadPersistenceError(updateResult.error)); + } + + return Result.ok(thread); + } catch (error) { + return Result.fail(new CollabThreadPersistenceError(error as Error)); + } + } +} + +export const UpdateThreadRepository = Repository.createImplementation({ + implementation: UpdateThreadRepositoryImpl, + dependencies: [UpdateEntryUseCase, CollabThreadModel] +}); diff --git a/packages/api-collaboration/src/features/thread/UpdateThread/abstractions.ts b/packages/api-collaboration/src/features/thread/UpdateThread/abstractions.ts new file mode 100644 index 00000000000..12b734c9229 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/UpdateThread/abstractions.ts @@ -0,0 +1,31 @@ +import type { Result } from "@webiny/feature/api"; +import { createAbstraction } from "@webiny/feature/api"; +import type { ICollabThread } from "~/domain/thread/abstractions.js"; +import type { + CollabThreadNotFoundError, + CollabThreadPersistenceError +} from "~/domain/thread/errors.js"; + +/** + * Shared repository that persists the full thread entry (read-modify-write). Used by every + * thread mutation (reply, resolve, reopen, edit/delete message, delete thread). + */ +export interface IUpdateThreadRepository { + execute(thread: ICollabThread): Promise>; +} + +export interface IUpdateThreadRepositoryErrors { + notFound: CollabThreadNotFoundError; + persistence: CollabThreadPersistenceError; +} + +type RepositoryError = IUpdateThreadRepositoryErrors[keyof IUpdateThreadRepositoryErrors]; + +export const UpdateThreadRepository = + createAbstraction("UpdateThreadRepository"); + +export namespace UpdateThreadRepository { + export type Interface = IUpdateThreadRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-collaboration/src/features/thread/UpdateThread/feature.ts b/packages/api-collaboration/src/features/thread/UpdateThread/feature.ts new file mode 100644 index 00000000000..45d33ca1e24 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/UpdateThread/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateThreadRepository } from "./UpdateThreadRepository.js"; + +export const UpdateThreadFeature = createFeature({ + name: "Collaboration/UpdateThread", + register(container) { + container.register(UpdateThreadRepository).inSingletonScope(); + } +}); diff --git a/packages/api-collaboration/src/features/thread/UpdateThread/index.ts b/packages/api-collaboration/src/features/thread/UpdateThread/index.ts new file mode 100644 index 00000000000..e428385dbf6 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/UpdateThread/index.ts @@ -0,0 +1 @@ +export { UpdateThreadRepository } from "./abstractions.js"; diff --git a/packages/api-collaboration/src/features/thread/shared/abstractions.ts b/packages/api-collaboration/src/features/thread/shared/abstractions.ts new file mode 100644 index 00000000000..935e62ee0c9 --- /dev/null +++ b/packages/api-collaboration/src/features/thread/shared/abstractions.ts @@ -0,0 +1,11 @@ +import type { ICollabThread } from "~/domain/thread/abstractions.js"; +import type { ICollabLocatorResolution } from "~/domain/locator/abstractions.js"; + +/** + * A thread paired with the current resolution of its anchor (existence, label, breadcrumb). + * The anchor is computed at read time and never persisted. + */ +export interface ICollabThreadView { + thread: ICollabThread; + anchor: ICollabLocatorResolution; +} diff --git a/packages/api-collaboration/src/graphql/collaboration.ts b/packages/api-collaboration/src/graphql/collaboration.ts new file mode 100644 index 00000000000..57f7cd86b55 --- /dev/null +++ b/packages/api-collaboration/src/graphql/collaboration.ts @@ -0,0 +1,371 @@ +import { GraphQLSchemaPlugin, NotFoundError, resolve, resolveList } from "@webiny/handler-graphql"; +import { createZodError } from "@webiny/utils"; +import { CollabThreadType } from "~/domain/thread/abstractions.js"; +import type { ICollabThreadView } from "~/features/thread/shared/abstractions.js"; +import { CreateThreadUseCase } from "~/features/thread/CreateThread/index.js"; +import { GetThreadUseCase } from "~/features/thread/GetThread/index.js"; +import { ListThreadsUseCase } from "~/features/thread/ListThreads/index.js"; +import { ReplyToThreadUseCase } from "~/features/thread/ReplyToThread/index.js"; +import { + ReopenThreadUseCase, + ResolveThreadUseCase +} from "~/features/thread/ThreadResolution/index.js"; +import { + DeleteMessageUseCase, + UpdateMessageUseCase +} from "~/features/thread/MessageOperations/index.js"; +import { DeleteThreadUseCase } from "~/features/thread/DeleteThread/index.js"; +import { + createCollabThreadValidation, + deleteCollabMessageValidation, + getCollabThreadValidation, + idOnlyValidation, + listCollabThreadsValidation, + replyToCollabThreadValidation, + updateCollabMessageValidation +} from "./validation.js"; + +const toGqlThread = (view: ICollabThreadView) => { + return { ...view.thread, anchor: view.anchor }; +}; + +export const createCollaborationSchema = () => { + return new GraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type CollabError { + code: String + message: String + data: JSON + stack: String + } + + enum CollabThreadType { + note + task + } + + type CollabIdentity { + id: String! + displayName: String + type: String + } + + type CollabMessage { + id: String! + body: String! + mentions: [String!]! + createdBy: CollabIdentity! + createdOn: String! + deleted: Boolean + deletedBy: CollabIdentity + deletedOn: String + } + + type CollabAnchor { + exists: Boolean! + authorized: Boolean! + label: String + path: [String!] + } + + type CollabThread { + id: ID! + contentType: String! + contentId: String! + locator: String! + type: CollabThreadType! + resolved: Boolean! + resolvedBy: CollabIdentity + resolvedOn: String + assigneeId: String + dueDate: String + messages: [CollabMessage!]! + createdBy: CollabIdentity! + createdOn: String! + anchor: CollabAnchor! + } + + type CollabThreadListMeta { + cursor: String + hasMoreItems: Boolean! + totalCount: Int! + } + + type GetCollabThreadResponse { + data: CollabThread + error: CollabError + } + + type ListCollabThreadsResponse { + data: [CollabThread!] + meta: CollabThreadListMeta + error: CollabError + } + + type CollabThreadResponse { + data: CollabThread + error: CollabError + } + + type CollabMessageResponse { + data: CollabMessage + error: CollabError + } + + type CollabBooleanResponse { + data: Boolean + error: CollabError + } + + input ListCollabThreadsWhereInput { + contentType: String! + contentId: String! + type: CollabThreadType + resolved: Boolean + } + + input CreateCollabThreadInput { + contentType: String! + contentId: String! + locator: String! + type: CollabThreadType! + body: String! + mentions: [String!] + assigneeId: String + dueDate: String + } + + type CollaborationQuery { + listCollabThreads( + where: ListCollabThreadsWhereInput! + limit: Int + after: String + ): ListCollabThreadsResponse! + getCollabThread(id: ID!): GetCollabThreadResponse! + } + + type CollaborationMutation { + createCollabThread(input: CreateCollabThreadInput!): CollabThreadResponse! + replyToCollabThread( + threadId: ID! + body: String! + mentions: [String!] + ): CollabMessageResponse! + resolveCollabThread(id: ID!): CollabThreadResponse! + reopenCollabThread(id: ID!): CollabThreadResponse! + updateCollabMessage( + threadId: ID! + messageId: ID! + body: String! + ): CollabMessageResponse! + deleteCollabMessage(threadId: ID!, messageId: ID!): CollabBooleanResponse! + deleteCollabThread(id: ID!): CollabBooleanResponse! + } + + extend type Query { + collaboration: CollaborationQuery + } + + extend type Mutation { + collaboration: CollaborationMutation + } + `, + resolvers: { + Query: { + collaboration: () => ({}) + }, + Mutation: { + collaboration: () => ({}) + }, + CollaborationQuery: { + getCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await getCollabThreadValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const getThread = context.container.resolve(GetThreadUseCase); + const threadResult = await getThread.execute(result.data.id); + + if (threadResult.isFail()) { + throw new NotFoundError(threadResult.error.message); + } + + return toGqlThread(threadResult.value); + }); + }, + listCollabThreads: async (_, args, context) => { + return resolveList(async () => { + const result = await listCollabThreadsValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const listThreads = context.container.resolve(ListThreadsUseCase); + const listResult = await listThreads.execute({ + where: { + contentType: result.data.where.contentType, + contentId: result.data.where.contentId, + type: result.data.where.type as CollabThreadType | undefined, + resolved: result.data.where.resolved + }, + limit: result.data.limit, + after: result.data.after + }); + + if (listResult.isFail()) { + throw listResult.error; + } + + return { + items: listResult.value.items.map(toGqlThread), + meta: listResult.value.meta + }; + }); + } + }, + CollaborationMutation: { + createCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await createCollabThreadValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const createThread = context.container.resolve(CreateThreadUseCase); + const createResult = await createThread.execute({ + contentType: result.data.input.contentType, + contentId: result.data.input.contentId, + locator: result.data.input.locator, + type: result.data.input.type as CollabThreadType, + body: result.data.input.body, + mentions: result.data.input.mentions, + assigneeId: result.data.input.assigneeId, + dueDate: result.data.input.dueDate + }); + + if (createResult.isFail()) { + throw createResult.error; + } + + return toGqlThread(createResult.value); + }); + }, + replyToCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await replyToCollabThreadValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const reply = context.container.resolve(ReplyToThreadUseCase); + const replyResult = await reply.execute({ + threadId: result.data.threadId, + body: result.data.body, + mentions: result.data.mentions + }); + + if (replyResult.isFail()) { + throw replyResult.error; + } + + return replyResult.value; + }); + }, + resolveCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await idOnlyValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const resolveThread = context.container.resolve(ResolveThreadUseCase); + const threadResult = await resolveThread.execute(result.data.id); + + if (threadResult.isFail()) { + throw threadResult.error; + } + + return toGqlThread(threadResult.value); + }); + }, + reopenCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await idOnlyValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const reopenThread = context.container.resolve(ReopenThreadUseCase); + const threadResult = await reopenThread.execute(result.data.id); + + if (threadResult.isFail()) { + throw threadResult.error; + } + + return toGqlThread(threadResult.value); + }); + }, + updateCollabMessage: async (_, args, context) => { + return resolve(async () => { + const result = await updateCollabMessageValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const updateMessage = context.container.resolve(UpdateMessageUseCase); + const messageResult = await updateMessage.execute({ + threadId: result.data.threadId, + messageId: result.data.messageId, + body: result.data.body + }); + + if (messageResult.isFail()) { + throw messageResult.error; + } + + return messageResult.value; + }); + }, + deleteCollabMessage: async (_, args, context) => { + return resolve(async () => { + const result = await deleteCollabMessageValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const deleteMessage = context.container.resolve(DeleteMessageUseCase); + const deleteResult = await deleteMessage.execute({ + threadId: result.data.threadId, + messageId: result.data.messageId + }); + + if (deleteResult.isFail()) { + throw deleteResult.error; + } + + return true; + }); + }, + deleteCollabThread: async (_, args, context) => { + return resolve(async () => { + const result = await idOnlyValidation.safeParseAsync(args); + if (!result.success) { + throw createZodError(result.error); + } + + const deleteThread = context.container.resolve(DeleteThreadUseCase); + const deleteResult = await deleteThread.execute(result.data.id); + + if (deleteResult.isFail()) { + throw deleteResult.error; + } + + return true; + }); + } + } + } + }); +}; diff --git a/packages/api-collaboration/src/graphql/validation.ts b/packages/api-collaboration/src/graphql/validation.ts new file mode 100644 index 00000000000..1bc4db2d29f --- /dev/null +++ b/packages/api-collaboration/src/graphql/validation.ts @@ -0,0 +1,54 @@ +import zod from "zod"; + +const threadType = zod.enum(["note", "task"]); + +export const createCollabThreadValidation = zod.object({ + input: zod.object({ + contentType: zod.string().min(1, "Content type is required."), + contentId: zod.string().min(1, "Content ID is required."), + // Empty string = entry-level (unanchored) comment. + locator: zod.string(), + type: threadType, + body: zod.string().min(1, "Message body is required."), + mentions: zod.array(zod.string()).optional(), + assigneeId: zod.string().nullish(), + dueDate: zod.string().nullish() + }) +}); + +export const listCollabThreadsValidation = zod.object({ + where: zod.object({ + contentType: zod.string().min(1, "Content type is required."), + contentId: zod.string().min(1, "Content ID is required."), + type: threadType.optional(), + resolved: zod.boolean().optional() + }), + limit: zod.number().optional(), + after: zod.string().nullish(), + sort: zod.array(zod.string()).optional() +}); + +export const getCollabThreadValidation = zod.object({ + id: zod.string().min(1, "ID is required.") +}); + +export const idOnlyValidation = zod.object({ + id: zod.string().min(1, "ID is required.") +}); + +export const replyToCollabThreadValidation = zod.object({ + threadId: zod.string().min(1, "Thread ID is required."), + body: zod.string().min(1, "Reply body is required."), + mentions: zod.array(zod.string()).optional() +}); + +export const updateCollabMessageValidation = zod.object({ + threadId: zod.string().min(1, "Thread ID is required."), + messageId: zod.string().min(1, "Message ID is required."), + body: zod.string().min(1, "Message body is required.") +}); + +export const deleteCollabMessageValidation = zod.object({ + threadId: zod.string().min(1, "Thread ID is required."), + messageId: zod.string().min(1, "Message ID is required.") +}); diff --git a/packages/api-collaboration/src/index.ts b/packages/api-collaboration/src/index.ts new file mode 100644 index 00000000000..4be47fe1059 --- /dev/null +++ b/packages/api-collaboration/src/index.ts @@ -0,0 +1,70 @@ +import { ContextPlugin, createRegisterExtensionPlugin } from "@webiny/handler"; +import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { COLLAB_THREAD_MODEL_ID } from "./constants.js"; +import { CollabThreadModel as CollabThreadPrivateModel } from "./domain/thread/threadModel.js"; +import { CollabThreadModel } from "./domain/thread/abstractions.js"; +import { CollabThreadMapper } from "./domain/thread/CollabThreadMapper.js"; +import { ResolveLocatorFeature } from "./features/locator/ResolveLocator/feature.js"; +import { CmsLocatorResolverFeature } from "./features/cms/CmsLocatorResolver/feature.js"; +import { GetThreadFeature } from "./features/thread/GetThread/feature.js"; +import { UpdateThreadFeature } from "./features/thread/UpdateThread/feature.js"; +import { CreateThreadFeature } from "./features/thread/CreateThread/feature.js"; +import { ListThreadsFeature } from "./features/thread/ListThreads/feature.js"; +import { ReplyToThreadFeature } from "./features/thread/ReplyToThread/feature.js"; +import { ThreadResolutionFeature } from "./features/thread/ThreadResolution/feature.js"; +import { MessageOperationsFeature } from "./features/thread/MessageOperations/feature.js"; +import { DeleteThreadFeature } from "./features/thread/DeleteThread/feature.js"; +import { createCollaborationSchema } from "./graphql/collaboration.js"; + +export const createCollaboration = () => { + const modelsPlugin = createRegisterExtensionPlugin(context => { + context.container.register(CollabThreadPrivateModel); + }); + + const collaborationContextPlugin = new ContextPlugin(async context => { + const tenantContext = context.container.resolve(TenantContext); + const identityContext = context.container.resolve(IdentityContext); + + // TODO(before release): collaboration ships in the APW tier — re-gate on the + // appropriate WCP capability (e.g. wcpContext.canUseWorkflows()). Ungated for now so it + // works without a workflows license during development. + if (!tenantContext.getTenant()) { + return; + } + + // Register private model, then resolve it to a CmsModel instance. + context.container.register(CollabThreadPrivateModel); + + const getModel = context.container.resolve(GetModelUseCase); + + await identityContext.withoutAuthorization(async () => { + const threadModel = await getModel.execute(COLLAB_THREAD_MODEL_ID); + context.container.registerInstance(CollabThreadModel, threadModel.value); + }); + + // Register the mapper. + context.container.register(CollabThreadMapper); + + // Register features. + ResolveLocatorFeature.register(context.container); + // Built-in CMS locator resolver (contentType "cms.entry"). + CmsLocatorResolverFeature.register(context.container); + UpdateThreadFeature.register(context.container); + GetThreadFeature.register(context.container); + CreateThreadFeature.register(context.container); + ListThreadsFeature.register(context.container); + ReplyToThreadFeature.register(context.container); + ThreadResolutionFeature.register(context.container); + MessageOperationsFeature.register(context.container); + DeleteThreadFeature.register(context.container); + + // Register the GraphQL schema. + context.plugins.register(createCollaborationSchema()); + }); + + collaborationContextPlugin.name = "collaboration.context"; + + return [collaborationContextPlugin, modelsPlugin]; +}; diff --git a/packages/api-collaboration/src/types.ts b/packages/api-collaboration/src/types.ts new file mode 100644 index 00000000000..8e17912c707 --- /dev/null +++ b/packages/api-collaboration/src/types.ts @@ -0,0 +1,5 @@ +export interface IMeta { + totalCount: number; + hasMoreItems: boolean; + cursor: string | null; +} diff --git a/packages/api-collaboration/src/utils/cmsContentId.ts b/packages/api-collaboration/src/utils/cmsContentId.ts new file mode 100644 index 00000000000..b431569dd74 --- /dev/null +++ b/packages/api-collaboration/src/utils/cmsContentId.ts @@ -0,0 +1,30 @@ +/** + * For `cms.entry` content, the collaboration core's opaque `contentId` encodes both the model + * and the (revision-independent) entry id as `":"`. The admin client always + * knows both when creating a thread, and the CMS resolver parses it here. This keeps the core + * content-agnostic (it never learns what a "model" is). + */ +export const formatCmsContentId = (modelId: string, entryId: string): string => { + return `${modelId}:${entryId}`; +}; + +export interface ParsedCmsContentId { + modelId: string | null; + entryId: string | null; +} + +export const parseCmsContentId = (contentId: string): ParsedCmsContentId => { + const separatorIndex = contentId.indexOf(":"); + if (separatorIndex === -1) { + return { modelId: null, entryId: null }; + } + + const modelId = contentId.slice(0, separatorIndex); + const entryId = contentId.slice(separatorIndex + 1); + + if (!modelId || !entryId) { + return { modelId: null, entryId: null }; + } + + return { modelId, entryId }; +}; diff --git a/packages/api-collaboration/src/utils/identity.ts b/packages/api-collaboration/src/utils/identity.ts new file mode 100644 index 00000000000..ce68ce61621 --- /dev/null +++ b/packages/api-collaboration/src/utils/identity.ts @@ -0,0 +1,16 @@ +import type { ICollabIdentity } from "~/domain/thread/abstractions.js"; + +/** + * Projects any Webiny identity (request identity or CMS identity) to the stored shape. + */ +export const toCollabIdentity = (identity: { + id: string; + displayName: string; + type: string; +}): ICollabIdentity => { + return { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }; +}; diff --git a/packages/api-collaboration/tsconfig.build.json b/packages/api-collaboration/tsconfig.build.json new file mode 100644 index 00000000000..db6398483f9 --- /dev/null +++ b/packages/api-collaboration/tsconfig.build.json @@ -0,0 +1,41 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api-core/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../testing/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/testing/*": ["../testing/src/*"], + "@webiny/testing": ["../testing/src"] + } + } +} diff --git a/packages/api-collaboration/tsconfig.json b/packages/api-collaboration/tsconfig.json new file mode 100644 index 00000000000..c9e87513e0a --- /dev/null +++ b/packages/api-collaboration/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api-core" }, + { "path": "../api-headless-cms" }, + { "path": "../feature" }, + { "path": "../handler" }, + { "path": "../handler-graphql" }, + { "path": "../utils" }, + { "path": "../plugins" }, + { "path": "../testing" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/testing/*": ["../testing/src/*"], + "@webiny/testing": ["../testing/src"] + } + } +} diff --git a/packages/api-collaboration/vitest.config.ts b/packages/api-collaboration/vitest.config.ts new file mode 100644 index 00000000000..abbb26649c9 --- /dev/null +++ b/packages/api-collaboration/vitest.config.ts @@ -0,0 +1,34 @@ +import { resolve } from "path"; +import { createTestConfig } from "../../testing"; + +/** + * Loads storage-operations presets straight from the given packages, bypassing the workspace + * scan. Used as a fallback when the scan fails (e.g. a stray `packages/*` dir without a + * package.json breaks `get-yarn-workspaces`). + */ +const loadPresetsFromPackages = async (...pkgs: string[]) => { + const all: unknown[] = []; + for (const pkg of pkgs) { + const presetsPath = resolve(process.cwd(), "packages", pkg, "__tests__/__api__/presets.js"); + const mod = await import(presetsPath); + all.push(...(mod.default ?? mod)); + } + return all; +}; + +export default async () => { + let presets; + try { + const { getPresets } = await import("@webiny/project-utils/testing/presets/index.js"); + presets = await getPresets( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-core", "storage-operations"] + ); + } catch { + // Fallback for local environments where the workspace scan can't enumerate all packages. + process.env.WEBINY_STORAGE_OPS = process.env.WEBINY_STORAGE_OPS || "ddb"; + presets = await loadPresetsFromPackages("api-core-ddb", "api-headless-cms-ddb"); + } + + return createTestConfig({ path: import.meta.dirname, presets }); +}; diff --git a/packages/api-collaboration/webiny.config.js b/packages/api-collaboration/webiny.config.js new file mode 100644 index 00000000000..4f5e3db04b9 --- /dev/null +++ b/packages/api-collaboration/webiny.config.js @@ -0,0 +1,8 @@ +import { createWatchPackage, createBuildPackage } from "@webiny/build-tools"; + +export default { + commands: { + build: createBuildPackage({ cwd: import.meta.dirname }), + watch: createWatchPackage({ cwd: import.meta.dirname }) + } +}; diff --git a/packages/app-admin/src/features/formModel/Field.ts b/packages/app-admin/src/features/formModel/Field.ts index 08484bd290e..296855c58e6 100644 --- a/packages/app-admin/src/features/formModel/Field.ts +++ b/packages/app-admin/src/features/formModel/Field.ts @@ -402,6 +402,7 @@ export class Field implements IField { return { name: this.config.name, + qualifiedName: this._qualifiedName, type: this.config.type, label: this.config.label, help: this.config.help, diff --git a/packages/app-admin/src/features/formModel/ObjectField.ts b/packages/app-admin/src/features/formModel/ObjectField.ts index 1bf23479df3..d51257e0093 100644 --- a/packages/app-admin/src/features/formModel/ObjectField.ts +++ b/packages/app-admin/src/features/formModel/ObjectField.ts @@ -764,6 +764,7 @@ export class ObjectField implements IObjectField { const baseVm = this._base.vm; return { name: baseVm.name, + qualifiedName: baseVm.qualifiedName, type: "object", label: baseVm.label, help: baseVm.help, diff --git a/packages/app-admin/src/features/formModel/abstractions.ts b/packages/app-admin/src/features/formModel/abstractions.ts index 25db8793d10..6bd9586a3ab 100644 --- a/packages/app-admin/src/features/formModel/abstractions.ts +++ b/packages/app-admin/src/features/formModel/abstractions.ts @@ -142,6 +142,8 @@ export interface IFieldValidation { export interface IFieldVM { name: string; + /** Full dotted path of the field within the form (e.g. "author.address.street"). */ + qualifiedName: string; type: string; label?: string; help?: string; diff --git a/packages/app-admin/src/features/formModel/createFieldRenderer.tsx b/packages/app-admin/src/features/formModel/createFieldRenderer.tsx index df962d7e8ec..5d0ed1b9c68 100644 --- a/packages/app-admin/src/features/formModel/createFieldRenderer.tsx +++ b/packages/app-admin/src/features/formModel/createFieldRenderer.tsx @@ -1,7 +1,20 @@ import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; +import { makeDecoratable } from "@webiny/react-composition"; import type { IFieldVM, IObjectFieldVM, FieldRendererSettings } from "./abstractions.js"; +/** + * Decoratable wrapper rendered around every leaf field's content. Features can decorate it to + * inject per-field UI (e.g. a comment marker) keyed on `field.qualifiedName`, without forking + * the form. By default it renders the field content unchanged. + */ +export const FormFieldWrapper = makeDecoratable( + "FormFieldWrapper", + ({ children }: { field: IFieldVM; children?: React.ReactNode }) => { + return <>{children}; + } +); + type RendererField = IFieldVM & { rendererSettings: FieldRendererSettings; }; @@ -27,7 +40,11 @@ const ScrollOnFocus = observer( field.clearFocusRequest(); }, [field.focusRequested]); - return
{children}
; + return ( +
+ {children} +
+ ); } ); diff --git a/packages/app-collaboration/package.json b/packages/app-collaboration/package.json new file mode 100644 index 00000000000..b5f4223cb61 --- /dev/null +++ b/packages/app-collaboration/package.json @@ -0,0 +1,45 @@ +{ + "name": "@webiny/app-collaboration", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./*": "./*" + }, + "description": "Collaboration Admin (threaded comments and tasks on content)", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/app-collaboration" + }, + "author": "Webiny Ltd", + "license": "MIT", + "dependencies": { + "@webiny/admin-ui": "0.0.0", + "@webiny/app": "0.0.0", + "@webiny/app-admin": "0.0.0", + "@webiny/app-headless-cms": "0.0.0", + "@webiny/feature": "0.0.0", + "@webiny/form": "0.0.0", + "@webiny/icons": "0.0.0", + "@webiny/utils": "0.0.0", + "@webiny/validation": "0.0.0", + "lodash": "^4.18.1", + "mobx": "^6.16.1", + "mobx-react-lite": "^4.1.1", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.31", + "@webiny/build-tools": "0.0.0", + "rimraf": "^6.1.3", + "typescript": "7.0.2" + }, + "publishConfig": { + "access": "public" + }, + "webiny": { + "publishFrom": "dist" + } +} diff --git a/packages/app-collaboration/src/app.tsx b/packages/app-collaboration/src/app.tsx new file mode 100644 index 00000000000..55cf24ace2b --- /dev/null +++ b/packages/app-collaboration/src/app.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { RegisterFeature } from "@webiny/app-admin"; +import { CollaborationApiFeature } from "~/features/api/feature.js"; +import { CommentsPresenterFeature } from "~/presentation/comments/feature.js"; +import { CommentsHeaderButton } from "~/cms/CommentsHeaderButton.js"; +import { CommentsSidePanelDecorator } from "~/cms/CommentsSidePanelDecorator.js"; +import { FieldMarkerDecorator } from "~/cms/FieldMarkerDecorator.js"; + +/** + * Mount once in the admin app. Registers the collaboration data-access + presenter features and + * injects the Comments header toggle + side panel into the Headless CMS entry editor. + * + * TODO(before release): collaboration ships in the APW tier — re-wrap in + * (or the appropriate capability gate). Ungated for now so it works + * without a workflows license during development. + */ +export const CollaborationAdminApp = () => { + return ( + <> + + + + + + + ); +}; diff --git a/packages/app-collaboration/src/cms/CommentFieldMarker.tsx b/packages/app-collaboration/src/cms/CommentFieldMarker.tsx new file mode 100644 index 00000000000..afc16a74a82 --- /dev/null +++ b/packages/app-collaboration/src/cms/CommentFieldMarker.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { ReactComponent as AddCommentIcon } from "@webiny/icons/add_comment.svg"; +import { ReactComponent as ChatBubbleIcon } from "@webiny/icons/chat_bubble.svg"; +import type { IFieldVM } from "@webiny/app-admin/features/formModel/abstractions.js"; +import { useCommentsPresenter } from "~/presentation/comments/useComments.js"; +import "~/presentation/comments/styles.js"; + +/** + * Per-field affordance in the CMS entry form: a comment-count badge when the field has open + * threads, otherwise a hover-revealed "Comment" pill. Clicking opens the panel focused on this + * field. Renders nothing outside the entry editor (no contentId loaded). + */ +export const CommentFieldMarker = observer(({ field }: { field: IFieldVM }) => { + const presenter = useCommentsPresenter(); + const { vm } = presenter; + + if (!vm.contentId) { + return null; + } + + const locator = field.qualifiedName; + const count = vm.threads.filter(thread => thread.locator === locator).length; + const open = () => presenter.openPanel(locator); + + if (count > 0) { + return ( + + + {count} + + ); + } + + return ( + + + Comment + + ); +}); diff --git a/packages/app-collaboration/src/cms/CommentsHeaderButton.tsx b/packages/app-collaboration/src/cms/CommentsHeaderButton.tsx new file mode 100644 index 00000000000..f87dbc9e61c --- /dev/null +++ b/packages/app-collaboration/src/cms/CommentsHeaderButton.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { InternalContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { CommentsToggle } from "~/presentation/comments/components/CommentsToggle.js"; + +const { Actions } = InternalContentEntryEditorConfig; + +export const CommentsHeaderButton = () => { + return ( + + } + /> + + ); +}; diff --git a/packages/app-collaboration/src/cms/CommentsSidePanelDecorator.tsx b/packages/app-collaboration/src/cms/CommentsSidePanelDecorator.tsx new file mode 100644 index 00000000000..3b8f9756e27 --- /dev/null +++ b/packages/app-collaboration/src/cms/CommentsSidePanelDecorator.tsx @@ -0,0 +1,75 @@ +import React, { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { ContentEntryFormContent } from "@webiny/app-headless-cms/presentation/contentEntries/views/layout/index.js"; +import { useContentEntryFormPresenter } from "@webiny/app-headless-cms/presentation/contentEntries/form/useContentEntryFormPresenter.js"; +import { useCommentsPresenter } from "~/presentation/comments/useComments.js"; +import { CommentsPanel } from "~/presentation/comments/components/CommentsPanel.js"; +import { buildLocatorLabel } from "~/cms/fieldLabels.js"; +import { cmsContentId } from "~/constants.js"; + +export const CommentsSidePanelDecorator = ContentEntryFormContent.createDecorator(Original => { + return observer(function CommentsSidePanelDecoratorInner(props) { + const formPresenter = useContentEntryFormPresenter(); + const presenter = useCommentsPresenter(); + + const vm = formPresenter.vm; + const entryId = vm.entry?.entryId; + const modelId = vm.model?.modelId; + + useEffect(() => { + if (!vm.isNewEntry && entryId && modelId) { + presenter.init(cmsContentId(modelId, entryId)); + } + }, [entryId, modelId, vm.isNewEntry, presenter]); + + if (vm.isNewEntry || !entryId || !modelId) { + return ; + } + + const open = presenter.vm.isOpen; + + const modelFields = vm.model.fields || []; + const resolveLabel = (locator: string) => buildLocatorLabel(modelFields, locator); + + const jumpToField = (locator: string) => { + vm.form?.focusField(locator); + }; + + // The panel stays mounted so it can animate both in and out: the container width + // transitions 0 <-> 384px (content column reflows smoothly) while the panel itself + // slides + fades in. + return ( +
+
+ +
+
+
+ +
+
+
+ ); + }); +}); diff --git a/packages/app-collaboration/src/cms/FieldMarkerDecorator.tsx b/packages/app-collaboration/src/cms/FieldMarkerDecorator.tsx new file mode 100644 index 00000000000..30174735949 --- /dev/null +++ b/packages/app-collaboration/src/cms/FieldMarkerDecorator.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { FormFieldWrapper } from "@webiny/app-admin/features/formModel/createFieldRenderer.js"; +import type { IFieldVM } from "@webiny/app-admin/features/formModel/abstractions.js"; +import { useCommentsPresenter } from "~/presentation/comments/useComments.js"; +import { CommentFieldMarker } from "./CommentFieldMarker.js"; + +interface WrapperProps { + field: IFieldVM; + children?: React.ReactNode; +} + +/** + * Decorates the shared per-field wrapper to mount a comment marker on each field. Passes through + * unchanged (zero layout impact) outside the CMS entry editor, where no thread contentId is set. + */ +export const FieldMarkerDecorator = FormFieldWrapper.createDecorator(Original => { + return observer(function FieldMarkerWrapper(props: WrapperProps) { + const presenter = useCommentsPresenter(); + + if (!presenter.vm.contentId) { + return ; + } + + return ( +
+ + +
+ ); + }); +}); diff --git a/packages/app-collaboration/src/cms/fieldLabels.ts b/packages/app-collaboration/src/cms/fieldLabels.ts new file mode 100644 index 00000000000..7045b184f7c --- /dev/null +++ b/packages/app-collaboration/src/cms/fieldLabels.ts @@ -0,0 +1,84 @@ +/** + * Client-side mirror of the server CmsLocatorResolver's model walk. Produces the same + * "Parent › Field" breadcrumb the resolved thread anchor shows, so the composer's location chip + * matches the created comment's location. + */ +export interface FieldLike { + fieldId: string; + label?: string; + settings?: { + fields?: FieldLike[]; + templates?: Array<{ fields?: FieldLike[] }>; + }; +} + +export interface FieldOption { + locator: string; + label: string; +} + +const isListIndex = (segment: string): boolean => /^\d+$/.test(segment); + +const childrenOf = (field: FieldLike): FieldLike[] => { + const children: FieldLike[] = []; + if (field.settings?.fields) { + children.push(...field.settings.fields); + } + if (field.settings?.templates) { + for (const template of field.settings.templates) { + if (template.fields) { + children.push(...template.fields); + } + } + } + return children; +}; + +/** + * Resolves a fieldId-dotted locator (numeric list indices skipped) to a breadcrumb label. + * Falls back to the raw locator if a segment can't be resolved. + */ +export const buildLocatorLabel = (fields: FieldLike[], locator: string): string => { + const segments = locator.split(".").filter(part => part.length > 0 && !isListIndex(part)); + + let current = fields; + let field: FieldLike | undefined; + const labels: string[] = []; + + for (const segment of segments) { + field = current.find(item => item.fieldId === segment); + if (!field) { + return locator; + } + labels.push(field.label || field.fieldId); + current = childrenOf(field); + } + + return labels.length > 0 ? labels.join(" › ") : locator; +}; + +/** + * Flattens all leaf fields (top-level + nested object/dynamic-zone) into selectable options with + * dotted locators and breadcrumb labels — used to populate the composer's field picker. + */ +export const flattenFields = ( + fields: FieldLike[], + parentPath = "", + parentLabels: string[] = [] +): FieldOption[] => { + const options: FieldOption[] = []; + + for (const field of fields) { + const locator = parentPath ? `${parentPath}.${field.fieldId}` : field.fieldId; + const labels = [...parentLabels, field.label || field.fieldId]; + const children = childrenOf(field); + + if (children.length === 0) { + options.push({ locator, label: labels.join(" › ") }); + } else { + options.push(...flattenFields(children, locator, labels)); + } + } + + return options; +}; diff --git a/packages/app-collaboration/src/constants.ts b/packages/app-collaboration/src/constants.ts new file mode 100644 index 00000000000..1f9fab63103 --- /dev/null +++ b/packages/app-collaboration/src/constants.ts @@ -0,0 +1,12 @@ +/** + * Content type for CMS entries. Must match the server-side CmsLocatorResolver. + */ +export const CONTENT_TYPE_CMS_ENTRY = "cms.entry"; + +/** + * Composes the opaque collaboration contentId for a CMS entry (`:`), matching + * how the server-side CmsLocatorResolver parses it. + */ +export const cmsContentId = (modelId: string, entryId: string): string => { + return `${modelId}:${entryId}`; +}; diff --git a/packages/app-collaboration/src/features/api/CollaborationApi.ts b/packages/app-collaboration/src/features/api/CollaborationApi.ts new file mode 100644 index 00000000000..d694b05411a --- /dev/null +++ b/packages/app-collaboration/src/features/api/CollaborationApi.ts @@ -0,0 +1,48 @@ +import { CollaborationApi as ApiAbstraction, CollaborationGateway } from "./abstractions.js"; +import type { IListThreadsOptions } from "./abstractions.js"; +import type { CreateThreadInput, ListThreadsWhere } from "~/types.js"; + +class CollaborationApiImpl implements ApiAbstraction.Interface { + constructor(private gateway: CollaborationGateway.Interface) {} + + listThreads(where: ListThreadsWhere, options?: IListThreadsOptions) { + return this.gateway.listThreads(where, options); + } + + createThread(input: CreateThreadInput) { + return this.gateway.createThread(input); + } + + replyToThread(threadId: string, body: string, mentions?: string[]) { + return this.gateway.replyToThread(threadId, body, mentions); + } + + resolveThread(id: string) { + return this.gateway.resolveThread(id); + } + + reopenThread(id: string) { + return this.gateway.reopenThread(id); + } + + deleteThread(id: string) { + return this.gateway.deleteThread(id); + } + + updateMessage(threadId: string, messageId: string, body: string) { + return this.gateway.updateMessage(threadId, messageId, body); + } + + deleteMessage(threadId: string, messageId: string) { + return this.gateway.deleteMessage(threadId, messageId); + } + + listMentionableUsers() { + return this.gateway.listMentionableUsers(); + } +} + +export const CollaborationApi = ApiAbstraction.createImplementation({ + implementation: CollaborationApiImpl, + dependencies: [CollaborationGateway] +}); diff --git a/packages/app-collaboration/src/features/api/CollaborationGateway.ts b/packages/app-collaboration/src/features/api/CollaborationGateway.ts new file mode 100644 index 00000000000..f8dfe58b340 --- /dev/null +++ b/packages/app-collaboration/src/features/api/CollaborationGateway.ts @@ -0,0 +1,227 @@ +import { MainGraphQLClient } from "@webiny/app/features/mainGraphQLClient/abstractions.js"; +import { CollaborationGateway as GatewayAbstraction } from "./abstractions.js"; +import type { IListThreadsOptions, IListThreadsResult } from "./abstractions.js"; +import type { + CollabMessage, + CollabThread, + CollabThreadsMeta, + CollabUser, + CreateThreadInput, + ListThreadsWhere +} from "~/types.js"; +import { ERROR_FIELDS, MESSAGE_FIELDS, THREAD_FIELDS } from "./graphqlFields.js"; + +interface GqlError { + code: string; + message: string; + data?: unknown; +} + +const unwrap = (envelope: { data: T | null; error: GqlError | null }): T => { + if (envelope.error) { + throw new Error(envelope.error.message); + } + return envelope.data as T; +}; + +const LIST_QUERY = /* GraphQL */ ` + query ListCollabThreads($where: ListCollabThreadsWhereInput!, $limit: Int, $after: String) { + collaboration { + listCollabThreads(where: $where, limit: $limit, after: $after) { + data ${THREAD_FIELDS} + meta { totalCount hasMoreItems cursor } + ${ERROR_FIELDS} + } + } + } +`; + +const CREATE_MUTATION = /* GraphQL */ ` + mutation CreateCollabThread($input: CreateCollabThreadInput!) { + collaboration { createCollabThread(input: $input) { data ${THREAD_FIELDS} ${ERROR_FIELDS} } } + } +`; + +const REPLY_MUTATION = /* GraphQL */ ` + mutation ReplyToCollabThread($threadId: ID!, $body: String!, $mentions: [String!]) { + collaboration { + replyToCollabThread(threadId: $threadId, body: $body, mentions: $mentions) { + data ${MESSAGE_FIELDS} + ${ERROR_FIELDS} + } + } + } +`; + +const RESOLVE_MUTATION = /* GraphQL */ ` + mutation ResolveCollabThread($id: ID!) { + collaboration { resolveCollabThread(id: $id) { data ${THREAD_FIELDS} ${ERROR_FIELDS} } } + } +`; + +const REOPEN_MUTATION = /* GraphQL */ ` + mutation ReopenCollabThread($id: ID!) { + collaboration { reopenCollabThread(id: $id) { data ${THREAD_FIELDS} ${ERROR_FIELDS} } } + } +`; + +const DELETE_MUTATION = /* GraphQL */ ` + mutation DeleteCollabThread($id: ID!) { + collaboration { deleteCollabThread(id: $id) { data ${ERROR_FIELDS} } } + } +`; + +const UPDATE_MESSAGE_MUTATION = /* GraphQL */ ` + mutation UpdateCollabMessage($threadId: ID!, $messageId: ID!, $body: String!) { + collaboration { + updateCollabMessage(threadId: $threadId, messageId: $messageId, body: $body) { + data ${MESSAGE_FIELDS} + ${ERROR_FIELDS} + } + } + } +`; + +const DELETE_MESSAGE_MUTATION = /* GraphQL */ ` + mutation DeleteCollabMessage($threadId: ID!, $messageId: ID!) { + collaboration { + deleteCollabMessage(threadId: $threadId, messageId: $messageId) { + data ${ERROR_FIELDS} + } + } + } +`; + +const LIST_USERS_QUERY = /* GraphQL */ ` + query CollabListMentionableUsers { + adminUsers { + listUsers { + data { + id + displayName + email + avatar + } + error { + code + message + } + } + } + } +`; + +interface ListResponse { + collaboration: { + listCollabThreads: { + data: CollabThread[] | null; + meta: CollabThreadsMeta | null; + error: GqlError | null; + }; + }; +} + +class CollaborationGatewayImpl implements GatewayAbstraction.Interface { + constructor(private client: MainGraphQLClient.Interface) {} + + async listThreads( + where: ListThreadsWhere, + options: IListThreadsOptions = {} + ): Promise { + const response = await this.client.execute({ + query: LIST_QUERY, + variables: { where, limit: options.limit, after: options.after } + }); + const envelope = response.collaboration.listCollabThreads; + if (envelope.error) { + throw new Error(envelope.error.message); + } + return { + items: envelope.data || [], + meta: envelope.meta || { totalCount: 0, hasMoreItems: false, cursor: null } + }; + } + + async createThread(input: CreateThreadInput): Promise { + const response = await this.client.execute<{ + collaboration: { + createCollabThread: { data: CollabThread | null; error: GqlError | null }; + }; + }>({ query: CREATE_MUTATION, variables: { input } }); + return unwrap(response.collaboration.createCollabThread); + } + + async replyToThread( + threadId: string, + body: string, + mentions?: string[] + ): Promise { + const response = await this.client.execute<{ + collaboration: { + replyToCollabThread: { data: CollabMessage | null; error: GqlError | null }; + }; + }>({ query: REPLY_MUTATION, variables: { threadId, body, mentions } }); + return unwrap(response.collaboration.replyToCollabThread); + } + + async resolveThread(id: string): Promise { + const response = await this.client.execute<{ + collaboration: { + resolveCollabThread: { data: CollabThread | null; error: GqlError | null }; + }; + }>({ query: RESOLVE_MUTATION, variables: { id } }); + return unwrap(response.collaboration.resolveCollabThread); + } + + async reopenThread(id: string): Promise { + const response = await this.client.execute<{ + collaboration: { + reopenCollabThread: { data: CollabThread | null; error: GqlError | null }; + }; + }>({ query: REOPEN_MUTATION, variables: { id } }); + return unwrap(response.collaboration.reopenCollabThread); + } + + async deleteThread(id: string): Promise { + const response = await this.client.execute<{ + collaboration: { deleteCollabThread: { data: boolean | null; error: GqlError | null } }; + }>({ query: DELETE_MUTATION, variables: { id } }); + return unwrap(response.collaboration.deleteCollabThread) === true; + } + + async updateMessage(threadId: string, messageId: string, body: string): Promise { + const response = await this.client.execute<{ + collaboration: { + updateCollabMessage: { data: CollabMessage | null; error: GqlError | null }; + }; + }>({ query: UPDATE_MESSAGE_MUTATION, variables: { threadId, messageId, body } }); + return unwrap(response.collaboration.updateCollabMessage); + } + + async deleteMessage(threadId: string, messageId: string): Promise { + const response = await this.client.execute<{ + collaboration: { + deleteCollabMessage: { data: boolean | null; error: GqlError | null }; + }; + }>({ query: DELETE_MESSAGE_MUTATION, variables: { threadId, messageId } }); + return unwrap(response.collaboration.deleteCollabMessage) === true; + } + + async listMentionableUsers(): Promise { + try { + const response = await this.client.execute<{ + adminUsers: { listUsers: { data: CollabUser[] | null; error: GqlError | null } }; + }>({ query: LIST_USERS_QUERY }); + const envelope = response.adminUsers.listUsers; + return envelope.error ? [] : envelope.data || []; + } catch { + // Best-effort: mentions degrade gracefully if the caller can't list users. + return []; + } + } +} + +export const CollaborationGateway = GatewayAbstraction.createImplementation({ + implementation: CollaborationGatewayImpl, + dependencies: [MainGraphQLClient] +}); diff --git a/packages/app-collaboration/src/features/api/abstractions.ts b/packages/app-collaboration/src/features/api/abstractions.ts new file mode 100644 index 00000000000..a88fbe4e8f3 --- /dev/null +++ b/packages/app-collaboration/src/features/api/abstractions.ts @@ -0,0 +1,53 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { + CollabMessage, + CollabThread, + CollabThreadsMeta, + CollabUser, + CreateThreadInput, + ListThreadsWhere +} from "~/types.js"; + +export interface IListThreadsResult { + items: CollabThread[]; + meta: CollabThreadsMeta; +} + +export interface IListThreadsOptions { + limit?: number; + after?: string | null; +} + +/** + * Multi-method data-access surface for the collaboration GraphQL API. The Gateway talks to the + * API; the Api use case is a thin, injectable pass-through the presenter depends on. + */ +export interface ICollaborationApi { + listThreads( + where: ListThreadsWhere, + options?: IListThreadsOptions + ): Promise; + createThread(input: CreateThreadInput): Promise; + replyToThread(threadId: string, body: string, mentions?: string[]): Promise; + resolveThread(id: string): Promise; + reopenThread(id: string): Promise; + deleteThread(id: string): Promise; + updateMessage(threadId: string, messageId: string, body: string): Promise; + deleteMessage(threadId: string, messageId: string): Promise; + /** Tenant members that can be @mentioned. Best-effort: returns [] if unavailable. */ + listMentionableUsers(): Promise; +} + +export const CollaborationGateway = createAbstraction("Collaboration/Gateway"); + +export namespace CollaborationGateway { + export type Interface = ICollaborationApi; +} + +export const CollaborationApi = createAbstraction("Collaboration/Api"); + +export namespace CollaborationApi { + export type Interface = ICollaborationApi; + export type ListResult = IListThreadsResult; + export type ListOptions = IListThreadsOptions; +} diff --git a/packages/app-collaboration/src/features/api/feature.ts b/packages/app-collaboration/src/features/api/feature.ts new file mode 100644 index 00000000000..53d74532dde --- /dev/null +++ b/packages/app-collaboration/src/features/api/feature.ts @@ -0,0 +1,17 @@ +import { createFeature } from "@webiny/feature/admin"; +import { CollaborationApi as ApiAbstraction } from "./abstractions.js"; +import { CollaborationGateway } from "./CollaborationGateway.js"; +import { CollaborationApi } from "./CollaborationApi.js"; + +export const CollaborationApiFeature = createFeature({ + name: "Collaboration/Api", + register(container) { + container.register(CollaborationGateway).inSingletonScope(); + container.register(CollaborationApi).inSingletonScope(); + }, + resolve(container) { + return { + api: container.resolve(ApiAbstraction) + }; + } +}); diff --git a/packages/app-collaboration/src/features/api/graphqlFields.ts b/packages/app-collaboration/src/features/api/graphqlFields.ts new file mode 100644 index 00000000000..1622dda4cda --- /dev/null +++ b/packages/app-collaboration/src/features/api/graphqlFields.ts @@ -0,0 +1,52 @@ +export const ERROR_FIELDS = /* GraphQL */ ` + error { + code + message + data + } +`; + +export const IDENTITY_FIELDS = /* GraphQL */ ` + { + id + displayName + type + } +`; + +export const MESSAGE_FIELDS = /* GraphQL */ ` + { + id + body + mentions + createdBy ${IDENTITY_FIELDS} + createdOn + deleted + deletedBy ${IDENTITY_FIELDS} + deletedOn + } +`; + +export const THREAD_FIELDS = /* GraphQL */ ` + { + id + contentType + contentId + locator + type + resolved + resolvedBy ${IDENTITY_FIELDS} + resolvedOn + assigneeId + dueDate + createdBy ${IDENTITY_FIELDS} + createdOn + messages ${MESSAGE_FIELDS} + anchor { + exists + authorized + label + path + } + } +`; diff --git a/packages/app-collaboration/src/index.tsx b/packages/app-collaboration/src/index.tsx new file mode 100644 index 00000000000..1facc1aa263 --- /dev/null +++ b/packages/app-collaboration/src/index.tsx @@ -0,0 +1,12 @@ +export { CollaborationAdminApp } from "./app.js"; +export { CollaborationApiFeature } from "./features/api/feature.js"; +export { CommentsPresenterFeature } from "./presentation/comments/feature.js"; +export { useCommentsPresenter } from "./presentation/comments/useComments.js"; +export { CONTENT_TYPE_CMS_ENTRY, cmsContentId } from "./constants.js"; +export type { + CollabThread, + CollabMessage, + CollabIdentity, + CollabAnchor, + CollabThreadType +} from "./types.js"; diff --git a/packages/app-collaboration/src/presentation/comments/CommentsPresenter.ts b/packages/app-collaboration/src/presentation/comments/CommentsPresenter.ts new file mode 100644 index 00000000000..d6d762e52f3 --- /dev/null +++ b/packages/app-collaboration/src/presentation/comments/CommentsPresenter.ts @@ -0,0 +1,193 @@ +import { makeAutoObservable, runInAction, toJS } from "mobx"; +import { CommentsPresenter as PresenterAbstraction } from "./abstractions.js"; +import type { ICreateThreadParams } from "./abstractions.js"; +import { CollaborationApi } from "~/features/api/abstractions.js"; +import { CONTENT_TYPE_CMS_ENTRY } from "~/constants.js"; +import type { CollabThread, CollabUser } from "~/types.js"; + +/** + * Extracts a human-readable message from an Apollo-style error (which otherwise surfaces the + * generic "GraphQL errors"). + */ +const readableError = (err: unknown): string => { + const error = err as { + message?: string; + graphQLErrors?: Array<{ message?: string }>; + networkError?: { message?: string }; + }; + return ( + error?.graphQLErrors?.[0]?.message || + error?.networkError?.message || + error?.message || + "Failed to load comments." + ); +}; + +class CommentsPresenterImpl implements PresenterAbstraction.Interface { + private contentId: string | null = null; + private threads: CollabThread[] = []; + private loading = false; + private error: string | null = null; + private isOpen = false; + private activeLocator: string | null = null; + private users: CollabUser[] = []; + + constructor(private api: CollaborationApi.Interface) { + makeAutoObservable(this, { api: false }); + } + + openPanel(locator?: string) { + this.isOpen = true; + // No locator => entry-level (unanchored) comment. + this.activeLocator = locator ?? null; + } + + closePanel() { + this.isOpen = false; + } + + togglePanel() { + this.isOpen = !this.isOpen; + } + + setActiveLocator(locator: string | null) { + this.activeLocator = locator; + } + + get vm(): PresenterAbstraction.ViewModel { + // Soft-deleted threads are filtered out server-side, so everything here is live. + const active = this.threads; + const open = active.filter(thread => !thread.resolved && thread.anchor.exists); + const outdated = active.filter(thread => !thread.resolved && !thread.anchor.exists); + const resolved = active.filter(thread => thread.resolved); + // Only field-anchored threads count toward "across N fields". + const fields = new Set(open.filter(thread => thread.locator).map(thread => thread.locator)); + + return { + loading: this.loading, + error: this.error, + contentId: this.contentId, + isOpen: this.isOpen, + activeLocator: this.activeLocator, + threads: toJS(open), + outdatedThreads: toJS(outdated), + resolvedThreads: toJS(resolved), + unresolvedCount: open.length + outdated.length, + fieldCount: fields.size, + mentionableUsers: toJS(this.users) + }; + } + + async init(contentId: string) { + if (this.contentId === contentId && this.threads.length > 0) { + return; + } + runInAction(() => { + this.contentId = contentId; + }); + void this.loadUsers(); + await this.reload(); + } + + private async loadUsers() { + if (this.users.length > 0) { + return; + } + try { + const users = await this.api.listMentionableUsers(); + runInAction(() => { + this.users = users; + }); + } catch { + // Best-effort — mentions simply won't autocomplete. + } + } + + async reload() { + if (!this.contentId) { + return; + } + runInAction(() => { + this.loading = true; + this.error = null; + }); + try { + const result = await this.api.listThreads({ + contentType: CONTENT_TYPE_CMS_ENTRY, + contentId: this.contentId + }); + runInAction(() => { + this.threads = result.items; + this.loading = false; + }); + } catch (err) { + console.error("[collaboration] failed to load comment threads", err); + runInAction(() => { + this.error = readableError(err); + this.loading = false; + }); + } + } + + async createThread(params: ICreateThreadParams) { + if (!this.contentId) { + return; + } + const thread = await this.api.createThread({ + contentType: CONTENT_TYPE_CMS_ENTRY, + contentId: this.contentId, + locator: params.locator, + type: params.type ?? "note", + body: params.body, + mentions: params.mentions + }); + runInAction(() => { + this.threads = [thread, ...this.threads]; + }); + } + + async reply(threadId: string, body: string, mentions?: string[]) { + await this.api.replyToThread(threadId, body, mentions); + await this.reload(); + } + + async resolve(threadId: string) { + const updated = await this.api.resolveThread(threadId); + this.replaceThread(updated); + } + + async reopen(threadId: string) { + const updated = await this.api.reopenThread(threadId); + this.replaceThread(updated); + } + + async remove(threadId: string) { + await this.api.deleteThread(threadId); + runInAction(() => { + this.threads = this.threads.filter(thread => thread.id !== threadId); + }); + } + + async editMessage(threadId: string, messageId: string, body: string) { + await this.api.updateMessage(threadId, messageId, body); + await this.reload(); + } + + async deleteMessage(threadId: string, messageId: string) { + await this.api.deleteMessage(threadId, messageId); + await this.reload(); + } + + private replaceThread(updated: CollabThread) { + runInAction(() => { + this.threads = this.threads.map(thread => + thread.id === updated.id ? updated : thread + ); + }); + } +} + +export const CommentsPresenter = PresenterAbstraction.createImplementation({ + implementation: CommentsPresenterImpl, + dependencies: [CollaborationApi] +}); diff --git a/packages/app-collaboration/src/presentation/comments/abstractions.ts b/packages/app-collaboration/src/presentation/comments/abstractions.ts new file mode 100644 index 00000000000..6703f4d2bbc --- /dev/null +++ b/packages/app-collaboration/src/presentation/comments/abstractions.ts @@ -0,0 +1,56 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { CollabThread, CollabThreadType, CollabUser } from "~/types.js"; + +export interface ICreateThreadParams { + locator: string; + body: string; + type?: CollabThreadType; + mentions?: string[]; +} + +export interface ICommentsViewModel { + loading: boolean; + error: string | null; + contentId: string | null; + /** Whether the comments side panel is open (shared UI state). */ + isOpen: boolean; + /** Locator the composer should default to (set by a field's "add comment" action). */ + activeLocator: string | null; + /** Open (unresolved) threads whose anchor still exists. */ + threads: CollabThread[]; + /** Threads whose anchor no longer exists in the current revision. */ + outdatedThreads: CollabThread[]; + /** Resolved threads. */ + resolvedThreads: CollabThread[]; + unresolvedCount: number; + fieldCount: number; + /** Tenant members available to @mention. */ + mentionableUsers: CollabUser[]; +} + +export interface ICommentsPresenter { + readonly vm: ICommentsViewModel; + init(contentId: string): Promise; + reload(): Promise; + openPanel(locator?: string): void; + closePanel(): void; + togglePanel(): void; + setActiveLocator(locator: string | null): void; + createThread(params: ICreateThreadParams): Promise; + reply(threadId: string, body: string, mentions?: string[]): Promise; + resolve(threadId: string): Promise; + reopen(threadId: string): Promise; + remove(threadId: string): Promise; + editMessage(threadId: string, messageId: string, body: string): Promise; + deleteMessage(threadId: string, messageId: string): Promise; +} + +export const CommentsPresenter = createAbstraction( + "Collaboration/CommentsPresenter" +); + +export namespace CommentsPresenter { + export type Interface = ICommentsPresenter; + export type ViewModel = ICommentsViewModel; + export type CreateParams = ICreateThreadParams; +} diff --git a/packages/app-collaboration/src/presentation/comments/components/AutoTextarea.tsx b/packages/app-collaboration/src/presentation/comments/components/AutoTextarea.tsx new file mode 100644 index 00000000000..50239ac8fdc --- /dev/null +++ b/packages/app-collaboration/src/presentation/comments/components/AutoTextarea.tsx @@ -0,0 +1,56 @@ +import React, { useLayoutEffect, useRef } from "react"; + +interface Props { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + autoFocus?: boolean; + /** Height (px) at which the textarea stops growing and starts scrolling. */ + maxHeight?: number; + onKeyDown?: (event: React.KeyboardEvent) => void; +} + +/** + * A controlled textarea that grows with its content (starting at one line) up to `maxHeight`, + * then scrolls. Keeps long comments and replies readable while typing. + */ +export const AutoTextarea = ({ + value, + onChange, + placeholder, + className, + autoFocus, + maxHeight = 220, + onKeyDown +}: Props) => { + const ref = useRef(null); + + const resize = () => { + const el = ref.current; + if (!el) { + return; + } + el.style.height = "auto"; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + }; + + useLayoutEffect(() => { + resize(); + }, [value]); + + return ( +