Bläddra i källkod

feat: add listin worspaces and workspace users

Karol Sójko 2 år sedan
förälder
incheckning
095d13f8bb
42 ändrade filer med 837 tillägg och 95 borttagningar
  1. 54 45
      .pnp.cjs
  2. BIN
      .yarn/cache/@standardnotes-api-npm-1.15.0-12a67ff9b7-88ae0a340e.zip
  3. BIN
      .yarn/cache/@standardnotes-common-npm-1.39.0-1e36e2ef74-92cfad04a6.zip
  4. BIN
      .yarn/cache/@standardnotes-encryption-npm-1.17.0-587d631df2-587516dfed.zip
  5. BIN
      .yarn/cache/@standardnotes-features-npm-1.53.0-8ea4a2d559-a856e815a3.zip
  6. BIN
      .yarn/cache/@standardnotes-models-npm-1.26.0-dade8919ab-f595a3de88.zip
  7. BIN
      .yarn/cache/@standardnotes-responses-npm-1.11.0-d066ddbbb6-46d6a47980.zip
  8. BIN
      .yarn/cache/@standardnotes-utils-npm-1.10.0-0dc2ade40b-c02d54ca8a.zip
  9. 1 0
      packages/api-gateway/bin/server.ts
  10. 23 0
      packages/api-gateway/src/Controller/v1/InvitesController.ts
  11. 16 1
      packages/api-gateway/src/Controller/v1/WorkspacesController.ts
  12. 1 1
      packages/auth/package.json
  13. 1 0
      packages/workspace/bin/server.ts
  14. 2 1
      packages/workspace/package.json
  15. 16 0
      packages/workspace/src/Bootstrap/Container.ts
  16. 6 0
      packages/workspace/src/Bootstrap/Types.ts
  17. 107 1
      packages/workspace/src/Controller/WorkspacesController.spec.ts
  18. 85 0
      packages/workspace/src/Controller/WorkspacesController.ts
  19. 3 0
      packages/workspace/src/Domain/Projection/ProjectorInterface.ts
  20. 3 0
      packages/workspace/src/Domain/Projection/WorkspaceProjection.ts
  21. 30 0
      packages/workspace/src/Domain/Projection/WorkspaceProjector.spec.ts
  22. 19 0
      packages/workspace/src/Domain/Projection/WorkspaceProjector.ts
  23. 3 0
      packages/workspace/src/Domain/Projection/WorkspaceUserProjection.ts
  24. 42 0
      packages/workspace/src/Domain/Projection/WorkspaceUserProjector.spec.ts
  25. 25 0
      packages/workspace/src/Domain/Projection/WorkspaceUserProjector.ts
  26. 84 0
      packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers.spec.ts
  27. 50 0
      packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers.ts
  28. 6 0
      packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsersDTO.ts
  29. 6 0
      packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsersResponse.ts
  30. 47 0
      packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspaces.spec.ts
  31. 40 0
      packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspaces.ts
  32. 5 0
      packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspacesDTO.ts
  33. 6 0
      packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspacesResponse.ts
  34. 3 0
      packages/workspace/src/Domain/Workspace/WorkspaceRepositoryInterface.ts
  35. 3 0
      packages/workspace/src/Domain/Workspace/WorkspaceUserRepositoryInterface.ts
  36. 23 0
      packages/workspace/src/Infra/InversifyExpressUtils/InversifyExpressInvitesController.ts
  37. 20 1
      packages/workspace/src/Infra/InversifyExpressUtils/InversifyExpressWorkspacesController.ts
  38. 18 0
      packages/workspace/src/Infra/MySQL/MySQLWorkspaceRepository.spec.ts
  39. 8 0
      packages/workspace/src/Infra/MySQL/MySQLWorkspaceRepository.ts
  40. 18 0
      packages/workspace/src/Infra/MySQL/MySQLWorkspaceUserRepository.spec.ts
  41. 8 0
      packages/workspace/src/Infra/MySQL/MySQLWorkspaceUserRepository.ts
  42. 55 45
      yarn.lock

+ 54 - 45
.pnp.cjs

