@ossy/workspaces 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/workspaces",
3
3
  "description": "Workspaces feature package - create, select, and manage workspaces, users, and invitations",
4
- "version": "3.11.0",
4
+ "version": "3.12.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -35,6 +35,7 @@
35
35
  "@ossy/design-system": ">=1.0.0",
36
36
  "@ossy/router-react": ">=1.0.0",
37
37
  "@ossy/sdk-react": ">=1.0.0",
38
+ "@ossy/users": ">=1.0.0",
38
39
  "react": ">=19.0.0 <20.0.0"
39
40
  },
40
41
  "devDependencies": {
@@ -50,5 +51,5 @@
50
51
  "/src",
51
52
  "README.md"
52
53
  ],
53
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
54
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
54
55
  }
@@ -1,9 +1,11 @@
1
- import React, { useState } from 'react'
1
+ import React, { useEffect, useState } from 'react'
2
2
  import { GetWorkspace, EnableService, DisableService } from '@ossy/workspaces'
3
3
  import { GetCurrentUser } from '@ossy/authentication'
4
4
  import { useSdk, AsyncStatus, cacheKey } from '@ossy/sdk-react'
5
5
  import { Switch, Button, View, Text, ContentHeader, useLocale } from '@ossy/design-system'
6
6
 
7
+ const GET_WORKSPACE_KPIS = { id: '@ossy/analytics/actions/get-workspace-kpis' }
8
+
7
9
  const formatDate = (timestamp) => timestamp ? new Date(timestamp).toLocaleDateString() : ''
8
10
  const FlowStage = { View: 'View', Edit: 'Edit' }
9
11
 
