@things-factory/worklist 10.1.198 → 10.1.203

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.
Files changed (124) hide show
  1. package/dist-client/components/activity-thread-timeline.d.ts +2 -2
  2. package/dist-client/pages/activity/activity-list-page.d.ts +3 -0
  3. package/dist-client/pages/activity/activity-list-page.js +25 -13
  4. package/dist-client/pages/activity/activity-list-page.js.map +1 -1
  5. package/dist-client/pages/activity/activity-partial-view.js +1 -1
  6. package/dist-client/pages/activity/activity-partial-view.js.map +1 -1
  7. package/dist-client/pages/activity/assignee-roles-cell.d.ts +32 -0
  8. package/dist-client/pages/activity/assignee-roles-cell.js +26 -0
  9. package/dist-client/pages/activity/assignee-roles-cell.js.map +1 -0
  10. package/dist-client/pages/activity-catalog/activity-catalog-page.js +1 -1
  11. package/dist-client/pages/activity-catalog/activity-catalog-page.js.map +1 -1
  12. package/dist-client/pages/activity-instance/activity-instance-view.d.ts +1 -1
  13. package/dist-client/pages/activity-thread/activity-thread-view.d.ts +1 -1
  14. package/dist-client/pages/work-door/door-sentence.d.ts +1 -1
  15. package/dist-client/pages/work-door/door-sentence.js +2 -2
  16. package/dist-client/pages/work-door/door-sentence.js.map +1 -1
  17. package/dist-client/route.d.ts +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-client/types/activity-instance-type.d.ts +2 -2
  20. package/dist-client/types/activity-instance-type.js.map +1 -1
  21. package/dist-client/types/activity-instance.d.ts +1 -1
  22. package/dist-client/types/activity-instance.js.map +1 -1
  23. package/dist-client/types/activity.d.ts +1 -1
  24. package/dist-client/types/activity.js.map +1 -1
  25. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.d.ts +2 -2
  26. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.js +2 -2
  27. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.js.map +1 -1
  28. package/dist-server/controllers/activity-instance/abort.js +1 -1
  29. package/dist-server/controllers/activity-instance/abort.js.map +1 -1
  30. package/dist-server/controllers/activity-instance/assign.d.ts +2 -1
  31. package/dist-server/controllers/activity-instance/assign.js +104 -2
  32. package/dist-server/controllers/activity-instance/assign.js.map +1 -1
  33. package/dist-server/controllers/activity-instance/assignee-input.d.ts +25 -0
  34. package/dist-server/controllers/activity-instance/assignee-input.js +28 -0
  35. package/dist-server/controllers/activity-instance/assignee-input.js.map +1 -0
  36. package/dist-server/controllers/activity-instance/draft.js +7 -2
  37. package/dist-server/controllers/activity-instance/draft.js.map +1 -1
  38. package/dist-server/controllers/activity-instance/end.js +1 -1
  39. package/dist-server/controllers/activity-instance/end.js.map +1 -1
  40. package/dist-server/controllers/activity-instance/entitlement.d.ts +1 -1
  41. package/dist-server/controllers/activity-instance/entitlement.js +7 -5
  42. package/dist-server/controllers/activity-instance/entitlement.js.map +1 -1
  43. package/dist-server/controllers/activity-instance/issue.js +28 -7
  44. package/dist-server/controllers/activity-instance/issue.js.map +1 -1
  45. package/dist-server/controllers/activity-instance/pick.js +22 -3
  46. package/dist-server/controllers/activity-instance/pick.js.map +1 -1
  47. package/dist-server/controllers/activity-read/assert-may-read.js +2 -1
  48. package/dist-server/controllers/activity-read/assert-may-read.js.map +1 -1
  49. package/dist-server/controllers/activity-read/participates.d.ts +2 -2
  50. package/dist-server/controllers/activity-read/participates.js +2 -2
  51. package/dist-server/controllers/activity-read/participates.js.map +1 -1
  52. package/dist-server/controllers/activity-thread/check-authority.js +22 -4
  53. package/dist-server/controllers/activity-thread/check-authority.js.map +1 -1
  54. package/dist-server/controllers/common.js +17 -12
  55. package/dist-server/controllers/common.js.map +1 -1
  56. package/dist-server/service/activity/activity-history.d.ts +1 -2
  57. package/dist-server/service/activity/activity-history.js +4 -8
  58. package/dist-server/service/activity/activity-history.js.map +1 -1
  59. package/dist-server/service/activity/activity-mutation.js +39 -8
  60. package/dist-server/service/activity/activity-mutation.js.map +1 -1
  61. package/dist-server/service/activity/activity-query.d.ts +1 -1
  62. package/dist-server/service/activity/activity-query.js +9 -9
  63. package/dist-server/service/activity/activity-query.js.map +1 -1
  64. package/dist-server/service/activity/activity-type.d.ts +2 -2
  65. package/dist-server/service/activity/activity-type.js +12 -6
  66. package/dist-server/service/activity/activity-type.js.map +1 -1
  67. package/dist-server/service/activity/activity.d.ts +2 -2
  68. package/dist-server/service/activity/activity.js +15 -7
  69. package/dist-server/service/activity/activity.js.map +1 -1
  70. package/dist-server/service/activity/event-subscriber.js.map +1 -1
  71. package/dist-server/service/activity-instance/activity-instance-history.d.ts +1 -2
  72. package/dist-server/service/activity-instance/activity-instance-history.js +4 -8
  73. package/dist-server/service/activity-instance/activity-instance-history.js.map +1 -1
  74. package/dist-server/service/activity-instance/activity-instance-mutation.d.ts +1 -1
  75. package/dist-server/service/activity-instance/activity-instance-mutation.js +10 -5
  76. package/dist-server/service/activity-instance/activity-instance-mutation.js.map +1 -1
  77. package/dist-server/service/activity-instance/activity-instance-query.d.ts +1 -2
  78. package/dist-server/service/activity-instance/activity-instance-query.js +16 -25
  79. package/dist-server/service/activity-instance/activity-instance-query.js.map +1 -1
  80. package/dist-server/service/activity-instance/activity-instance-type.d.ts +2 -2
  81. package/dist-server/service/activity-instance/activity-instance-type.js +12 -6
  82. package/dist-server/service/activity-instance/activity-instance-type.js.map +1 -1
  83. package/dist-server/service/activity-instance/activity-instance.d.ts +2 -2
  84. package/dist-server/service/activity-instance/activity-instance.js +15 -7
  85. package/dist-server/service/activity-instance/activity-instance.js.map +1 -1
  86. package/dist-server/service/activity-instance/assignee-pool.d.ts +59 -0
  87. package/dist-server/service/activity-instance/assignee-pool.js +99 -0
  88. package/dist-server/service/activity-instance/assignee-pool.js.map +1 -0
  89. package/dist-server/service/activity-instance/event-subscriber.d.ts +9 -1
  90. package/dist-server/service/activity-instance/event-subscriber.js +20 -4
  91. package/dist-server/service/activity-instance/event-subscriber.js.map +1 -1
  92. package/dist-server/service/activity-summary/activity-summary-query.js +3 -3
  93. package/dist-server/service/activity-summary/activity-summary-query.js.map +1 -1
  94. package/dist-server/service/activity-thread/my-open-threads.d.ts +1 -1
  95. package/dist-server/service/activity-thread/my-open-threads.js +4 -3
  96. package/dist-server/service/activity-thread/my-open-threads.js.map +1 -1
  97. package/dist-server/service/index.d.ts +1 -1
  98. package/dist-server/service/subscription/subscription-filters.d.ts +3 -2
  99. package/dist-server/service/subscription/subscription-filters.js +6 -3
  100. package/dist-server/service/subscription/subscription-filters.js.map +1 -1
  101. package/dist-server/service/work-door/work-door-service.js +9 -6
  102. package/dist-server/service/work-door/work-door-service.js.map +1 -1
  103. package/dist-server/service/work-door/work-door-type.d.ts +1 -1
  104. package/dist-server/service/work-door/work-door-type.js +4 -4
  105. package/dist-server/service/work-door/work-door-type.js.map +1 -1
  106. package/dist-server/service/work-door/work-door.d.ts +3 -3
  107. package/dist-server/service/work-door/work-door.js +2 -2
  108. package/dist-server/service/work-door/work-door.js.map +1 -1
  109. package/dist-server/tsconfig.tsbuildinfo +1 -1
  110. package/package.json +9 -9
  111. package/tests/assign-role-pool.test.ts +508 -0
  112. package/tests/assignee-pool-history.test.ts +81 -0
  113. package/tests/may-read-case.test.ts +3 -2
  114. package/tests/org-member-domain.test.ts +10 -4
  115. package/tests/pick-race.pg.test.ts +2 -2
  116. package/tests/published-shape.test.ts +2 -2
  117. package/tests/role-pool-race.pg.test.ts +182 -0
  118. package/tests/work-door.test.ts +3 -3
  119. package/translations/en.json +5 -0
  120. package/translations/es.json +5 -0
  121. package/translations/ja.json +5 -0
  122. package/translations/ko.json +5 -0
  123. package/translations/ms.json +5 -0
  124. package/translations/zh.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/worklist",