@@ -2521,16 +2521,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/api", [\
-        ["npm:1.12.1", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.12.1-6b8bfe4ccf-8623fc82de.zip/node_modules/@standardnotes/api/",\
+        ["npm:1.15.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.15.0-12a67ff9b7-88ae0a340e.zip/node_modules/@standardnotes/api/",\
           "packageDependencies": [\
-            ["@standardnotes/api", "npm:1.12.1"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
-            ["@standardnotes/encryption", "npm:1.16.2"],\
-            ["@standardnotes/models", "npm:1.24.2"],\
-            ["@standardnotes/responses", "npm:1.10.6"],\
+            ["@standardnotes/api", "npm:1.15.0"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["@standardnotes/encryption", "npm:1.17.0"],\
+            ["@standardnotes/models", "npm:1.26.0"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
             ["@standardnotes/security", "workspace:packages/security"],\
-            ["@standardnotes/utils", "npm:1.9.1"],\
+            ["@standardnotes/utils", "npm:1.10.0"],\
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
           "linkType": "HARD"\
@@ -2600,7 +2600,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
             ["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
             ["@sentry/node", "npm:7.5.0"],\
             ["@standardnotes/analytics", "workspace:packages/analytics"],\
-            ["@standardnotes/api", "npm:1.12.1"],\
+            ["@standardnotes/api", "npm:1.15.0"],\
             ["@standardnotes/common", "workspace:packages/common"],\
             ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
             ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
@@ -2653,6 +2653,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/common", [\
+        ["npm:1.39.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-common-npm-1.39.0-1e36e2ef74-92cfad04a6.zip/node_modules/@standardnotes/common/",\
+          "packageDependencies": [\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["reflect-metadata", "npm:0.1.13"]\
+          ],\
+          "linkType": "HARD"\
+        }],\
         ["workspace:packages/common", {\
           "packageLocation": "./packages/common/",\
           "packageDependencies": [\
@@ -2725,15 +2733,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/encryption", [\
-        ["npm:1.16.2", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.16.2-9e53125abe-50efc1b201.zip/node_modules/@standardnotes/encryption/",\
+        ["npm:1.17.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.17.0-587d631df2-587516dfed.zip/node_modules/@standardnotes/encryption/",\
           "packageDependencies": [\
-            ["@standardnotes/encryption", "npm:1.16.2"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
-            ["@standardnotes/models", "npm:1.24.2"],\
-            ["@standardnotes/responses", "npm:1.10.6"],\
+            ["@standardnotes/encryption", "npm:1.17.0"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["@standardnotes/models", "npm:1.26.0"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
             ["@standardnotes/sncrypto-common", "npm:1.13.0"],\
-            ["@standardnotes/utils", "npm:1.9.1"],\
+            ["@standardnotes/utils", "npm:1.10.0"],\
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
           "linkType": "HARD"\
@@ -2791,12 +2799,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
           ],\
           "linkType": "HARD"\
         }],\
-        ["npm:1.52.4", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.4-05c59084e4-aea7b48627.zip/node_modules/@standardnotes/features/",\
+        ["npm:1.53.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.53.0-8ea4a2d559-a856e815a3.zip/node_modules/@standardnotes/features/",\
           "packageDependencies": [\
-            ["@standardnotes/features", "npm:1.52.4"],\
+            ["@standardnotes/features", "npm:1.53.0"],\
             ["@standardnotes/auth", "npm:3.19.4"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
             ["@standardnotes/security", "workspace:packages/security"],\
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
@@ -2856,14 +2864,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/models", [\
-        ["npm:1.24.2", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.24.2-8c2c157efa-17b3cfba39.zip/node_modules/@standardnotes/models/",\
-          "packageDependencies": [\
-            ["@standardnotes/models", "npm:1.24.2"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
-            ["@standardnotes/features", "npm:1.52.4"],\
-            ["@standardnotes/responses", "npm:1.10.6"],\
-            ["@standardnotes/utils", "npm:1.9.1"],\
+        ["npm:1.26.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.26.0-dade8919ab-f595a3de88.zip/node_modules/@standardnotes/models/",\
+          "packageDependencies": [\
+            ["@standardnotes/models", "npm:1.26.0"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["@standardnotes/features", "npm:1.53.0"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
+            ["@standardnotes/utils", "npm:1.10.0"],\
             ["lodash", "npm:4.17.21"],\
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
@@ -2899,12 +2907,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/responses", [\
-        ["npm:1.10.6", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.6-f636794f47-0583e2cb77.zip/node_modules/@standardnotes/responses/",\
+        ["npm:1.11.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.11.0-d066ddbbb6-46d6a47980.zip/node_modules/@standardnotes/responses/",\
           "packageDependencies": [\
-            ["@standardnotes/responses", "npm:1.10.6"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
-            ["@standardnotes/features", "npm:1.52.4"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["@standardnotes/features", "npm:1.53.0"],\
             ["@standardnotes/security", "workspace:packages/security"],\
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
@@ -3125,6 +3133,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         }]\
       ]],\
       ["@standardnotes/utils", [\
+        ["npm:1.10.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-utils-npm-1.10.0-0dc2ade40b-c02d54ca8a.zip/node_modules/@standardnotes/utils/",\
+          "packageDependencies": [\
+            ["@standardnotes/utils", "npm:1.10.0"],\
+            ["@standardnotes/common", "npm:1.39.0"],\
+            ["dompurify", "npm:2.4.0"],\
+            ["lodash", "npm:4.17.21"],\
+            ["reflect-metadata", "npm:0.1.13"]\
+          ],\
+          "linkType": "HARD"\
+        }],\
         ["npm:1.6.12", {\
           "packageLocation": "./.yarn/cache/@standardnotes-utils-npm-1.6.12-8fa8d7d09b-e177b1fa51.zip/node_modules/@standardnotes/utils/",\
           "packageDependencies": [\
@@ -3134,17 +3153,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
             ["lodash", "npm:4.17.21"]\
           ],\
           "linkType": "HARD"\
-        }],\
-        ["npm:1.9.1", {\
-          "packageLocation": "./.yarn/cache/@standardnotes-utils-npm-1.9.1-e48d87ffc7-f775bb3744.zip/node_modules/@standardnotes/utils/",\
-          "packageDependencies": [\
-            ["@standardnotes/utils", "npm:1.9.1"],\
-            ["@standardnotes/common", "workspace:packages/common"],\
-            ["dompurify", "npm:2.4.0"],\
-            ["lodash", "npm:4.17.21"],\
-            ["reflect-metadata", "npm:0.1.13"]\
-          ],\
-          "linkType": "HARD"\
         }]\
       ]],\
       ["@standardnotes/workspace-server", [\
@@ -3154,10 +3162,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
             ["@standardnotes/workspace-server", "workspace:packages/workspace"],\
             ["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
             ["@sentry/node", "npm:7.5.0"],\
-            ["@standardnotes/api", "npm:1.12.1"],\
+            ["@standardnotes/api", "npm:1.15.0"],\
             ["@standardnotes/common", "workspace:packages/common"],\
             ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
             ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
+            ["@standardnotes/models", "npm:1.26.0"],\
             ["@standardnotes/security", "workspace:packages/security"],\
             ["@standardnotes/time", "workspace:packages/time"],\
             ["@types/cors", "npm:2.8.12"],\

BIN
.yarn/cache/@standardnotes-api-npm-1.12.1-6b8bfe4ccf-8623fc82de.zip → .yarn/cache/@standardnotes-api-npm-1.15.0-12a67ff9b7-88ae0a340e.zip


BIN
.yarn/cache/@standardnotes-common-npm-1.39.0-1e36e2ef74-92cfad04a6.zip


BIN
.yarn/cache/@standardnotes-encryption-npm-1.16.2-9e53125abe-50efc1b201.zip → .yarn/cache/@standardnotes-encryption-npm-1.17.0-587d631df2-587516dfed.zip


BIN
.yarn/cache/@standardnotes-features-npm-1.52.4-05c59084e4-aea7b48627.zip → .yarn/cache/@standardnotes-features-npm-1.53.0-8ea4a2d559-a856e815a3.zip


BIN
.yarn/cache/@standardnotes-models-npm-1.24.2-8c2c157efa-17b3cfba39.zip → .yarn/cache/@standardnotes-models-npm-1.26.0-dade8919ab-f595a3de88.zip


BIN
.yarn/cache/@standardnotes-responses-npm-1.10.6-f636794f47-0583e2cb77.zip → .yarn/cache/@standardnotes-responses-npm-1.11.0-d066ddbbb6-46d6a47980.zip


BIN
.yarn/cache/@standardnotes-utils-npm-1.9.1-e48d87ffc7-f775bb3744.zip → .yarn/cache/@standardnotes-utils-npm-1.10.0-0dc2ade40b-c02d54ca8a.zip


+ 1 - 0
packages/api-gateway/bin/server.ts

@@ -20,6 +20,7 @@ import '../src/Controller/v1/OfflineController'
 import '../src/Controller/v1/FilesController'
 import '../src/Controller/v1/SubscriptionInvitesController'
 import '../src/Controller/v1/WorkspacesController'
+import '../src/Controller/v1/InvitesController'
 
 import '../src/Controller/v2/PaymentsControllerV2'
 import '../src/Controller/v2/ActionsControllerV2'

+ 23 - 0
packages/api-gateway/src/Controller/v1/InvitesController.ts

@@ -0,0 +1,23 @@
+import { inject } from 'inversify'
+import { Request, Response } from 'express'
+import { controller, BaseHttpController, httpPost } from 'inversify-express-utils'
+
+import TYPES from '../../Bootstrap/Types'
+import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
+
+@controller('/v1/invites', TYPES.AuthMiddleware)
+export class InvitesController extends BaseHttpController {
+  constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
+    super()
+  }
+
+  @httpPost('/:inviteUuid/accept')
+  async accept(request: Request, response: Response): Promise<void> {
+    await this.httpService.callWorkspaceServer(
+      request,
+      response,
+      `invites/${request.params.inviteUuid}/accept`,
+      request.body,
+    )
+  }
+}

+ 16 - 1
packages/api-gateway/src/Controller/v1/WorkspacesController.ts

@@ -1,6 +1,6 @@
 import { inject } from 'inversify'
 import { Request, Response } from 'express'
-import { controller, BaseHttpController, httpPost } from 'inversify-express-utils'
+import { controller, BaseHttpController, httpPost, httpGet } from 'inversify-express-utils'
 
 import TYPES from '../../Bootstrap/Types'
 import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
@@ -16,6 +16,21 @@ export class WorkspacesController extends BaseHttpController {
     await this.httpService.callWorkspaceServer(request, response, 'workspaces', request.body)
   }
 
+  @httpGet('/:workspaceUuid/users')
+  async listWorkspaceUsers(request: Request, response: Response): Promise<void> {
+    await this.httpService.callWorkspaceServer(
+      request,
+      response,
+      `workspaces/${request.params.workspaceUuid}/users`,
+      request.body,
+    )
+  }
+
+  @httpGet('/')
+  async listWorkspaces(request: Request, response: Response): Promise<void> {
+    await this.httpService.callWorkspaceServer(request, response, 'workspaces', request.body)
+  }
+
   @httpPost('/:workspaceUuid/invites')
   async invite(request: Request, response: Response): Promise<void> {
     await this.httpService.callWorkspaceServer(

+ 1 - 1
packages/auth/package.json

@@ -34,7 +34,7 @@
     "@newrelic/winston-enricher": "^4.0.0",
     "@sentry/node": "^7.3.0",
     "@standardnotes/analytics": "workspace:*",
-    "@standardnotes/api": "^1.12.1",
+    "@standardnotes/api": "^1.15.0",
     "@standardnotes/common": "workspace:*",
     "@standardnotes/domain-events": "workspace:*",
     "@standardnotes/domain-events-infra": "workspace:*",

+ 1 - 0
packages/workspace/bin/server.ts

@@ -5,6 +5,7 @@ import 'newrelic'
 import * as Sentry from '@sentry/node'
 
 import '../src/Infra/InversifyExpressUtils/InversifyExpressHealthCheckController'
+import '../src/Infra/InversifyExpressUtils/InversifyExpressInvitesController'
 import '../src/Infra/InversifyExpressUtils/InversifyExpressWorkspacesController'
 
 import * as cors from 'cors'

+ 2 - 1
packages/workspace/package.json

@@ -25,10 +25,11 @@
   "dependencies": {
     "@newrelic/winston-enricher": "^4.0.0",
     "@sentry/node": "^7.3.0",
-    "@standardnotes/api": "^1.12.1",
+    "@standardnotes/api": "^1.15.0",
     "@standardnotes/common": "workspace:*",
     "@standardnotes/domain-events": "workspace:^",
     "@standardnotes/domain-events-infra": "workspace:^",
+    "@standardnotes/models": "^1.26.0",
     "@standardnotes/security": "workspace:*",
     "@standardnotes/time": "workspace:^",
     "aws-sdk": "^2.1159.0",

+ 16 - 0
packages/workspace/src/Bootstrap/Container.ts

@@ -38,6 +38,14 @@ import { WorkspaceInvite } from '../Domain/Invite/WorkspaceInvite'
 import { InviteToWorkspace } from '../Domain/UseCase/InviteToWorkspace/InviteToWorkspace'
 import { DomainEventFactory } from '../Domain/Event/DomainEventFactory'
 import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
+import { WorkspaceProjection } from '../Domain/Projection/WorkspaceProjection'
+import { WorkspaceProjector } from '../Domain/Projection/WorkspaceProjector'
+import { ProjectorInterface } from '../Domain/Projection/ProjectorInterface'
+import { WorkspaceUserProjection } from '../Domain/Projection/WorkspaceUserProjection'
+import { WorkspaceUserProjector } from '../Domain/Projection/WorkspaceUserProjector'
+import { AcceptInvitation } from '../Domain/UseCase/AcceptInvitation/AcceptInvitation'
+import { ListWorkspaces } from '../Domain/UseCase/ListWorkspaces/ListWorkspaces'
+import { ListWorkspaceUsers } from '../Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers'
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -131,8 +139,16 @@ export class ContainerConfigLoader {
     // use cases
     container.bind<CreateWorkspace>(TYPES.CreateWorkspace).to(CreateWorkspace)
     container.bind<InviteToWorkspace>(TYPES.InviteToWorkspace).to(InviteToWorkspace)
+    container.bind<AcceptInvitation>(TYPES.AcceptInvitation).to(AcceptInvitation)
+    container.bind<ListWorkspaces>(TYPES.ListWorkspaces).to(ListWorkspaces)
+    container.bind<ListWorkspaceUsers>(TYPES.ListWorkspaceUsers).to(ListWorkspaceUsers)
     // Handlers
     container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
+    // Projection
+    container.bind<ProjectorInterface<Workspace, WorkspaceProjection>>(TYPES.WorkspaceProjector).to(WorkspaceProjector)
+    container
+      .bind<ProjectorInterface<WorkspaceUser, WorkspaceUserProjection>>(TYPES.WorkspaceUserProjector)
+      .to(WorkspaceUserProjector)
     // Services
     container.bind<DomainEventFactoryInterface>(TYPES.DomainEventFactory).to(DomainEventFactory)
     container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())

+ 6 - 0
packages/workspace/src/Bootstrap/Types.ts

@@ -28,8 +28,14 @@ const TYPES = {
   // use cases
   CreateWorkspace: Symbol.for('CreateWorkspace'),
   InviteToWorkspace: Symbol.for('InviteToWorkspace'),
+  AcceptInvitation: Symbol.for('AcceptInvitation'),
+  ListWorkspaces: Symbol.for('ListWorkspaces'),
+  ListWorkspaceUsers: Symbol.for('ListWorkspaceUsers'),
   // Handlers
   UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
+  // Projection
+  WorkspaceProjector: Symbol.for('WorkspaceProjector'),
+  WorkspaceUserProjector: Symbol.for('WorkspaceUserProjector'),
   // Services
   Timer: Symbol.for('Timer'),
   CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),

+ 107 - 1
packages/workspace/src/Controller/WorkspacesController.spec.ts

@@ -1,16 +1,42 @@
 import { WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common'
 import 'reflect-metadata'
+import { ProjectorInterface } from '../Domain/Projection/ProjectorInterface'
+import { WorkspaceProjection } from '../Domain/Projection/WorkspaceProjection'
+import { WorkspaceUserProjection } from '../Domain/Projection/WorkspaceUserProjection'
+import { AcceptInvitation } from '../Domain/UseCase/AcceptInvitation/AcceptInvitation'
 
 import { CreateWorkspace } from '../Domain/UseCase/CreateWorkspace/CreateWorkspace'
 import { InviteToWorkspace } from '../Domain/UseCase/InviteToWorkspace/InviteToWorkspace'
+import { ListWorkspaces } from '../Domain/UseCase/ListWorkspaces/ListWorkspaces'
+import { ListWorkspaceUsers } from '../Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers'
+import { Workspace } from '../Domain/Workspace/Workspace'
+import { WorkspaceUser } from '../Domain/Workspace/WorkspaceUser'
 
 import { WorkspacesController } from './WorkspacesController'
 
 describe('WorkspacesController', () => {
   let doCreateWorkspace: CreateWorkspace
   let doInviteToWorkspace: InviteToWorkspace
+  let doAcceptInvitation: AcceptInvitation
+  let doListWorkspaces: ListWorkspaces
+  let doListWorkspaceUsers: ListWorkspaceUsers
+  let workspacesProject: ProjectorInterface<Workspace, WorkspaceProjection>
+  let workspaceUsersProjector: ProjectorInterface<WorkspaceUser, WorkspaceUserProjection>
+  let workspace1: Workspace
+  let workspace2: Workspace
+  let workspaceUser1: WorkspaceUser
+  let workspaceUser2: WorkspaceUser
 
-  const createController = () => new WorkspacesController(doCreateWorkspace, doInviteToWorkspace)
+  const createController = () =>
+    new WorkspacesController(
+      doCreateWorkspace,
+      doInviteToWorkspace,
+      doListWorkspaces,
+      doListWorkspaceUsers,
+      doAcceptInvitation,
+      workspacesProject,
+      workspaceUsersProjector,
+    )
 
   beforeEach(() => {
     doCreateWorkspace = {} as jest.Mocked<CreateWorkspace>
@@ -18,6 +44,23 @@ describe('WorkspacesController', () => {
 
     doInviteToWorkspace = {} as jest.Mocked<InviteToWorkspace>
     doInviteToWorkspace.execute = jest.fn().mockReturnValue({ invite: { uuid: 'i-1-2-3' } })
+
+    doListWorkspaces = {} as jest.Mocked<ListWorkspaces>
+    doListWorkspaces.execute = jest
+      .fn()
+      .mockReturnValue({ ownedWorkspaces: [workspace1], joinedWorkspaces: [workspace2] })
+
+    doListWorkspaceUsers = {} as jest.Mocked<ListWorkspaceUsers>
+    doListWorkspaceUsers.execute = jest.fn().mockReturnValue({ workspaceUsers: [workspaceUser1, workspaceUser2] })
+
+    doAcceptInvitation = {} as jest.Mocked<AcceptInvitation>
+    doAcceptInvitation.execute = jest.fn().mockReturnValue({ success: true })
+
+    workspacesProject = {} as jest.Mocked<ProjectorInterface<Workspace, WorkspaceProjection>>
+    workspacesProject.project = jest.fn().mockReturnValue({ foo: 'bar' })
+
+    workspaceUsersProjector = {} as jest.Mocked<ProjectorInterface<WorkspaceUser, WorkspaceUserProjection>>
+    workspaceUsersProjector.project = jest.fn().mockReturnValue({ bar: 'buzz' })
   })
 
   it('should create a workspace', async () => {
@@ -52,4 +95,67 @@ describe('WorkspacesController', () => {
       status: 200,
     })
   })
+
+  it('should accept an invite', async () => {
+    const result = await createController().acceptInvite({
+      userUuid: '1-2-3',
+      encryptedPrivateKey: 'foo',
+      inviteUuid: 'i-1-2-3',
+      publicKey: 'bar',
+    })
+
+    expect(result).toEqual({
+      data: {
+        success: true,
+      },
+      status: 200,
+    })
+  })
+
+  it('should not accept an invite if it fails', async () => {
+    doAcceptInvitation.execute = jest.fn().mockReturnValue({ success: false })
+    const result = await createController().acceptInvite({
+      userUuid: '1-2-3',
+      encryptedPrivateKey: 'foo',
+      inviteUuid: 'i-1-2-3',
+      publicKey: 'bar',
+    })
+
+    expect(result).toEqual({
+      data: {
+        error: {
+          message: 'Could not accept invite',
+        },
+      },
+      status: 400,
+    })
+  })
+
+  it('should list workspaces', async () => {
+    const result = await createController().listWorkspaces({
+      userUuid: '1-2-3',
+    })
+
+    expect(result).toEqual({
+      data: {
+        ownedWorkspaces: [{ foo: 'bar' }],
+        joinedWorkspaces: [{ foo: 'bar' }],
+      },
+      status: 200,
+    })
+  })
+
+  it('should list workspace users', async () => {
+    const result = await createController().listWorkspaceUsers({
+      userUuid: '1-2-3',
+      workspaceUuid: 'w-1-2-3',
+    })
+
+    expect(result).toEqual({
+      data: {
+        users: [{ bar: 'buzz' }, { bar: 'buzz' }],
+      },
+      status: 200,
+    })
+  })
 })

+ 85 - 0
packages/workspace/src/Controller/WorkspacesController.ts

@@ -6,18 +6,38 @@ import {
   WorkspaceInvitationRequestParams,
   WorkspaceInvitationResponse,
   WorkspaceServerInterface,
+  WorkspaceListRequestParams,
+  WorkspaceListResponse,
+  WorkspaceInvitationAcceptingRequestParams,
+  WorkspaceInvitationAcceptingResponse,
+  WorkspaceUserListRequestParams,
 } from '@standardnotes/api'
 import { Uuid, WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common'
 
 import TYPES from '../Bootstrap/Types'
 import { CreateWorkspace } from '../Domain/UseCase/CreateWorkspace/CreateWorkspace'
 import { InviteToWorkspace } from '../Domain/UseCase/InviteToWorkspace/InviteToWorkspace'
+import { ProjectorInterface } from '../Domain/Projection/ProjectorInterface'
+import { WorkspaceProjection } from '../Domain/Projection/WorkspaceProjection'
+import { Workspace } from '../Domain/Workspace/Workspace'
+import { ListWorkspaces } from '../Domain/UseCase/ListWorkspaces/ListWorkspaces'
+import { WorkspaceUserListResponse } from '@standardnotes/api/dist/Domain/Response/Workspace/WorkspaceUserListResponse'
+import { AcceptInvitation } from '../Domain/UseCase/AcceptInvitation/AcceptInvitation'
+import { WorkspaceUser } from '../Domain/Workspace/WorkspaceUser'
+import { WorkspaceUserProjection } from '../Domain/Projection/WorkspaceUserProjection'
+import { ListWorkspaceUsers } from '../Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers'
 
 @injectable()
 export class WorkspacesController implements WorkspaceServerInterface {
   constructor(
     @inject(TYPES.CreateWorkspace) private doCreateWorkspace: CreateWorkspace,
     @inject(TYPES.InviteToWorkspace) private doInviteToWorkspace: InviteToWorkspace,
+    @inject(TYPES.ListWorkspaces) private doListWorkspaces: ListWorkspaces,
+    @inject(TYPES.ListWorkspaceUsers) private doListWorkspaceUsers: ListWorkspaceUsers,
+    @inject(TYPES.AcceptInvitation) private doAcceptInvite: AcceptInvitation,
+    @inject(TYPES.WorkspaceProjector) private workspaceProjector: ProjectorInterface<Workspace, WorkspaceProjection>,
+    @inject(TYPES.WorkspaceUserProjector)
+    private workspaceUserProjector: ProjectorInterface<WorkspaceUser, WorkspaceUserProjection>,
   ) {}
 
   async inviteToWorkspace(params: WorkspaceInvitationRequestParams): Promise<WorkspaceInvitationResponse> {
@@ -49,4 +69,69 @@ export class WorkspacesController implements WorkspaceServerInterface {
       data: { uuid: workspace.uuid },
     }
   }
+
+  async listWorkspaces(params: WorkspaceListRequestParams): Promise<WorkspaceListResponse> {
+    const { ownedWorkspaces, joinedWorkspaces } = await this.doListWorkspaces.execute({
+      userUuid: params.userUuid as Uuid,
+    })
+
+    const ownedWorkspacesProjections = []
+    for (const ownedWorkspace of ownedWorkspaces) {
+      ownedWorkspacesProjections.push(await this.workspaceProjector.project(ownedWorkspace))
+    }
+
+    const joinedWorkspacesProjections = []
+    for (const joinedWorkspace of joinedWorkspaces) {
+      joinedWorkspacesProjections.push(await this.workspaceProjector.project(joinedWorkspace))
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: { ownedWorkspaces: ownedWorkspacesProjections, joinedWorkspaces: joinedWorkspacesProjections },
+    }
+  }
+
+  async listWorkspaceUsers(params: WorkspaceUserListRequestParams): Promise<WorkspaceUserListResponse> {
+    const { workspaceUsers } = await this.doListWorkspaceUsers.execute({
+      userUuid: params.userUuid as Uuid,
+      workspaceUuid: params.workspaceUuid,
+    })
+
+    const workspaceUserProjections = []
+    for (const workspaceUser of workspaceUsers) {
+      workspaceUserProjections.push(await this.workspaceUserProjector.project(workspaceUser))
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: { users: workspaceUserProjections },
+    }
+  }
+
+  async acceptInvite(params: WorkspaceInvitationAcceptingRequestParams): Promise<WorkspaceInvitationAcceptingResponse> {
+    const result = await this.doAcceptInvite.execute({
+      acceptingUserUuid: params.userUuid,
+      encryptedPrivateKey: params.encryptedPrivateKey,
+      invitationUuid: params.inviteUuid,
+      publicKey: params.publicKey,
+    })
+
+    if (!result.success) {
+      return {
+        status: HttpStatusCode.BadRequest,
+        data: {
+          error: {
+            message: 'Could not accept invite',
+          },
+        },
+      }
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: {
+        success: true,
+      },
+    }
+  }
 }

+ 3 - 0
packages/workspace/src/Domain/Projection/ProjectorInterface.ts

@@ -0,0 +1,3 @@
+export interface ProjectorInterface<T, E> {
+  project(object: T): Promise<E>
+}

+ 3 - 0
packages/workspace/src/Domain/Projection/WorkspaceProjection.ts

@@ -0,0 +1,3 @@
+import { Workspace } from '@standardnotes/models'
+
+export type WorkspaceProjection = Workspace

+ 30 - 0
packages/workspace/src/Domain/Projection/WorkspaceProjector.spec.ts

@@ -0,0 +1,30 @@
+import 'reflect-metadata'
+
+import { WorkspaceType } from '@standardnotes/common'
+import { Workspace } from '../Workspace/Workspace'
+
+import { WorkspaceProjector } from './WorkspaceProjector'
+
+describe('WorkspaceProjector', () => {
+  const createProjector = () => new WorkspaceProjector()
+
+  it('should project a workspace', async () => {
+    expect(
+      await createProjector().project({
+        uuid: 'w-1-2-3',
+        type: WorkspaceType.Private,
+        name: 'test',
+        keyRotationIndex: 0,
+        createdAt: 1,
+        updatedAt: 2,
+      } as jest.Mocked<Workspace>),
+    ).toEqual({
+      uuid: 'w-1-2-3',
+      type: 'private',
+      name: 'test',
+      keyRotationIndex: 0,
+      createdAt: 1,
+      updatedAt: 2,
+    })
+  })
+})

+ 19 - 0
packages/workspace/src/Domain/Projection/WorkspaceProjector.ts

@@ -0,0 +1,19 @@
+import { injectable } from 'inversify'
+import { ProjectorInterface } from './ProjectorInterface'
+
+import { WorkspaceProjection } from './WorkspaceProjection'
+import { Workspace } from '../Workspace/Workspace'
+
+@injectable()
+export class WorkspaceProjector implements ProjectorInterface<Workspace, WorkspaceProjection> {
+  async project(workspace: Workspace): Promise<WorkspaceProjection> {
+    return {
+      uuid: workspace.uuid,
+      type: workspace.type,
+      name: workspace.name,
+      keyRotationIndex: workspace.keyRotationIndex,
+      createdAt: workspace.createdAt,
+      updatedAt: workspace.updatedAt,
+    }
+  }
+}

+ 3 - 0
packages/workspace/src/Domain/Projection/WorkspaceUserProjection.ts

@@ -0,0 +1,3 @@
+import { WorkspaceUser } from '@standardnotes/models'
+
+export type WorkspaceUserProjection = WorkspaceUser

+ 42 - 0
packages/workspace/src/Domain/Projection/WorkspaceUserProjector.spec.ts

@@ -0,0 +1,42 @@
+import 'reflect-metadata'
+
+import { WorkspaceAccessLevel, WorkspaceUserStatus } from '@standardnotes/common'
+import { WorkspaceUser } from '../Workspace/WorkspaceUser'
+
+import { WorkspaceUserProjector } from './WorkspaceUserProjector'
+
+describe('WorkspaceUserProjector', () => {
+  const createProjector = () => new WorkspaceUserProjector()
+
+  it('should project a workspace user', async () => {
+    expect(
+      await createProjector().project({
+        uuid: '1-2-3',
+        accessLevel: WorkspaceAccessLevel.Owner,
+        userUuid: 'u-1-2-3',
+        userDisplayName: 'foobar',
+        workspaceUuid: 'w-1-2-3',
+        encryptedWorkspaceKey: 'foo',
+        publicKey: 'bar',
+        encryptedPrivateKey: 'buzz',
+        status: WorkspaceUserStatus.PendingKeyshare,
+        keyRotationIndex: 0,
+        createdAt: 1,
+        updatedAt: 2,
+      } as jest.Mocked<WorkspaceUser>),
+    ).toEqual({
+      uuid: '1-2-3',
+      accessLevel: 'owner',
+      userUuid: 'u-1-2-3',
+      userDisplayName: 'foobar',
+      workspaceUuid: 'w-1-2-3',
+      encryptedWorkspaceKey: 'foo',
+      publicKey: 'bar',
+      encryptedPrivateKey: 'buzz',
+      status: 'pending-keyshare',
+      keyRotationIndex: 0,
+      createdAt: 1,
+      updatedAt: 2,
+    })
+  })
+})

+ 25 - 0
packages/workspace/src/Domain/Projection/WorkspaceUserProjector.ts

@@ -0,0 +1,25 @@
+import { injectable } from 'inversify'
+import { ProjectorInterface } from './ProjectorInterface'
+
+import { WorkspaceUserProjection } from './WorkspaceUserProjection'
+import { WorkspaceUser } from '../Workspace/WorkspaceUser'
+
+@injectable()
+export class WorkspaceUserProjector implements ProjectorInterface<WorkspaceUser, WorkspaceUserProjection> {
+  async project(workspaceUser: WorkspaceUser): Promise<WorkspaceUserProjection> {
+    return {
+      uuid: workspaceUser.uuid,
+      accessLevel: workspaceUser.accessLevel,
+      userUuid: workspaceUser.userUuid,
+      userDisplayName: workspaceUser.userDisplayName,
+      workspaceUuid: workspaceUser.workspaceUuid,
+      encryptedWorkspaceKey: workspaceUser.encryptedWorkspaceKey,
+      publicKey: workspaceUser.publicKey,
+      encryptedPrivateKey: workspaceUser.encryptedPrivateKey,
+      status: workspaceUser.status,
+      keyRotationIndex: workspaceUser.keyRotationIndex,
+      createdAt: workspaceUser.createdAt,
+      updatedAt: workspaceUser.updatedAt,
+    }
+  }
+}

+ 84 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers.spec.ts

@@ -0,0 +1,84 @@
+import { WorkspaceAccessLevel } from '@standardnotes/common'
+import 'reflect-metadata'
+import { Workspace } from '../../Workspace/Workspace'
+import { WorkspaceRepositoryInterface } from '../../Workspace/WorkspaceRepositoryInterface'
+import { WorkspaceUser } from '../../Workspace/WorkspaceUser'
+import { WorkspaceUserRepositoryInterface } from '../../Workspace/WorkspaceUserRepositoryInterface'
+
+import { ListWorkspaceUsers } from './ListWorkspaceUsers'
+
+describe('ListWorkspaceUsers', () => {
+  let workspaceRepository: WorkspaceRepositoryInterface
+  let workspaceUserRepository: WorkspaceUserRepositoryInterface
+  let workspace: Workspace
+  let workspaceUser1: WorkspaceUser
+  let workspaceUser2: WorkspaceUser
+
+  const createUseCase = () => new ListWorkspaceUsers(workspaceRepository, workspaceUserRepository)
+
+  beforeEach(() => {
+    workspace = { uuid: 'j-1-2-3' } as jest.Mocked<Workspace>
+
+    workspaceUser1 = { userUuid: 'u-1-2-3', accessLevel: WorkspaceAccessLevel.Owner } as jest.Mocked<WorkspaceUser>
+    workspaceUser2 = {
+      userUuid: 'u-2-3-4',
+      accessLevel: WorkspaceAccessLevel.WriteAndRead,
+    } as jest.Mocked<WorkspaceUser>
+
+    workspaceRepository = {} as jest.Mocked<WorkspaceRepositoryInterface>
+    workspaceRepository.findOneByUuid = jest.fn().mockReturnValue(workspace)
+
+    workspaceUserRepository = {} as jest.Mocked<WorkspaceUserRepositoryInterface>
+    workspaceUserRepository.findByWorkspaceUuid = jest.fn().mockReturnValue([workspaceUser1, workspaceUser2])
+  })
+
+  it('should list users in a workspace where the user is owner or admin', async () => {
+    const result = await createUseCase().execute({
+      userUuid: 'u-1-2-3',
+      workspaceUuid: 'j-1-2-3',
+    })
+
+    expect(result).toEqual({
+      workspaceUsers: [workspaceUser1, workspaceUser2],
+      userIsOwnerOrAdmin: true,
+    })
+  })
+
+  it('should list users in a workspace where the user is not the owner or admin with indiciation', async () => {
+    const result = await createUseCase().execute({
+      userUuid: 'u-2-3-4',
+      workspaceUuid: 'j-1-2-3',
+    })
+
+    expect(result).toEqual({
+      workspaceUsers: [workspaceUser1, workspaceUser2],
+      userIsOwnerOrAdmin: false,
+    })
+  })
+
+  it('should not list users in a workspace where the user does not belong', async () => {
+    const result = await createUseCase().execute({
+      userUuid: 'z-1-2-3',
+      workspaceUuid: 'j-1-2-3',
+    })
+
+    expect(result).toEqual({
+      workspaceUsers: [],
+      userIsOwnerOrAdmin: false,
+    })
+  })
+
+  it('should not list users in a workspace that does not exist', async () => {
+    workspaceRepository.findOneByUuid = jest.fn().mockReturnValue(null)
+
+    const result = await createUseCase().execute({
+      userUuid: 'u-1-2-3',
+      workspaceUuid: 'j-1-2-3',
+    })
+
+    expect(result).toEqual({
+      workspaceUsers: [],
+      userIsOwnerOrAdmin: false,
+    })
+  })
+})

+ 50 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsers.ts

@@ -0,0 +1,50 @@
+import { WorkspaceAccessLevel } from '@standardnotes/common'
+import { inject, injectable } from 'inversify'
+import TYPES from '../../../Bootstrap/Types'
+import { WorkspaceRepositoryInterface } from '../../Workspace/WorkspaceRepositoryInterface'
+import { WorkspaceUserRepositoryInterface } from '../../Workspace/WorkspaceUserRepositoryInterface'
+import { UseCaseInterface } from '../UseCaseInterface'
+import { ListWorkspaceUsersDTO } from './ListWorkspaceUsersDTO'
+import { ListWorkspaceUsersResponse } from './ListWorkspaceUsersResponse'
+
+@injectable()
+export class ListWorkspaceUsers implements UseCaseInterface {
+  constructor(
+    @inject(TYPES.WorkspaceRepository) private workspaceRepository: WorkspaceRepositoryInterface,
+    @inject(TYPES.WorkspaceUserRepository) private workspaceUserRepository: WorkspaceUserRepositoryInterface,
+  ) {}
+
+  async execute(dto: ListWorkspaceUsersDTO): Promise<ListWorkspaceUsersResponse> {
+    const workspace = await this.workspaceRepository.findOneByUuid(dto.workspaceUuid)
+    if (workspace === null) {
+      return {
+        workspaceUsers: [],
+        userIsOwnerOrAdmin: false,
+      }
+    }
+
+    const workspaceUsers = await this.workspaceUserRepository.findByWorkspaceUuid(dto.workspaceUuid)
+    let userIsOwnerOrAdmin = false
+    let userIsInWorkspace = false
+    for (const workspaceUser of workspaceUsers) {
+      if (workspaceUser.userUuid === dto.userUuid) {
+        userIsInWorkspace = true
+        if ([WorkspaceAccessLevel.Admin, WorkspaceAccessLevel.Owner].includes(workspaceUser.accessLevel)) {
+          userIsOwnerOrAdmin = true
+        }
+      }
+    }
+
+    if (!userIsInWorkspace) {
+      return {
+        workspaceUsers: [],
+        userIsOwnerOrAdmin: false,
+      }
+    }
+
+    return {
+      workspaceUsers,
+      userIsOwnerOrAdmin,
+    }
+  }
+}

+ 6 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsersDTO.ts

@@ -0,0 +1,6 @@
+import { Uuid } from '@standardnotes/common'
+
+export type ListWorkspaceUsersDTO = {
+  workspaceUuid: Uuid
+  userUuid: Uuid
+}

+ 6 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaceUsers/ListWorkspaceUsersResponse.ts

@@ -0,0 +1,6 @@
+import { WorkspaceUser } from '../../Workspace/WorkspaceUser'
+
+export type ListWorkspaceUsersResponse = {
+  workspaceUsers: WorkspaceUser[]
+  userIsOwnerOrAdmin: boolean
+}

+ 47 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspaces.spec.ts

@@ -0,0 +1,47 @@
+import { WorkspaceAccessLevel } from '@standardnotes/common'
+import 'reflect-metadata'
+import { Workspace } from '../../Workspace/Workspace'
+import { WorkspaceRepositoryInterface } from '../../Workspace/WorkspaceRepositoryInterface'
+import { WorkspaceUser } from '../../Workspace/WorkspaceUser'
+import { WorkspaceUserRepositoryInterface } from '../../Workspace/WorkspaceUserRepositoryInterface'
+
+import { ListWorkspaces } from './ListWorkspaces'
+
+describe('ListWorkspaces', () => {
+  let workspaceRepository: WorkspaceRepositoryInterface
+  let workspaceUserRepository: WorkspaceUserRepositoryInterface
+  let ownedWorkspace: Workspace
+  let joinedWorkspace: Workspace
+  let workspaceUser1: WorkspaceUser
+  let workspaceUser2: WorkspaceUser
+
+  const createUseCase = () => new ListWorkspaces(workspaceRepository, workspaceUserRepository)
+
+  beforeEach(() => {
+    ownedWorkspace = { uuid: 'o-1-2-3' } as jest.Mocked<Workspace>
+    joinedWorkspace = { uuid: 'j-1-2-3' } as jest.Mocked<Workspace>
+
+    workspaceUser1 = { accessLevel: WorkspaceAccessLevel.Owner } as jest.Mocked<WorkspaceUser>
+    workspaceUser2 = { accessLevel: WorkspaceAccessLevel.WriteAndRead } as jest.Mocked<WorkspaceUser>
+
+    workspaceRepository = {} as jest.Mocked<WorkspaceRepositoryInterface>
+    workspaceRepository.findByUuids = jest
+      .fn()
+      .mockReturnValueOnce([ownedWorkspace])
+      .mockReturnValueOnce([joinedWorkspace])
+
+    workspaceUserRepository = {} as jest.Mocked<WorkspaceUserRepositoryInterface>
+    workspaceUserRepository.findByUserUuid = jest.fn().mockReturnValue([workspaceUser1, workspaceUser2])
+  })
+
+  it('should list owned and joined workspaces for a user', async () => {
+    const result = await createUseCase().execute({
+      userUuid: 'u-1-2-3',
+    })
+
+    expect(result).toEqual({
+      ownedWorkspaces: [ownedWorkspace],
+      joinedWorkspaces: [joinedWorkspace],
+    })
+  })
+})

+ 40 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspaces.ts

@@ -0,0 +1,40 @@
+import { WorkspaceAccessLevel } from '@standardnotes/common'
+import { inject, injectable } from 'inversify'
+
+import TYPES from '../../../Bootstrap/Types'
+import { WorkspaceRepositoryInterface } from '../../Workspace/WorkspaceRepositoryInterface'
+import { WorkspaceUserRepositoryInterface } from '../../Workspace/WorkspaceUserRepositoryInterface'
+import { UseCaseInterface } from '../UseCaseInterface'
+
+import { ListWorkspacesDTO } from './ListWorkspacesDTO'
+import { ListWorkspacesResponse } from './ListWorkspacesResponse'
+
+@injectable()
+export class ListWorkspaces implements UseCaseInterface {
+  constructor(
+    @inject(TYPES.WorkspaceRepository) private workspaceRepository: WorkspaceRepositoryInterface,
+    @inject(TYPES.WorkspaceUserRepository) private workspaceUserRepository: WorkspaceUserRepositoryInterface,
+  ) {}
+
+  async execute(dto: ListWorkspacesDTO): Promise<ListWorkspacesResponse> {
+    const workspaceAssociations = await this.workspaceUserRepository.findByUserUuid(dto.userUuid)
+
+    const ownedWorkspacesUuids = []
+    const joinedWorkspacesUuids = []
+    for (const workspaceAssociation of workspaceAssociations) {
+      if ([WorkspaceAccessLevel.Admin, WorkspaceAccessLevel.Owner].includes(workspaceAssociation.accessLevel)) {
+        ownedWorkspacesUuids.push(workspaceAssociation.uuid)
+      } else {
+        joinedWorkspacesUuids.push(workspaceAssociation.uuid)
+      }
+    }
+
+    const ownedWorkspaces = await this.workspaceRepository.findByUuids(ownedWorkspacesUuids)
+    const joinedWorkspaces = await this.workspaceRepository.findByUuids(joinedWorkspacesUuids)
+
+    return {
+      ownedWorkspaces,
+      joinedWorkspaces,
+    }
+  }
+}

+ 5 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspacesDTO.ts

@@ -0,0 +1,5 @@
+import { Uuid } from '@standardnotes/common'
+
+export type ListWorkspacesDTO = {
+  userUuid: Uuid
+}

+ 6 - 0
packages/workspace/src/Domain/UseCase/ListWorkspaces/ListWorkspacesResponse.ts

@@ -0,0 +1,6 @@
+import { Workspace } from '../../Workspace/Workspace'
+
+export type ListWorkspacesResponse = {
+  ownedWorkspaces: Array<Workspace>
+  joinedWorkspaces: Array<Workspace>
+}

+ 3 - 0
packages/workspace/src/Domain/Workspace/WorkspaceRepositoryInterface.ts

@@ -1,5 +1,8 @@
+import { Uuid } from '@standardnotes/common'
 import { Workspace } from './Workspace'
 
 export interface WorkspaceRepositoryInterface {
   save(workspace: Workspace): Promise<Workspace>
+  findByUuids(uuids: Uuid[]): Promise<Workspace[]>
+  findOneByUuid(uuid: Uuid): Promise<Workspace | null>
 }

+ 3 - 0
packages/workspace/src/Domain/Workspace/WorkspaceUserRepositoryInterface.ts

@@ -1,5 +1,8 @@
+import { Uuid } from '@standardnotes/common'
 import { WorkspaceUser } from './WorkspaceUser'
 
 export interface WorkspaceUserRepositoryInterface {
   save(workspace: WorkspaceUser): Promise<WorkspaceUser>
+  findByUserUuid(userUuid: Uuid): Promise<WorkspaceUser[]>
+  findByWorkspaceUuid(workspaceUuid: Uuid): Promise<WorkspaceUser[]>
 }

+ 23 - 0
packages/workspace/src/Infra/InversifyExpressUtils/InversifyExpressInvitesController.ts

@@ -0,0 +1,23 @@
+import { Request, Response } from 'express'
+import { inject } from 'inversify'
+import { BaseHttpController, controller, httpPost, results } from 'inversify-express-utils'
+import TYPES from '../../Bootstrap/Types'
+import { WorkspacesController } from '../../Controller/WorkspacesController'
+
+@controller('/invites', TYPES.ApiGatewayAuthMiddleware)
+export class InversifyExpressInvitesController extends BaseHttpController {
+  constructor(@inject(TYPES.WorkspacesController) private workspacesController: WorkspacesController) {
+    super()
+  }
+
+  @httpPost('/:inviteUuid/accept')
+  async acceptInvite(request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.workspacesController.acceptInvite({
+      ...request.body,
+      inviteUuid: request.params.inviteUuid,
+      userUuid: response.locals.user.uuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+}

+ 20 - 1
packages/workspace/src/Infra/InversifyExpressUtils/InversifyExpressWorkspacesController.ts

@@ -1,6 +1,6 @@
 import { Request, Response } from 'express'
 import { inject } from 'inversify'
-import { BaseHttpController, controller, httpPost, results } from 'inversify-express-utils'
+import { BaseHttpController, controller, httpGet, httpPost, results } from 'inversify-express-utils'
 import TYPES from '../../Bootstrap/Types'
 import { WorkspacesController } from '../../Controller/WorkspacesController'
 
@@ -20,6 +20,25 @@ export class InversifyExpressWorkspacesController extends BaseHttpController {
     return this.json(result.data, result.status)
   }
 
+  @httpGet('/')
+  async listWorkspaces(response: Response): Promise<results.JsonResult> {
+    const result = await this.workspacesController.listWorkspaces({
+      userUuid: response.locals.user.uuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
+  @httpGet('/:workspaceUuid/users')
+  async listWorkspaceUsers(request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.workspacesController.listWorkspaceUsers({
+      userUuid: response.locals.user.uuid,
+      workspaceUuid: request.params.workspaceUuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
   @httpPost('/:workspaceUuid/invites')
   async inviteToWorkspace(request: Request, response: Response): Promise<results.JsonResult> {
     if (request.params.workspaceUuid !== request.body.workspaceUuid) {

+ 18 - 0
packages/workspace/src/Infra/MySQL/MySQLWorkspaceRepository.spec.ts

@@ -28,4 +28,22 @@ describe('MySQLWorkspaceRepository', () => {
 
     expect(ormRepository.save).toHaveBeenCalledWith(workspace)
   })
+
+  it('should find many by uuids', async () => {
+    queryBuilder.where = jest.fn().mockReturnThis()
+    queryBuilder.getMany = jest.fn().mockReturnValue([])
+
+    await createRepository().findByUuids(['i-1-2-3'])
+
+    expect(queryBuilder.where).toHaveBeenCalledWith('uuid IN (:...uuids)', { uuids: ['i-1-2-3'] })
+  })
+
+  it('should find one by uuid', async () => {
+    queryBuilder.where = jest.fn().mockReturnThis()
+    queryBuilder.getOne = jest.fn().mockReturnValue(null)
+
+    await createRepository().findOneByUuid('i-1-2-3')
+
+    expect(queryBuilder.where).toHaveBeenCalledWith('uuid = :uuid', { uuid: 'i-1-2-3' })
+  })
 })

+ 8 - 0
packages/workspace/src/Infra/MySQL/MySQLWorkspaceRepository.ts

@@ -11,6 +11,14 @@ export class MySQLWorkspaceRepository implements WorkspaceRepositoryInterface {
     private ormRepository: Repository<Workspace>,
   ) {}
 
+  async findOneByUuid(uuid: string): Promise<Workspace | null> {
+    return this.ormRepository.createQueryBuilder().where('uuid = :uuid', { uuid }).getOne()
+  }
+
+  async findByUuids(uuids: string[]): Promise<Workspace[]> {
+    return this.ormRepository.createQueryBuilder().where('uuid IN (:...uuids)', { uuids }).getMany()
+  }
+
   async save(workspace: Workspace): Promise<Workspace> {
     return this.ormRepository.save(workspace)
   }

+ 18 - 0
packages/workspace/src/Infra/MySQL/MySQLWorkspaceUserRepository.spec.ts

@@ -27,4 +27,22 @@ describe('MySQLWorkspaceUserRepository', () => {
 
     expect(ormRepository.save).toHaveBeenCalledWith(workspace)
   })
+
+  it('should find many by user uuid', async () => {
+    queryBuilder.where = jest.fn().mockReturnThis()
+    queryBuilder.getMany = jest.fn().mockReturnValue([])
+
+    await createRepository().findByUserUuid('i-1-2-3')
+
+    expect(queryBuilder.where).toHaveBeenCalledWith('user_uuid = :userUuid', { userUuid: 'i-1-2-3' })
+  })
+
+  it('should find many by workspace uuid', async () => {
+    queryBuilder.where = jest.fn().mockReturnThis()
+    queryBuilder.getMany = jest.fn().mockReturnValue([])
+
+    await createRepository().findByWorkspaceUuid('i-1-2-3')
+
+    expect(queryBuilder.where).toHaveBeenCalledWith('workspace_uuid = :workspaceUuid', { workspaceUuid: 'i-1-2-3' })
+  })
 })

+ 8 - 0
packages/workspace/src/Infra/MySQL/MySQLWorkspaceUserRepository.ts

@@ -12,6 +12,14 @@ export class MySQLWorkspaceUserRepository implements WorkspaceUserRepositoryInte
     private ormRepository: Repository<WorkspaceUser>,
   ) {}
 
+  async findByWorkspaceUuid(workspaceUuid: string): Promise<WorkspaceUser[]> {
+    return this.ormRepository.createQueryBuilder().where('workspace_uuid = :workspaceUuid', { workspaceUuid }).getMany()
+  }
+
+  async findByUserUuid(userUuid: string): Promise<WorkspaceUser[]> {
+    return this.ormRepository.createQueryBuilder().where('user_uuid = :userUuid', { userUuid }).getMany()
+  }
+
   async save(workspaceUser: WorkspaceUser): Promise<WorkspaceUser> {
     return this.ormRepository.save(workspaceUser)
   }

+ 55 - 45
yarn.lock

@@ -1824,18 +1824,18 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/api@npm:^1.12.1":
-  version: 1.12.1
-  resolution: "@standardnotes/api@npm:1.12.1"
-  dependencies:
-    "@standardnotes/common": ^1.36.1
-    "@standardnotes/encryption": 1.16.2
-    "@standardnotes/models": 1.24.2
-    "@standardnotes/responses": 1.10.6
+"@standardnotes/api@npm:^1.15.0":
+  version: 1.15.0
+  resolution: "@standardnotes/api@npm:1.15.0"
+  dependencies:
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/encryption": 1.17.0
+    "@standardnotes/models": 1.26.0
+    "@standardnotes/responses": 1.11.0
     "@standardnotes/security": ^1.1.0
-    "@standardnotes/utils": 1.9.1
+    "@standardnotes/utils": 1.10.0
     reflect-metadata: ^0.1.13
-  checksum: 8623fc82de0cbe6793691bc50bf168d1ab2535516f71ffc10ac642abe6ab9ac2faef6cfe406350c2a1b6ea31e0ad34ad29cab804721a49500f6a1d3498cdd46e
+  checksum: 88ae0a340e175b8251e4960f377998a9e21ec5c7967be85c813ff2103d281faea8b13faf787ac51dab4d1737521df136748c9dbd34f0eee2f61d860085bca840
   languageName: node
   linkType: hard
 
@@ -1846,7 +1846,7 @@ __metadata:
     "@newrelic/winston-enricher": ^4.0.0
     "@sentry/node": ^7.3.0
     "@standardnotes/analytics": "workspace:*"
-    "@standardnotes/api": ^1.12.1
+    "@standardnotes/api": ^1.15.0
     "@standardnotes/common": "workspace:*"
     "@standardnotes/domain-events": "workspace:*"
     "@standardnotes/domain-events-infra": "workspace:*"
@@ -1907,7 +1907,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@standardnotes/common@^1.19.1, @standardnotes/common@^1.23.1, @standardnotes/common@^1.32.0, @standardnotes/common@^1.36.1, @standardnotes/common@workspace:*, @standardnotes/common@workspace:^, @standardnotes/common@workspace:packages/common":
+"@standardnotes/common@^1.19.1, @standardnotes/common@^1.23.1, @standardnotes/common@^1.32.0, @standardnotes/common@workspace:*, @standardnotes/common@workspace:^, @standardnotes/common@workspace:packages/common":
   version: 0.0.0-use.local
   resolution: "@standardnotes/common@workspace:packages/common"
   dependencies:
@@ -1921,6 +1921,15 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@standardnotes/common@npm:^1.39.0":
+  version: 1.39.0
+  resolution: "@standardnotes/common@npm:1.39.0"
+  dependencies:
+    reflect-metadata: ^0.1.13
+  checksum: 92cfad04a631cdcf971516be4bf0e066e9cb6e894017102ac406cd8964282e088653ebed282d7624f371d617418edce124d12a62964723697ed7fa33b6d6e4d0
+  languageName: node
+  linkType: hard
+
 "@standardnotes/config@npm:2.4.3":
   version: 2.4.3
   resolution: "@standardnotes/config@npm:2.4.3"
@@ -1972,17 +1981,17 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/encryption@npm:1.16.2":
-  version: 1.16.2
-  resolution: "@standardnotes/encryption@npm:1.16.2"
+"@standardnotes/encryption@npm:1.17.0":
+  version: 1.17.0
+  resolution: "@standardnotes/encryption@npm:1.17.0"
   dependencies:
-    "@standardnotes/common": ^1.36.1
-    "@standardnotes/models": 1.24.2
-    "@standardnotes/responses": 1.10.6
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/models": 1.26.0
+    "@standardnotes/responses": 1.11.0
     "@standardnotes/sncrypto-common": 1.13.0
-    "@standardnotes/utils": 1.9.1
+    "@standardnotes/utils": 1.10.0
     reflect-metadata: ^0.1.13
-  checksum: 50efc1b20105b06be2325d17440a0952e3fd47a596f99ea937446e6da895c349cb5ae449398c390829c2379c03a7cfa160e7f6b2d9bf3339ccb53fc34fa81c86
+  checksum: 587516dfed87ba0bc46fef9ddcc6edb42757c140f852096d6bf6f7911418a7aa1eb0b370c5a0c56eeab3e6b13117ec055374b6e023c64e67cdf2362a584ac06e
   languageName: node
   linkType: hard
 
@@ -2014,15 +2023,15 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/features@npm:1.52.4":
-  version: 1.52.4
-  resolution: "@standardnotes/features@npm:1.52.4"
+"@standardnotes/features@npm:1.53.0":
+  version: 1.53.0
+  resolution: "@standardnotes/features@npm:1.53.0"
   dependencies:
     "@standardnotes/auth": ^3.19.4
-    "@standardnotes/common": ^1.36.1
+    "@standardnotes/common": ^1.39.0
     "@standardnotes/security": ^1.2.0
     reflect-metadata: ^0.1.13
-  checksum: aea7b486275e9485c3d87b3db334c2b955f3ddd160054282e769aa59f026f6daffcb15edd8a3a4959a861552294df54437edb8d5b636f4e4f1c59eb74e75424b
+  checksum: a856e815a313d42706836b1e4c8bbaeb987ee86f2e53bf4b928ed401de483ab11ecd31a8bcb13b23cb7b6a53f72a9f481da2acf68d5e0e970af4ce8c8f2e7c65
   languageName: node
   linkType: hard
 
@@ -2099,17 +2108,17 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/models@npm:1.24.2":
-  version: 1.24.2
-  resolution: "@standardnotes/models@npm:1.24.2"
+"@standardnotes/models@npm:1.26.0, @standardnotes/models@npm:^1.26.0":
+  version: 1.26.0
+  resolution: "@standardnotes/models@npm:1.26.0"
   dependencies:
-    "@standardnotes/common": ^1.36.1
-    "@standardnotes/features": 1.52.4
-    "@standardnotes/responses": 1.10.6
-    "@standardnotes/utils": 1.9.1
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/features": 1.53.0
+    "@standardnotes/responses": 1.11.0
+    "@standardnotes/utils": 1.10.0
     lodash: ^4.17.21
     reflect-metadata: ^0.1.13
-  checksum: 17b3cfba39c97f9e7b1960fe5cc0e6edd0ac0fbc492e266d7814972a6619465f8fb7ca6c90ac082de9592c83e2bbc2d883d9eaa076d4b283d93092e779a1af5c
+  checksum: f595a3de88ca815d04190ff188c1355ac1848369f94b81f7fc7be3a03918eaaa964eb078bb77622efd1ba7e2db1a47595db94e3ecc90c3544a54838a3980d6b0
   languageName: node
   linkType: hard
 
@@ -2138,15 +2147,15 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/responses@npm:1.10.6":
-  version: 1.10.6
-  resolution: "@standardnotes/responses@npm:1.10.6"
+"@standardnotes/responses@npm:1.11.0":
+  version: 1.11.0
+  resolution: "@standardnotes/responses@npm:1.11.0"
   dependencies:
-    "@standardnotes/common": ^1.36.1
-    "@standardnotes/features": 1.52.4
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/features": 1.53.0
     "@standardnotes/security": ^1.1.0
     reflect-metadata: ^0.1.13
-  checksum: 0583e2cb77a23c22c7aa90e912c87b41fc8b8d236256cfa672e2a1272138974da0ebbbfa284049860adfed818e9c45a7f8ba169ff137b6b5c46a32a689320e6a
+  checksum: 46d6a479806507b15dc6f949e8863915f2cac81964aac715aab0bfc063e7bd7961767fcc6488e08397a0e1a52ccf2808429d8660b98a79415e25f601719125dc
   languageName: node
   linkType: hard
 
@@ -2349,15 +2358,15 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/utils@npm:1.9.1":
-  version: 1.9.1
-  resolution: "@standardnotes/utils@npm:1.9.1"
+"@standardnotes/utils@npm:1.10.0":
+  version: 1.10.0
+  resolution: "@standardnotes/utils@npm:1.10.0"
   dependencies:
-    "@standardnotes/common": ^1.36.1
+    "@standardnotes/common": ^1.39.0
     dompurify: ^2.3.8
     lodash: ^4.17.21
     reflect-metadata: ^0.1.13
-  checksum: f775bb37447300bef1c02d911f7d132538bf52d8b65573129def50b0545b1a69af785b1d09932734ecc14a37e07cf1913e5e7b25866249a059bcea9fa7e9b18c
+  checksum: c02d54ca8a4debb62ecf6642825e1c313b3aad11767b1f0e6d36a976ec3058eba8592ab3194544042825296357676bc821e168489b638b0514bd1650dc9d0e0d
   languageName: node
   linkType: hard
 
@@ -2378,10 +2387,11 @@ __metadata:
   dependencies:
     "@newrelic/winston-enricher": ^4.0.0
     "@sentry/node": ^7.3.0
-    "@standardnotes/api": ^1.12.1
+    "@standardnotes/api": ^1.15.0
     "@standardnotes/common": "workspace:*"
     "@standardnotes/domain-events": "workspace:^"
     "@standardnotes/domain-events-infra": "workspace:^"
+    "@standardnotes/models": ^1.26.0
     "@standardnotes/security": "workspace:*"
     "@standardnotes/time": "workspace:^"
     "@types/cors": ^2.8.9