@@ -21,8 +23,30 @@ export const GeneralSettings = () => {
21
23
  const { status: userStatus } = sdk.read(GetCurrentUser)
22
24
  const [flowStage, setFlowStage] = useState(FlowStage.View)
23
25
  const [busyPackage, setBusyPackage] = useState(null)
26
+ const [kpis, setKpis] = useState({ status: 'idle', members: 0, resources: 0 })
24
27
  const services = workspace?.services || {}
25
28
 
29
+ useEffect(() => {
30
+ if (!workspace?.id) return undefined
31
+ let cancelled = false
32
+ sdk.invoke(GET_WORKSPACE_KPIS, {})
33
+ .then((json) => {
34
+ if (cancelled) return
35
+ setKpis({
36
+ status: 'success',
37
+ members: json?.members || 0,
38
+ resources: json?.resources || 0,
39
+ })
40
+ })
41
+ .catch(() => {
42
+ if (cancelled) return
43
+ setKpis((current) => ({ ...current, status: 'error' }))
44
+ })
45
+ return () => {
46
+ cancelled = true
47
+ }
48
+ }, [sdk, workspace?.id])
49
+
26
50
  const toggleService = async (packageName, enable) => {
27
51
  setBusyPackage(packageName)
28
52
  try {
@@ -61,6 +85,12 @@ export const GeneralSettings = () => {
61
85
  >
62
86
  <View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.name" /><Text>{workspace?.name}</Text></View>
63
87
  <View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.created" /><Text>{formatDate(workspace?.created)}</Text></View>
88
+ {kpis.status === 'success' && (
89
+ <>
90
+ <View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.members" /><Text>{kpis.members}</Text></View>
91
+ <View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.resources" /><Text>{kpis.resources}</Text></View>
92
+ </>
93
+ )}
64
94
  {MANAGED_SERVICES.length > 0 && (
65
95
  <>
66
96
  <Text variant="heading-tertiary" as="h3" text="workspace.settings.services" />
@@ -78,6 +78,8 @@
78
78
  "workspace.settings.service.enable": "Enable",
79
79
  "workspace.settings.service.disable": "Disable",
80
80
  "workspace.settings.loading": "Loading...",
81
+ "workspace.settings.members": "Members",
82
+ "workspace.settings.resources": "Resources",
81
83
  "workspace.invitations.description": "Invite users to your workspace to collaborate.",
82
84
  "workspace.invitations.invite": "Invite user",
83
85
  "workspace.invitations.empty": "No invitations",
@@ -1,4 +1,5 @@
1
1
  import signUpFlow from '@ossy/authentication/sign-up.flow.js'
2
+ import { CompleteUserName, CompleteUserNameForm } from '@ossy/users'
2
3
  import { InviteUser } from '@ossy/workspaces'
3
4
  import { metadata as InviteUserForm } from './invite-user.form.js'
4
5
 
@@ -10,12 +11,13 @@ export const metadata = {
10
11
  }
11
12
 
12
13
  /**
13
- * Happy path for issue #5 — invite a teammate, then accept via invitation email.
14
+ * Invite a teammate, accept via email, then complete the signed-in name gate
15
+ * (issue #419). Invitees are created email-only; product UI prompts for name.
14
16
  */
15
17
  export default {
16
18
  title: 'Invite and accept workspace invitation',
17
19
  description:
18
- 'Signed-in user invites another email, asserts pending invite, invitee accepts via email and lands on workspaces',
20
+ 'Signed-in user invites another email, invitee accepts, sets name on the product gate, and sees that name on profile',
19
21
  steps: [
20
22
  ...signUpFlow.steps,
21
23
  // Keep the inviter address before the invite form overwrites `$email`.
@@ -36,5 +38,28 @@ export default {
36
38
  },
37
39
  { result: { selector: '[data-flow-stage="invite-verify-success"]', timeout: 15000 } },
38
40
  { result: { page: '@profile/workspaces', timeout: 20000 } },
41
+ { result: { selector: '[data-flow-stage="complete-user-name"]', timeout: 15000 } },
42
+ {
43
+ form: CompleteUserNameForm,
44
+ fields: {
45
+ firstName: 'Invitee',
46
+ lastName: 'Named',
47
+ },
48
+ },
49
+ { action: CompleteUserName },
50
+ {
51
+ result: {
52
+ selector: '[data-flow-stage="complete-user-name"]',
53
+ hidden: true,
54
+ timeout: 15000,
55
+ },
56
+ },
57
+ { page: '@profile' },
58
+ {
59
+ result: {
60
+ selector: '[data-user-name="Invitee Named"]',
61
+ timeout: 15000,
62
+ },
63
+ },
39
64
  ],
40
65
  }
@@ -78,6 +78,8 @@
78
78
  "workspace.settings.service.enable": "Aktivera",
79
79
  "workspace.settings.service.disable": "Inaktivera",
80
80
  "workspace.settings.loading": "Laddar...",
81
+ "workspace.settings.members": "Medlemmar",
82
+ "workspace.settings.resources": "Resurser",
81
83
  "workspace.invitations.description": "Bjud in användare till ditt workspace för att samarbeta.",
82
84
  "workspace.invitations.invite": "Bjud in användare",
83
85
  "workspace.invitations.empty": "Inga inbjudningar",
@@ -4,42 +4,78 @@ import { WorkspaceSchema } from './schema-ids.js'
4
4
 
5
5
  const log = createLogger('workspaces')
6
6
 
7
+ /**
8
+ * Membership events that mean the user belongs to a workspace.
9
+ * Kept narrow so the list query never scans every workspace stream.
10
+ */
11
+ const MEMBERSHIP_EVENTS = ['Created', 'UserInvitationAccepted']
12
+
7
13
  export class WorkspacesQueries {
14
+ /**
15
+ * Minified `{ id, name }[]` for workspaces the user created or joined.
16
+ *
17
+ * Pipeline shape (perf-critical for select-workspace):
18
+ * 1. `$match` only this user's membership events (not every workspace event)
19
+ * 2. `$group` by workspace id (name when the user is the creator)
20
+ * 3. `$lookup` the workspace `Created` event for name when the user only joined
21
+ */
8
22
  static getAllByUserIncluded (userId) {
9
23
  log.info('[WorkspacesQueries] getAllByUserIncluded()')
24
+ if (!userId) return Promise.resolve([])
25
+
10
26
  return EventStore.Aggregate([
11
- { $match: { type: WorkspaceSchema.workspace, resourceId: { $exists: true } } },
12
- { $group: { _id: '$resourceId', events: { $push: '$$ROOT' } } },
13
27
  {
14
28
  $match: {
15
- $or: [
16
- { 'events.createdBy': userId },
17
- { 'events.event': 'UserInvitationAccepted', 'events.createdBy': userId },
18
- ],
19
- 'events.event': { $in: ['Created', 'UserInvitationAccepted'] },
29
+ type: WorkspaceSchema.workspace,
30
+ createdBy: userId,
31
+ event: { $in: MEMBERSHIP_EVENTS },
20
32
  },
21
33
  },
22
34
  {
23
- $project: {
24
- id: '$_id',
35
+ $group: {
36
+ _id: '$resourceId',
25
37
  name: {
26
- $let: {
27
- vars: {
28
- created: {
29
- $arrayElemAt: [
30
- {
31
- $filter: {
32
- input: '$events',
33
- as: 'e',
34
- cond: { $eq: ['$$e.event', 'Created'] },
35
- },
36
- },
37
- 0,
38
+ $max: {
39
+ $cond: [
40
+ { $eq: ['$event', 'Created'] },
41
+ '$payload.name',
42
+ null,
43
+ ],
44
+ },
45
+ },
46
+ },
47
+ },
48
+ {
49
+ $lookup: {
50
+ from: 'eventstore',
51
+ let: { workspaceId: '$_id' },
52
+ pipeline: [
53
+ {
54
+ $match: {
55
+ $expr: {
56
+ $and: [
57
+ { $eq: ['$resourceId', '$$workspaceId'] },
58
+ { $eq: ['$type', WorkspaceSchema.workspace] },
59
+ { $eq: ['$event', 'Created'] },
38
60
  ],
39
61
  },
40
62
  },
41
- in: '$$created.payload.name',
42
63
  },
64
+ { $limit: 1 },
65
+ { $project: { _id: 0, name: '$payload.name' } },
66
+ ],
67
+ as: '_created',
68
+ },
69
+ },
70
+ {
71
+ $project: {
72
+ _id: 0,
73
+ id: '$_id',
74
+ name: {
75
+ $ifNull: [
76
+ '$name',
77
+ { $arrayElemAt: ['$_created.name', 0] },
78
+ ],
43
79
  },
44
80
  },
45
81
  },
@@ -0,0 +1,59 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const aggregateMock = jest.fn()
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ EventStore: {
7
+ Aggregate: aggregateMock,
8
+ },
9
+ }))
10
+
11
+ jest.unstable_mockModule('@ossy/observability', () => ({
12
+ createLogger: () => ({
13
+ info: () => {},
14
+ error: () => {},
15
+ warn: () => {},
16
+ debug: () => {},
17
+ }),
18
+ }))
19
+
20
+ const { WorkspacesQueries } = await import('./workspaces.queries.js')
21
+ const { WorkspaceSchema } = await import('./schema-ids.js')
22
+
23
+ describe('WorkspacesQueries.getAllByUserIncluded', () => {
24
+ beforeEach(() => {
25
+ aggregateMock.mockReset()
26
+ })
27
+
28
+ it('returns [] without hitting Mongo when userId is missing', async () => {
29
+ await expect(WorkspacesQueries.getAllByUserIncluded()).resolves.toEqual([])
30
+ await expect(WorkspacesQueries.getAllByUserIncluded(null)).resolves.toEqual([])
31
+ expect(aggregateMock).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('scopes the aggregation to this user membership events only', async () => {
35
+ aggregateMock.mockResolvedValue([{ id: 'ws-1', name: 'Acme' }])
36
+
37
+ await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([
38
+ { id: 'ws-1', name: 'Acme' },
39
+ ])
40
+
41
+ expect(aggregateMock).toHaveBeenCalledTimes(1)
42
+ const [pipeline] = aggregateMock.mock.calls[0]
43
+ expect(pipeline[0]).toEqual({
44
+ $match: {
45
+ type: WorkspaceSchema.workspace,
46
+ createdBy: 'user-1',
47
+ event: { $in: ['Created', 'UserInvitationAccepted'] },
48
+ },
49
+ })
50
+ expect(pipeline.some(stage => stage.$lookup?.from === 'eventstore')).toBe(true)
51
+ // Must not scan every workspace stream then filter
52
+ expect(JSON.stringify(pipeline[0])).not.toContain('events.createdBy')
53
+ })
54
+
55
+ it('returns [] when the aggregation rejects', async () => {
56
+ aggregateMock.mockRejectedValue(new Error('mongo down'))
57
+ await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([])
58
+ })
59
+ })