3
- "version": "10.1.198",
3
+ "version": "10.1.203",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "exports": {
@@ -54,15 +54,15 @@
54
54
  "@operato/moment-timezone-es": "^10.0.0",
55
55
  "@operato/p13n": "^10.29.43",
56
56
  "@operato/popup": "^10.29.43",
57
- "@things-factory/attachment-base": "^10.1.198",
58
- "@things-factory/auth-base": "^10.1.198",
59
- "@things-factory/board-service": "^10.1.198",
57
+ "@things-factory/attachment-base": "^10.1.203",
58
+ "@things-factory/auth-base": "^10.1.203",
59
+ "@things-factory/board-service": "^10.1.203",
60
60
  "@things-factory/context-ui": "^10.1.188",
61
- "@things-factory/i18n-base": "^10.1.198",
62
- "@things-factory/organization": "^10.1.198",
63
- "@things-factory/personalization": "^10.1.198",
64
- "@things-factory/scheduler-client": "^10.1.198",
61
+ "@things-factory/i18n-base": "^10.1.203",
62
+ "@things-factory/organization": "^10.1.203",
63
+ "@things-factory/personalization": "^10.1.203",
64
+ "@things-factory/scheduler-client": "^10.1.203",
65
65
  "@things-factory/shell": "^10.1.188"
66
66
  },
67
- "gitHead": "68f884a78ca3a779c2b057ba5b8f6e03f477d701"
67
+ "gitHead": "031aaae3b75b2975e6bce007b3ad7d8d74fe60c8"
68
68
  }
@@ -0,0 +1,508 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * A case put in a role's pool by assign, and a role among assignees refused (ruling 2026-10-20).
5
+ *
6
+ * The pool is what issue() already makes for an activity that names assignee roles: `assigneeRoles`,
7
+ * `threadsMin` 1, one unassigned thread. The to-do list reads exactly that, so the check that matters
8
+ * is the list - a member sees the case, someone else does not. Real entities on sqlite; the race that
9
+ * the UPDATE closes is staged by handing assign a read taken before someone picked the case.
10
+ */
11
+
12
+ /* Nobody here manages activity instances - the role is the only way in. */
13
+ jest.mock('@things-factory/auth-base', () => {
14
+ const actual = jest.requireActual('@things-factory/auth-base')
15
+ return { ...actual, checkPermission: async () => false }
16
+ })
17
+
18
+ const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
19
+ const DIST = join(__dirname, '..', 'dist-server')
20
+ const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
21
+ const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
22
+ const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
23
+ const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
24
+ const { myOpenThreadsWhere } = require(join(DIST, 'service', 'activity-thread', 'my-open-threads.js'))
25
+ const { assign } = require(join(DIST, 'controllers', 'activity-instance', 'assign.js'))
26
+ const { issue } = require(join(DIST, 'controllers', 'activity-instance', 'issue.js'))
27
+ const { pick } = require(join(DIST, 'controllers', 'activity-instance', 'pick.js'))
28
+ const { start } = require(join(DIST, 'controllers', 'activity-thread', 'start.js'))
29
+ const { checkAuthority } = require(join(DIST, 'controllers', 'activity-thread', 'check-authority.js'))
30
+ const { ActivityMutation } = require(join(DIST, 'service', 'activity', 'activity-mutation.js'))
31
+ const { Domain } = require('@things-factory/shell')
32
+ const { Role, User } = require('@things-factory/auth-base')
33
+
34
+ let db: any
35
+ let here: any
36
+ let elsewhere: any
37
+ let manager: any
38
+ let member: any
39
+ let other: any
40
+ let pool: any
41
+ let anotherPool: any
42
+ let emptyPool: any
43
+ let alsoEmpty: any
44
+ let both: any
45
+ let theirPool: any
46
+ let activity: any
47
+
48
+ beforeAll(async () => {
49
+ db = createSqliteTestDataSource([Activity, ActivityInstance, ActivityThread, ActivityApproval])
50
+ await db.initialize()
51
+
52
+ here = await db.getRepository(Domain).save({ name: 'here', subdomain: 'here' })
53
+ elsewhere = await db.getRepository(Domain).save({ name: 'elsewhere', subdomain: 'elsewhere' })
54
+
55
+ const person = (name: string, domain: any) =>
56
+ db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status: 'activated', userType: 'user', domains: [domain] })
57
+
58
+ manager = await person('manager', here)
59
+ member = await person('member', here)
60
+ other = await person('other', here)
61
+ both = await person('both', here)
62
+ const theirs = await person('theirs', elsewhere)
63
+
64
+ pool = await db.getRepository(Role).save({ domain: here, name: 'finance', users: [member, both] })
65
+ anotherPool = await db.getRepository(Role).save({ domain: here, name: 'support', users: [other, both] })
66
+ emptyPool = await db.getRepository(Role).save({ domain: here, name: 'nobody', users: [] })
67
+ alsoEmpty = await db.getRepository(Role).save({ domain: here, name: 'nobody-either', users: [] })
68
+ theirPool = await db.getRepository(Role).save({ domain: elsewhere, name: 'theirs', users: [theirs] })
69
+
70
+ activity = await db.getRepository(Activity).save({ domain: here, name: 'tax-invoice', activityType: 'standard', startingType: 'issue' })
71
+ })
72
+
73
+ afterAll(async () => db?.destroy())
74
+
75
+ const as = (user: any, tx: any) => ({ state: { domain: here, user, tx }, t: (key: string) => key }) as any
76
+
77
+ const aCase = (fields: any = {}) =>
78
+ db.getRepository(ActivityInstance).save({ domain: here, name: 'case', state: 'issued', activity, ...fields })
79
+
80
+ const assignAs = (user: any, args: any) => db.transaction((tx: any) => assign(args, as(user, tx)))
81
+
82
+ const reread = (instance: any) =>
83
+ db.getRepository(ActivityInstance).findOne({ where: { id: instance.id }, relations: ['assigneeRoles'] })
84
+
85
+ /** The pool's role ids, in no particular order - sorted to compare. */
86
+ const poolOf = (row: any) => (row?.assigneeRoles || []).map((role: any) => role.id).sort()
87
+
88
+ const threadsOf = (instance: any) => db.getRepository(ActivityThread).find({ where: { activityInstance: { id: instance.id } } })
89
+
90
+ /** The to-do list's own condition (my-open-threads.ts), for one case. */
91
+ const onTheListOf = (user: any, roles: any[], instance: any) =>
92
+ db
93
+ .getRepository(ActivityThread)
94
+ .createQueryBuilder('at')
95
+ .innerJoin('at.activityInstance', 'ai')
96
+ .where(myOpenThreadsWhere({ userId: user.id, roles: roles.map(role => role.id) }))
97
+ .andWhere('ai.id = :id', { id: instance.id })
98
+ .getCount()
99
+
100
+ /** A transaction whose first read of the case is one taken earlier - someone else wrote in between. */
101
+ const seesTheOldRead = (tx: any, stale: any) => {
102
+ let given = false
103
+
104
+ return new Proxy(tx, {
105
+ get(target, key) {
106
+ if (key !== 'getRepository') return Reflect.get(target, key)
107
+
108
+ return (entity: any) => {
109
+ const repository = target.getRepository(entity)
110
+ if (entity !== ActivityInstance) return repository
111
+
112
+ const once = Object.create(repository)
113
+ once.findOne = async (...args: any[]) => (given ? repository.findOne(...args) : ((given = true), { ...stale }))
114
+ return once
115
+ }
116
+ }
117
+ })
118
+ }
119
+
120
+ describe('assign to a role', () => {
121
+ it('puts a case nobody holds in the pool: the role, one waiting thread, still issued', async () => {
122
+ const instance = await aCase()
123
+
124
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
125
+
126
+ const after = await reread(instance)
127
+ expect(after.state).toBe('issued')
128
+ expect(poolOf(after)).toEqual([pool.id])
129
+ expect(after.threadsMin).toBe(1)
130
+
131
+ const threads = await threadsOf(instance)
132
+ expect(threads.map((thread: any) => thread.state)).toEqual(['unassigned'])
133
+ expect(threads[0].assigneeId).toBeFalsy()
134
+ })
135
+
136
+ it('a member of the role finds it on their list; someone of another role does not', async () => {
137
+ const instance = await aCase()
138
+
139
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
140
+
141
+ expect(await onTheListOf(member, [pool], instance)).toBe(1)
142
+ expect(await onTheListOf(other, [anotherPool], instance)).toBe(0)
143
+ })
144
+
145
+ it('a case already in a pool moves to the other pool and keeps its one waiting thread', async () => {
146
+ const instance = await aCase()
147
+
148
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
149
+ await assignAs(manager, { id: instance.id, assigneeRoles: [anotherPool.id] })
150
+
151
+ expect(poolOf(await reread(instance))).toEqual([anotherPool.id])
152
+ expect((await threadsOf(instance)).length).toBe(1)
153
+ expect(await onTheListOf(other, [anotherPool], instance)).toBe(1)
154
+ expect(await onTheListOf(member, [pool], instance)).toBe(0)
155
+ })
156
+
157
+ it.each(['assigned', 'started', 'unassigned', 'ended', 'draft'])('a case %s is refused by name and left as it was', async state => {
158
+ const instance = await aCase({ state })
159
+
160
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })).rejects.toThrow(
161
+ 'error.activity-instance should not be assigned'
162
+ )
163
+ expect(poolOf(await reread(instance))).toEqual([])
164
+ expect((await threadsOf(instance)).length).toBe(0)
165
+ })
166
+
167
+ it("another tenant's role is refused by name", async () => {
168
+ const instance = await aCase()
169
+
170
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [theirPool.id] })).rejects.toThrow(
171
+ 'error.activity-instance role not in this domain'
172
+ )
173
+ expect(poolOf(await reread(instance))).toEqual([])
174
+ })
175
+
176
+ it('a role nobody holds is refused by name - nobody would see the case', async () => {
177
+ const instance = await aCase()
178
+
179
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [emptyPool.id] })).rejects.toThrow(
180
+ 'error.activity-instance role has no members'
181
+ )
182
+ expect(poolOf(await reread(instance))).toEqual([])
183
+ expect((await threadsOf(instance)).length).toBe(0)
184
+ })
185
+
186
+ it('people and a role in one call are refused by name', async () => {
187
+ const instance = await aCase()
188
+
189
+ await expect(assignAs(manager, { id: instance.id, assignees: [member.email], assigneeRoles: [pool.id] })).rejects.toThrow(
190
+ 'error.activity-instance assign people or a role, not both'
191
+ )
192
+ expect((await threadsOf(instance)).length).toBe(0)
193
+ })
194
+
195
+ it.each(['assigned', 'started', 'submitted', 'ended'])(
196
+ 'a case still marked issued whose thread someone holds (%s) is refused by name and left as it was',
197
+ async held => {
198
+ /* The state is worked out again only when a thread changes - a member taking the waiting thread is a step ahead of it. */
199
+ const instance = await aCase({ assigneeRoles: [pool], threadsMin: 1 })
200
+ await db.getRepository(ActivityThread).save({ domain: here, activityInstance: instance, assignee: member, state: held })
201
+
202
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [anotherPool.id] })).rejects.toThrow(
203
+ 'error.activity-approval taken by someone else'
204
+ )
205
+ expect(poolOf(await reread(instance))).toEqual([pool.id])
206
+ expect((await threadsOf(instance)).map((thread: any) => thread.state)).toEqual([held])
207
+ }
208
+ )
209
+
210
+ it('someone picked it after assign read it: refused by name, no pool, no second thread', async () => {
211
+ const instance = await aCase()
212
+ const stale = await db.getRepository(ActivityInstance).findOne({ where: { id: instance.id }, relations: ['activity'] })
213
+
214
+ /*
215
+ * Picked in between: pick took the case first, and its thread is not yet there for this transaction to
216
+ * read - only the case's UPDATE can tell.
217
+ */
218
+ await db.getRepository(ActivityInstance).update({ id: instance.id }, { state: 'assigned' })
219
+
220
+ await expect(
221
+ db.transaction((tx: any) => assign({ id: instance.id, assigneeRoles: [pool.id] }, as(manager, seesTheOldRead(tx, stale))))
222
+ ).rejects.toThrow('error.activity-approval taken by someone else')
223
+
224
+ expect(poolOf(await reread(instance))).toEqual([])
225
+ expect((await threadsOf(instance)).length).toBe(0)
226
+ })
227
+
228
+ it('makes the very pool issue() makes for an activity naming an assignee role', async () => {
229
+ const shape = async (instance: any) => {
230
+ const row = await reread(instance)
231
+ return {
232
+ state: row.state,
233
+ assigneeRoles: poolOf(row),
234
+ threadsMin: row.threadsMin,
235
+ threads: (await threadsOf(instance)).map((thread: any) => [thread.state, thread.assigneeId || null]),
236
+ member: await onTheListOf(member, [pool], instance),
237
+ other: await onTheListOf(other, [anotherPool], instance)
238
+ }
239
+ }
240
+
241
+ const issued = await db.transaction((tx: any) =>
242
+ issue({ activityId: activity.id, assignees: [], assigneeRoles: [pool], threadsMin: 1 }, as(manager, tx))
243
+ )
244
+
245
+ const assigned = await aCase()
246
+ await assignAs(manager, { id: assigned.id, assigneeRoles: [pool.id] })
247
+
248
+ expect(await shape(assigned)).toEqual(await shape(issued))
249
+ expect(await shape(assigned)).toMatchObject({ state: 'issued', threads: [['unassigned', null]], member: 1, other: 0 })
250
+ })
251
+
252
+ it('assigning people still works as before', async () => {
253
+ const instance = await aCase()
254
+
255
+ await assignAs(manager, { id: instance.id, assignees: [member.email] })
256
+
257
+ const threads = await threadsOf(instance)
258
+ expect(threads.map((thread: any) => [thread.state, thread.assigneeId])).toEqual([['assigned', member.id]])
259
+ })
260
+ })
261
+
262
+ describe('the other ways into a waiting case', () => {
263
+ const pooled = async () => {
264
+ const instance = await aCase()
265
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
266
+ return instance
267
+ }
268
+
269
+ it('a member claiming the waiting thread holds the case, and the case says so', async () => {
270
+ const instance = await pooled()
271
+ const [waiting] = await threadsOf(instance)
272
+
273
+ await db.transaction((tx: any) => start(waiting.id, {}, '', as(member, tx)))
274
+
275
+ expect((await threadsOf(instance)).map((thread: any) => [thread.state, thread.assigneeId])).toEqual([['started', member.id]])
276
+ expect((await reread(instance)).state).not.toBe('issued')
277
+ })
278
+
279
+ it('a claim on a waiting thread someone claimed meanwhile is refused by name, and the thread stays theirs', async () => {
280
+ const instance = await pooled()
281
+ const [waiting] = await threadsOf(instance)
282
+ const stale = { ...waiting }
283
+
284
+ await db.getRepository(ActivityThread).update({ id: waiting.id }, { state: 'assigned', assignee: { id: other.id } })
285
+
286
+ await expect(db.transaction((tx: any) => checkAuthority(stale, as(member, tx)))).rejects.toThrow(
287
+ 'error.activity-approval taken by someone else'
288
+ )
289
+ expect((await threadsOf(instance)).map((thread: any) => thread.assigneeId)).toEqual([other.id])
290
+ })
291
+
292
+ it('pick that read the case before it went to a pool: someone outside the role is refused by name', async () => {
293
+ const instance = await aCase()
294
+ const stale = await db.getRepository(ActivityInstance).findOne({ where: { id: instance.id }, relations: ['activity'] })
295
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
296
+
297
+ await expect(db.transaction((tx: any) => pick(instance.id, as(other, seesTheOldRead(tx, stale))))).rejects.toThrow(
298
+ 'error.activity-instance is addressed to another role'
299
+ )
300
+ expect(poolOf(await reread(instance))).toEqual([pool.id])
301
+ })
302
+
303
+ it('pick that read the case before it went to a pool: a member takes it, and the pool is not wiped', async () => {
304
+ const instance = await aCase()
305
+ const stale = await db.getRepository(ActivityInstance).findOne({ where: { id: instance.id }, relations: ['activity'] })
306
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id] })
307
+
308
+ await db.transaction((tx: any) => pick(instance.id, as(member, seesTheOldRead(tx, stale))))
309
+
310
+ const after = await reread(instance)
311
+ expect(poolOf(after)).toEqual([pool.id])
312
+ expect(after.threadsMin).toBe(1)
313
+ })
314
+ })
315
+
316
+ describe('a pool of several roles - anyone holding any one of them (ADR-0091 decision 9)', () => {
317
+ const twoPools = async () => {
318
+ const instance = await aCase()
319
+ await assignAs(manager, { id: instance.id, assigneeRoles: [pool.id, anotherPool.id] })
320
+ return instance
321
+ }
322
+
323
+ it('is one case with one waiting thread, in both pools', async () => {
324
+ const instance = await twoPools()
325
+
326
+ expect(poolOf(await reread(instance))).toEqual([pool.id, anotherPool.id].sort())
327
+ expect((await threadsOf(instance)).map((thread: any) => thread.state)).toEqual(['unassigned'])
328
+ })
329
+
330
+ it('reaches the to-do list of a holder of either role, once for someone holding both, and nobody else', async () => {
331
+ const instance = await twoPools()
332
+
333
+ expect(await onTheListOf(member, [pool], instance)).toBe(1)
334
+ /* Only the second role. */
335
+ expect(await onTheListOf(other, [anotherPool], instance)).toBe(1)
336
+ expect(await onTheListOf(both, [pool, anotherPool], instance)).toBe(1)
337
+ expect(await onTheListOf(manager, [emptyPool], instance)).toBe(0)
338
+ })
339
+
340
+ it('someone holding only the second role picks it', async () => {
341
+ const instance = await twoPools()
342
+
343
+ await db.transaction((tx: any) => pick(instance.id, as(other, tx)))
344
+
345
+ expect((await threadsOf(instance)).some((thread: any) => thread.assigneeId === other.id)).toBe(true)
346
+ })
347
+
348
+ it('someone holding only the second role claims the waiting thread', async () => {
349
+ const instance = await twoPools()
350
+ const [waiting] = await threadsOf(instance)
351
+
352
+ await db.transaction((tx: any) => start(waiting.id, {}, '', as(other, tx)))
353
+
354
+ expect((await threadsOf(instance)).map((thread: any) => [thread.state, thread.assigneeId])).toEqual([['started', other.id]])
355
+ })
356
+
357
+ it('someone holding none of them is refused the claim', async () => {
358
+ const instance = await twoPools()
359
+ const [waiting] = await threadsOf(instance)
360
+
361
+ await expect(db.transaction((tx: any) => start(waiting.id, {}, '', as(manager, tx)))).rejects.toThrow(
362
+ 'error.not authorized to handle this task'
363
+ )
364
+ })
365
+
366
+ it('roles whose people together are nobody are refused by name; one of them with people is enough', async () => {
367
+ const instance = await aCase()
368
+
369
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [emptyPool.id, alsoEmpty.id] })).rejects.toThrow(
370
+ 'error.activity-instance role has no members'
371
+ )
372
+ expect(poolOf(await reread(instance))).toEqual([])
373
+
374
+ await assignAs(manager, { id: instance.id, assigneeRoles: [emptyPool.id, anotherPool.id] })
375
+ expect(poolOf(await reread(instance))).toEqual([emptyPool.id, anotherPool.id].sort())
376
+ })
377
+
378
+ it('the same role twice is refused by name, and the case is left as it was', async () => {
379
+ const instance = await aCase()
380
+
381
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [pool.id, pool.id] })).rejects.toMatchObject({
382
+ code: 'assignee-role-named-twice'
383
+ })
384
+ expect(poolOf(await reread(instance))).toEqual([])
385
+ expect((await threadsOf(instance)).length).toBe(0)
386
+ })
387
+
388
+ it("another tenant's role among them is refused by name", async () => {
389
+ const instance = await aCase()
390
+
391
+ await expect(assignAs(manager, { id: instance.id, assigneeRoles: [pool.id, theirPool.id] })).rejects.toMatchObject({
392
+ code: 'assignee-role-not-in-this-domain'
393
+ })
394
+ expect(poolOf(await reread(instance))).toEqual([])
395
+ })
396
+
397
+ it('moving a pool of two to a pool of one keeps the one waiting thread', async () => {
398
+ const instance = await twoPools()
399
+
400
+ await assignAs(manager, { id: instance.id, assigneeRoles: [anotherPool.id] })
401
+
402
+ expect(poolOf(await reread(instance))).toEqual([anotherPool.id])
403
+ expect((await threadsOf(instance)).length).toBe(1)
404
+ expect(await onTheListOf(member, [pool], instance)).toBe(0)
405
+ })
406
+
407
+ it('issued to everyone of the pool (threadsMin 0), each person gets one thread however many of its roles they hold', async () => {
408
+ const issued = await db.transaction((tx: any) =>
409
+ issue({ activityId: activity.id, assignees: [], assigneeRoles: [pool, anotherPool], threadsMin: 0 }, as(manager, tx))
410
+ )
411
+
412
+ const holders = (await threadsOf(issued)).map((thread: any) => thread.assigneeId).sort()
413
+ expect(holders).toEqual([member.id, other.id, both.id].sort())
414
+ })
415
+
416
+ it("an activity's pool of two is the pool of every case issued from it", async () => {
417
+ const saved = await db.transaction((tx: any) =>
418
+ new ActivityMutation().createActivity({ name: 'two-pools', assigneeRoles: [{ id: pool.id }, { id: anotherPool.id }] }, as(manager, tx))
419
+ )
420
+ const issued = await db.transaction((tx: any) => issue({ activityId: saved.id, assignees: [], threadsMin: 1 }, as(manager, tx)))
421
+
422
+ expect(poolOf(await reread(issued))).toEqual([pool.id, anotherPool.id].sort())
423
+ expect(await onTheListOf(other, [anotherPool], issued)).toBe(1)
424
+ })
425
+
426
+ it.each([
427
+ ['the same role twice', () => [{ id: pool.id }, { id: pool.id }], 'assignee-role-named-twice'],
428
+ ["another tenant's role", () => [{ id: pool.id }, { id: theirPool.id }], 'assignee-role-not-in-this-domain']
429
+ ])('an activity saved with %s is refused by name and nothing is written', async (_, roles, code) => {
430
+ const before = await db.getRepository(Activity).count()
431
+
432
+ await expect(
433
+ db.transaction((tx: any) => new ActivityMutation().createActivity({ name: `bad-${code}`, assigneeRoles: roles() }, as(manager, tx)))
434
+ ).rejects.toMatchObject({ code })
435
+
436
+ expect(await db.getRepository(Activity).count()).toBe(before)
437
+ })
438
+
439
+ it('a case issued with the same role twice is refused by name and nothing is written', async () => {
440
+ const before = await db.getRepository(ActivityInstance).count()
441
+
442
+ await expect(
443
+ db.transaction((tx: any) => issue({ activityId: activity.id, assignees: [], assigneeRoles: [pool, pool], threadsMin: 1 }, as(manager, tx)))
444
+ ).rejects.toMatchObject({ code: 'assignee-role-named-twice' })
445
+
446
+ expect(await db.getRepository(ActivityInstance).count()).toBe(before)
447
+ })
448
+ })
449
+
450
+ describe('a role among assignees', () => {
451
+ const issueAs = (user: any, input: any) => db.transaction((tx: any) => issue(input, as(user, tx)))
452
+
453
+ it('handed in when issuing, is refused by name and nothing is written', async () => {
454
+ const before = await db.getRepository(ActivityInstance).count()
455
+
456
+ await expect(
457
+ issueAs(manager, { activityId: activity.id, assignees: [{ type: 'Role', value: pool.id }] })
458
+ ).rejects.toMatchObject({ code: 'role-is-not-an-assignee', message: 'error.activity-instance a role is not an assignee' })
459
+
460
+ expect(await db.getRepository(ActivityInstance).count()).toBe(before)
461
+ })
462
+
463
+ it('saved on the activity before this, is refused when a case of it is issued', async () => {
464
+ const saved = await db
465
+ .getRepository(Activity)
466
+ .save({ domain: here, name: 'payroll', activityType: 'standard', startingType: 'issue', assignees: [{ type: 'Role', value: pool.id }] })
467
+
468
+ await expect(issueAs(manager, { activityId: saved.id })).rejects.toMatchObject({ code: 'role-is-not-an-assignee' })
469
+ })
470
+
471
+ const mutation = new ActivityMutation()
472
+ const role = () => [{ type: 'Role', value: pool.id }]
473
+
474
+ it.each([
475
+ ['created', (context: any) => mutation.createActivity({ name: 'with-role', assignees: role() }, context)],
476
+ ['updated', async (context: any) => mutation.updateActivity(activity.id, { assignees: role() }, context)],
477
+ ['saved in a grid', (context: any) => mutation.updateMultipleActivity([{ name: 'grid-role', cuFlag: '+', assignees: role() }], context)],
478
+ ['imported', (context: any) => mutation.importActivities([{ name: 'imported-role', assignees: role() }], context)]
479
+ ])('on an activity being %s, is refused by name and nothing is written', async (_, save) => {
480
+ const before = await db.getRepository(Activity).count()
481
+
482
+ await expect(db.transaction((tx: any) => save(as(manager, tx)))).rejects.toMatchObject({ code: 'role-is-not-an-assignee' })
483
+
484
+ expect(await db.getRepository(Activity).count()).toBe(before)
485
+ expect((await db.getRepository(Activity).findOneBy({ id: activity.id })).assignees).toBeFalsy()
486
+ })
487
+
488
+ it('an activity saved with people among its assignees stands', async () => {
489
+ const saved = await db.transaction((tx: any) =>
490
+ mutation.createActivity({ name: 'with-person', assignees: [{ type: 'Myself' }] }, as(manager, tx))
491
+ )
492
+
493
+ expect(saved.assignees).toEqual([{ type: 'Myself' }])
494
+ })
495
+
496
+ it('a person among assignees is issued as before', async () => {
497
+ const issued = await issueAs(manager, { activityId: activity.id, assignees: [{ type: 'Myself' }] })
498
+
499
+ expect((await threadsOf(issued)).map((thread: any) => [thread.state, thread.assigneeId])).toEqual([['assigned', manager.id]])
500
+ })
501
+
502
+ it('the pool named by assigneeRoles is issued as before', async () => {
503
+ const issued = await issueAs(manager, { activityId: activity.id, assignees: [], assigneeRoles: [pool], threadsMin: 1 })
504
+
505
+ expect((await threadsOf(issued)).map((thread: any) => thread.state)).toEqual(['unassigned'])
506
+ expect(await onTheListOf(member, [pool], issued)).toBe(1)
507
+ })
508
+ })
@@ -0,0 +1,81 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * The history row of an activity or a case keeps the pool **as it was saved** (ADR-0091 decision 9).
5
+ *
6
+ * The pool is a join table, so history keeps its ids, copied by name off the saved object's RelationId.
7
+ * typeorm fills that in with the new pool once the join rows are written (measured 2026-10-20), so the copy
8
+ * is the pool after the save. Real entities and subscribers on sqlite.
9
+ */
10
+
11
+ const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
12
+ const DIST = join(__dirname, '..', 'dist-server')
13
+ const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
14
+ const { ActivityHistory } = require(join(DIST, 'service', 'activity', 'activity-history.js'))
15
+ const { ActivityHistoryEntitySubscriber } = require(join(DIST, 'service', 'activity', 'event-subscriber.js'))
16
+ const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
17
+ const { ActivityInstanceHistory } = require(join(DIST, 'service', 'activity-instance', 'activity-instance-history.js'))
18
+ const { ActivityInstanceHistoryEntitySubscriber } = require(join(DIST, 'service', 'activity-instance', 'event-subscriber.js'))
19
+ const { ActivityMutation } = require(join(DIST, 'service', 'activity', 'activity-mutation.js'))
20
+ const { withPoolIds } = require(join(DIST, 'service', 'activity-instance', 'event-subscriber.js'))
21
+ const { Domain } = require('@things-factory/shell')
22
+ const { Role, User } = require('@things-factory/auth-base')
23
+
24
+ let db: any
25
+ let here: any
26
+ let manager: any
27
+ let finance: any
28
+ let support: any
29
+
30
+ beforeAll(async () => {
31
+ db = createSqliteTestDataSource([Activity, ActivityHistory, ActivityInstance, ActivityInstanceHistory], {
32
+ subscribers: [ActivityHistoryEntitySubscriber, ActivityInstanceHistoryEntitySubscriber]
33
+ })
34
+ await db.initialize()
35
+
36
+ here = await db.getRepository(Domain).save({ name: 'here', subdomain: 'here' })
37
+ manager = await db.getRepository(User).save({ name: 'm', email: 'm@example.com', password: 'x', domains: [here] })
38
+ finance = await db.getRepository(Role).save({ domain: here, name: 'finance' })
39
+ support = await db.getRepository(Role).save({ domain: here, name: 'support' })
40
+ })
41
+
42
+ afterAll(async () => db?.destroy())
43
+
44
+ const as = (tx: any) => ({ state: { domain: here, user: manager, tx }, t: (key: string) => key }) as any
45
+ const latest = async (entity: any, originalId: string) =>
46
+ (await db.getRepository(entity).find({ where: { originalId }, order: { version: 'DESC' } }))[0]
47
+
48
+ it("an activity's history keeps the pool it was changed to, not the one before", async () => {
49
+ const mutation = new ActivityMutation()
50
+ const created = await db.transaction((tx: any) => mutation.createActivity({ name: 'pay', assigneeRoles: [{ id: finance.id }] }, as(tx)))
51
+
52
+ await db.transaction((tx: any) =>
53
+ mutation.updateActivity(created.id, { assigneeRoles: [{ id: finance.id }, { id: support.id }] }, as(tx))
54
+ )
55
+
56
+ expect([...(await latest(ActivityHistory, created.id)).assigneeRoleIds].sort()).toEqual([finance.id, support.id].sort())
57
+ })
58
+
59
+ it("a case's history keeps the pool it was saved with", async () => {
60
+ const saved = await db.getRepository(ActivityInstance).save({ domain: here, name: 'case', state: 'issued', assigneeRoles: [support] })
61
+
62
+ expect((await latest(ActivityInstanceHistory, saved.id)).assigneeRoleIds).toEqual([support.id])
63
+ })
64
+
65
+ /*
66
+ * What is published goes through withPoolIds - the subscription filter decides who hears of a case by the
67
+ * pool's ids on it (subscription-filters.ts). A row saved without its pool in hand still carries them.
68
+ */
69
+ it('a case published without its pool in hand carries the pool read from the join table', async () => {
70
+ const saved = await db.getRepository(ActivityInstance).save({ domain: here, name: 'published', state: 'issued', assigneeRoles: [finance, support] })
71
+ const read = await db.getRepository(ActivityInstance).findOne({ where: { id: saved.id } })
72
+ delete read.assigneeRoleIds
73
+
74
+ expect([...(await withPoolIds(read, db.manager)).assigneeRoleIds].sort()).toEqual([finance.id, support.id].sort())
75
+ })
76
+
77
+ it('a case published with its pool in hand carries those ids - the new pool, not a read before', async () => {
78
+ const published = await withPoolIds({ id: 'x', assigneeRoleIds: ['old'], assigneeRoles: [{ id: 'new' }] } as any, db.manager)
79
+
80
+ expect(published.assigneeRoleIds).toEqual(['new'])
81
+ })
@@ -128,7 +128,8 @@ describe('one instance by id', () => {
128
128
  id: 'i-1',
129
129
  domainId: 'd-here',
130
130
  creatorId: 'u-operator',
131
- assigneeRoleId: 'r-waiting'
131
+ /* The reader holds only the second role of the pool. */
132
+ assigneeRoleIds: ['r-other', 'r-waiting']
132
133
  })
133
134
  mockRows.set('ActivityThread:*', [{ id: 't-1', assigneeId: 'u-worker' }])
134
135
  })
@@ -151,7 +152,7 @@ describe('one instance by id', () => {
151
152
  })
152
153
 
153
154
  it('the waiting or supervisory role counts only when held in this domain', async () => {
154
- mockRows.set('ActivityInstance:i-1', { id: 'i-1', domainId: 'd-here', assigneeRoleId: 'r-waiting', supervisoryRoleId: 'r-super' })
155
+ mockRows.set('ActivityInstance:i-1', { id: 'i-1', domainId: 'd-here', assigneeRoleIds: ['r-other', 'r-waiting'], supervisoryRoleId: 'r-super' })
155
156
 
156
157
  await expect(read(contextFor('u-elsewhere', ['r-waiting', 'r-super'], 'd-other'))).rejects.toThrow(
157
158
  'error.activity-case not a participant'
@@ -52,20 +52,26 @@ const resolve = (item: any) =>
52
52
 
53
53
  it.each([
54
54
  ['Employee', 'employee'],
55
- ['Department', 'department'],
56
- ['Role', 'role']
55
+ ['Department', 'department']
57
56
  ])('an entry of type %s names the person of this domain', async (type, key) => {
58
57
  expect(await resolve({ type, value: mine[key].id })).toMatchObject({ id: mine.user.id })
59
58
  })
60
59
 
61
60
  it.each([
62
61
  ['Employee', 'employee'],
63
- ['Department', 'department'],
64
- ['Role', 'role']
62
+ ['Department', 'department']
65
63
  ])("an entry of type %s naming another tenant's is nobody here", async (type, key) => {
66
64
  expect(await resolve({ type, value: theirs[key].id })).toBeFalsy()
67
65
  })
68
66
 
67
+ /* A role is not one person - it answered the role's first user (ruling 2026-10-20, assignee-input.ts). */
68
+ it.each([
69
+ ['of this domain', () => mine],
70
+ ["of another tenant's", () => theirs]
71
+ ])('an entry of type Role %s is refused, never one person', async (_, whose) => {
72
+ await expect(resolve({ type: 'Role', value: whose().role.id })).rejects.toMatchObject({ code: 'role-is-not-an-assignee' })
73
+ })
74
+
69
75
  describe('assign', () => {
70
76
  const assignTo = (instance: any, emails: string[]) =>
71
77
  db.transaction((tx: any) =>