@things-factory/worklist 10.1.202 → 10.1.205

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 (130) hide show
  1. package/dist-client/index.d.ts +1 -0
  2. package/dist-client/index.js +1 -0
  3. package/dist-client/index.js.map +1 -1
  4. package/dist-client/pages/activity/activity-list-page.d.ts +3 -0
  5. package/dist-client/pages/activity/activity-list-page.js +25 -13
  6. package/dist-client/pages/activity/activity-list-page.js.map +1 -1
  7. package/dist-client/pages/activity/activity-partial-view.js +1 -1
  8. package/dist-client/pages/activity/activity-partial-view.js.map +1 -1
  9. package/dist-client/pages/activity/assignee-roles-cell.d.ts +32 -0
  10. package/dist-client/pages/activity/assignee-roles-cell.js +26 -0
  11. package/dist-client/pages/activity/assignee-roles-cell.js.map +1 -0
  12. package/dist-client/pages/activity-catalog/activity-catalog-page.js +1 -1
  13. package/dist-client/pages/activity-catalog/activity-catalog-page.js.map +1 -1
  14. package/dist-client/pages/todo/done-list-calendar-page.d.ts +2 -0
  15. package/dist-client/pages/todo/done-list-calendar-page.js +63 -64
  16. package/dist-client/pages/todo/done-list-calendar-page.js.map +1 -1
  17. package/dist-client/pages/work-door/door-sentence.d.ts +1 -1
  18. package/dist-client/pages/work-door/door-sentence.js +2 -2
  19. package/dist-client/pages/work-door/door-sentence.js.map +1 -1
  20. package/dist-client/tsconfig.tsbuildinfo +1 -1
  21. package/dist-client/types/activity-instance-type.d.ts +2 -2
  22. package/dist-client/types/activity-instance-type.js.map +1 -1
  23. package/dist-client/types/activity-instance.d.ts +1 -1
  24. package/dist-client/types/activity-instance.js.map +1 -1
  25. package/dist-client/types/activity.d.ts +1 -1
  26. package/dist-client/types/activity.js.map +1 -1
  27. package/dist-client/work-calendar-sources.d.ts +53 -0
  28. package/dist-client/work-calendar-sources.js +47 -0
  29. package/dist-client/work-calendar-sources.js.map +1 -0
  30. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.d.ts +2 -2
  31. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.js +2 -2
  32. package/dist-server/controllers/activity-approval/resolve-judgeable-seats.js.map +1 -1
  33. package/dist-server/controllers/activity-instance/abort.js +1 -1
  34. package/dist-server/controllers/activity-instance/abort.js.map +1 -1
  35. package/dist-server/controllers/activity-instance/assign.d.ts +2 -1
  36. package/dist-server/controllers/activity-instance/assign.js +104 -2
  37. package/dist-server/controllers/activity-instance/assign.js.map +1 -1
  38. package/dist-server/controllers/activity-instance/assignee-input.d.ts +25 -0
  39. package/dist-server/controllers/activity-instance/assignee-input.js +28 -0
  40. package/dist-server/controllers/activity-instance/assignee-input.js.map +1 -0
  41. package/dist-server/controllers/activity-instance/draft.js +7 -2
  42. package/dist-server/controllers/activity-instance/draft.js.map +1 -1
  43. package/dist-server/controllers/activity-instance/end.js +1 -1
  44. package/dist-server/controllers/activity-instance/end.js.map +1 -1
  45. package/dist-server/controllers/activity-instance/entitlement.d.ts +1 -1
  46. package/dist-server/controllers/activity-instance/entitlement.js +7 -5
  47. package/dist-server/controllers/activity-instance/entitlement.js.map +1 -1
  48. package/dist-server/controllers/activity-instance/issue.js +28 -7
  49. package/dist-server/controllers/activity-instance/issue.js.map +1 -1
  50. package/dist-server/controllers/activity-instance/pick.js +56 -4
  51. package/dist-server/controllers/activity-instance/pick.js.map +1 -1
  52. package/dist-server/controllers/activity-read/assert-may-read.js +2 -1
  53. package/dist-server/controllers/activity-read/assert-may-read.js.map +1 -1
  54. package/dist-server/controllers/activity-read/participates.d.ts +2 -2
  55. package/dist-server/controllers/activity-read/participates.js +2 -2
  56. package/dist-server/controllers/activity-read/participates.js.map +1 -1
  57. package/dist-server/controllers/activity-thread/check-authority.js +38 -5
  58. package/dist-server/controllers/activity-thread/check-authority.js.map +1 -1
  59. package/dist-server/controllers/common.d.ts +13 -0
  60. package/dist-server/controllers/common.js +43 -15
  61. package/dist-server/controllers/common.js.map +1 -1
  62. package/dist-server/service/activity/activity-history.d.ts +1 -2
  63. package/dist-server/service/activity/activity-history.js +4 -8
  64. package/dist-server/service/activity/activity-history.js.map +1 -1
  65. package/dist-server/service/activity/activity-mutation.js +39 -8
  66. package/dist-server/service/activity/activity-mutation.js.map +1 -1
  67. package/dist-server/service/activity/activity-query.d.ts +1 -1
  68. package/dist-server/service/activity/activity-query.js +9 -9
  69. package/dist-server/service/activity/activity-query.js.map +1 -1
  70. package/dist-server/service/activity/activity-type.d.ts +2 -2
  71. package/dist-server/service/activity/activity-type.js +12 -6
  72. package/dist-server/service/activity/activity-type.js.map +1 -1
  73. package/dist-server/service/activity/activity.d.ts +2 -2
  74. package/dist-server/service/activity/activity.js +15 -7
  75. package/dist-server/service/activity/activity.js.map +1 -1
  76. package/dist-server/service/activity/event-subscriber.js.map +1 -1
  77. package/dist-server/service/activity-instance/activity-instance-history.d.ts +1 -2
  78. package/dist-server/service/activity-instance/activity-instance-history.js +4 -8
  79. package/dist-server/service/activity-instance/activity-instance-history.js.map +1 -1
  80. package/dist-server/service/activity-instance/activity-instance-mutation.d.ts +1 -1
  81. package/dist-server/service/activity-instance/activity-instance-mutation.js +10 -5
  82. package/dist-server/service/activity-instance/activity-instance-mutation.js.map +1 -1
  83. package/dist-server/service/activity-instance/activity-instance-query.d.ts +1 -2
  84. package/dist-server/service/activity-instance/activity-instance-query.js +16 -25
  85. package/dist-server/service/activity-instance/activity-instance-query.js.map +1 -1
  86. package/dist-server/service/activity-instance/activity-instance-type.d.ts +2 -2
  87. package/dist-server/service/activity-instance/activity-instance-type.js +12 -6
  88. package/dist-server/service/activity-instance/activity-instance-type.js.map +1 -1
  89. package/dist-server/service/activity-instance/activity-instance.d.ts +2 -2
  90. package/dist-server/service/activity-instance/activity-instance.js +15 -7
  91. package/dist-server/service/activity-instance/activity-instance.js.map +1 -1
  92. package/dist-server/service/activity-instance/assignee-pool.d.ts +59 -0
  93. package/dist-server/service/activity-instance/assignee-pool.js +99 -0
  94. package/dist-server/service/activity-instance/assignee-pool.js.map +1 -0
  95. package/dist-server/service/activity-instance/event-subscriber.d.ts +9 -1
  96. package/dist-server/service/activity-instance/event-subscriber.js +20 -4
  97. package/dist-server/service/activity-instance/event-subscriber.js.map +1 -1
  98. package/dist-server/service/activity-summary/activity-summary-query.js +3 -3
  99. package/dist-server/service/activity-summary/activity-summary-query.js.map +1 -1
  100. package/dist-server/service/activity-thread/my-open-threads.d.ts +1 -1
  101. package/dist-server/service/activity-thread/my-open-threads.js +19 -4
  102. package/dist-server/service/activity-thread/my-open-threads.js.map +1 -1
  103. package/dist-server/service/subscription/subscription-filters.d.ts +3 -2
  104. package/dist-server/service/subscription/subscription-filters.js +6 -3
  105. package/dist-server/service/subscription/subscription-filters.js.map +1 -1
  106. package/dist-server/service/work-door/work-door-service.js +9 -6
  107. package/dist-server/service/work-door/work-door-service.js.map +1 -1
  108. package/dist-server/service/work-door/work-door-type.d.ts +1 -1
  109. package/dist-server/service/work-door/work-door-type.js +4 -4
  110. package/dist-server/service/work-door/work-door-type.js.map +1 -1
  111. package/dist-server/service/work-door/work-door.d.ts +3 -3
  112. package/dist-server/service/work-door/work-door.js +2 -2
  113. package/dist-server/service/work-door/work-door.js.map +1 -1
  114. package/dist-server/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +11 -11
  116. package/tests/assign-role-pool.test.ts +508 -0
  117. package/tests/assignee-pool-history.test.ts +81 -0
  118. package/tests/may-read-case.test.ts +3 -2
  119. package/tests/org-member-domain.test.ts +10 -4
  120. package/tests/pick-race.pg.test.ts +81 -3
  121. package/tests/pool-pick-waiting-thread.test.ts +254 -0
  122. package/tests/published-shape.test.ts +2 -2
  123. package/tests/role-pool-race.pg.test.ts +182 -0
  124. package/tests/work-door.test.ts +3 -3
  125. package/translations/en.json +6 -0
  126. package/translations/es.json +6 -0
  127. package/translations/ja.json +6 -0
  128. package/translations/ko.json +6 -0
  129. package/translations/ms.json +6 -0
  130. package/translations/zh.json +6 -0
@@ -7,7 +7,8 @@ import { join } from 'path'
7
7
  * between. Runs only when WORKLIST_PG_URL names a postgres server (see discard-versus-issue.pg.test.ts).
8
8
  *
9
9
  * A case waiting for one person must end with one thread, whoever wins; and one person pressing twice
10
- * must not hold it twice.
10
+ * must not hold it twice. A case in a pool waits with one unassigned thread: whoever takes it, by pick or
11
+ * by claim, takes that thread, and nothing is left waiting once the case has its people.
11
12
  */
12
13
 
13
14
  const url = process.env.WORKLIST_PG_URL
@@ -23,6 +24,7 @@ maybe('picking at once, on postgres', () => {
23
24
  const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
24
25
  const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
25
26
  const { pick } = require(join(DIST, 'controllers', 'activity-instance', 'pick.js'))
27
+ const { start } = require(join(DIST, 'controllers', 'activity-thread', 'start.js'))
26
28
  const { Domain } = require('@things-factory/shell')
27
29
  const { Role, User } = require('@things-factory/auth-base')
28
30
 
@@ -63,7 +65,7 @@ maybe('picking at once, on postgres', () => {
63
65
  name: `p${round}`,
64
66
  state: 'issued',
65
67
  activity,
66
- assigneeRole: role,
68
+ assigneeRoles: [role],
67
69
  threadsMin: 1,
68
70
  threadsMax: 1
69
71
  })
@@ -111,7 +113,7 @@ maybe('picking at once, on postgres', () => {
111
113
  name: `two${round}`,
112
114
  state: 'issued',
113
115
  activity,
114
- assigneeRole: role,
116
+ assigneeRoles: [role],
115
117
  threadsMin: 2,
116
118
  threadsMax: 2
117
119
  })
@@ -125,4 +127,80 @@ maybe('picking at once, on postgres', () => {
125
127
  expect(await threadsOf(instance)).toBe(2)
126
128
  }
127
129
  }, 120000)
130
+
131
+ /** The shape issue() and a pool assign make: the pool, threadsMin, one unassigned thread. */
132
+ const aPoolCase = async (name: string, threadsMin: number) => {
133
+ const instance = await db.getRepository(ActivityInstance).save({
134
+ domain,
135
+ name,
136
+ state: 'issued',
137
+ activity,
138
+ assigneeRoles: [role],
139
+ threadsMin,
140
+ threadsMax: threadsMin
141
+ })
142
+ const waiting = await db.getRepository(ActivityThread).save({ domain, activityInstance: instance, state: 'unassigned' })
143
+ return { instance, waiting }
144
+ }
145
+
146
+ const statesOf = async (instance: any) =>
147
+ (await db.getRepository(ActivityThread).find({ where: { activityInstance: { id: instance.id } } }))
148
+ .map((thread: any) => [thread.state, thread.assigneeId || null])
149
+ .sort((a: any, b: any) => String(a).localeCompare(String(b)))
150
+
151
+ it('two members picking a pool case that waits for one: one holds the waiting thread, nothing is left waiting', async () => {
152
+ for (let round = 0; round < 20; round++) {
153
+ const { instance, waiting } = await aPoolCase(`pool${round}`, 1)
154
+
155
+ const results = await Promise.allSettled([
156
+ db.transaction((tx: any) => pick(instance.id, as(first)(tx))),
157
+ db.transaction((tx: any) => pick(instance.id, as(second)(tx)))
158
+ ])
159
+
160
+ const won = results.findIndex(one => one.status === 'fulfilled')
161
+ expect(results.filter(one => one.status === 'fulfilled').length).toBe(1)
162
+ expect((results[won] as any).value.id).toBe(waiting.id)
163
+ expect(await statesOf(instance)).toEqual([['assigned', [first, second][won].id]])
164
+ }
165
+ }, 120000)
166
+
167
+ it('a pick and a claim of the waiting thread at once: exactly one wins, and it holds the one thread', async () => {
168
+ const endings: string[] = []
169
+
170
+ for (let round = 0; round < 20; round++) {
171
+ const { instance, waiting } = await aPoolCase(`claim${round}`, 1)
172
+
173
+ const [picked, claimed] = await Promise.allSettled([
174
+ db.transaction((tx: any) => pick(instance.id, as(first)(tx))),
175
+ db.transaction((tx: any) => start(waiting.id, {}, '', as(second)(tx)))
176
+ ])
177
+ endings.push(`pick ${picked.status} · claim ${claimed.status}`)
178
+
179
+ expect([picked.status, claimed.status].filter(status => status === 'fulfilled').length).toBe(1)
180
+ expect(await statesOf(instance)).toEqual(
181
+ picked.status === 'fulfilled' ? [['assigned', first.id]] : [['started', second.id]]
182
+ )
183
+ }
184
+
185
+ console.log('endings', [...new Set(endings)].join(' · '))
186
+ }, 120000)
187
+
188
+ it('two members picking a pool case that waits for two: both hold a thread, nothing is left waiting', async () => {
189
+ for (let round = 0; round < 10; round++) {
190
+ const { instance } = await aPoolCase(`pool-two${round}`, 2)
191
+
192
+ const results = await Promise.allSettled([
193
+ db.transaction((tx: any) => pick(instance.id, as(first)(tx))),
194
+ db.transaction((tx: any) => pick(instance.id, as(second)(tx)))
195
+ ])
196
+
197
+ expect(results.map(one => one.status)).toEqual(['fulfilled', 'fulfilled'])
198
+ expect(await statesOf(instance)).toEqual(
199
+ [
200
+ ['assigned', first.id],
201
+ ['assigned', second.id]
202
+ ].sort((a: any, b: any) => String(a).localeCompare(String(b)))
203
+ )
204
+ }
205
+ }, 120000)
128
206
  })
@@ -0,0 +1,254 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * A pool's waiting thread, taken by pick or by claim (2026-10-20).
5
+ *
6
+ * A case in a role's pool carries one unassigned thread, and the to-do list of every pool holder reads that
7
+ * thread (my-open-threads.ts). Pick made a new assigned thread beside it and left it there, so a case one
8
+ * member had taken stayed on everyone else's list. The rule now, whichever door is used:
9
+ *
10
+ * - the taker gets the waiting thread itself;
11
+ * - while the case still waits for more people (fewer valid threads than threadsMin), one waiting thread is
12
+ * left for the rest of the pool, and none once it has enough;
13
+ * - one person holds one thread of a case - a second take is refused by name, and the waiting thread is off
14
+ * the list of whoever already holds one.
15
+ *
16
+ * Real entities on sqlite, as assign-role-pool.test.ts.
17
+ */
18
+
19
+ /* Nobody here manages activity instances - the role is the only way in. */
20
+ jest.mock('@things-factory/auth-base', () => {
21
+ const actual = jest.requireActual('@things-factory/auth-base')
22
+ return { ...actual, checkPermission: async () => false }
23
+ })
24
+
25
+ const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
26
+ const DIST = join(__dirname, '..', 'dist-server')
27
+ const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
28
+ const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
29
+ const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
30
+ const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
31
+ const { myOpenThreadsWhere } = require(join(DIST, 'service', 'activity-thread', 'my-open-threads.js'))
32
+ const { assign } = require(join(DIST, 'controllers', 'activity-instance', 'assign.js'))
33
+ const { issue } = require(join(DIST, 'controllers', 'activity-instance', 'issue.js'))
34
+ const { pick } = require(join(DIST, 'controllers', 'activity-instance', 'pick.js'))
35
+ const { start } = require(join(DIST, 'controllers', 'activity-thread', 'start.js'))
36
+ const { Domain } = require('@things-factory/shell')
37
+ const { Role, User } = require('@things-factory/auth-base')
38
+
39
+ let db: any
40
+ let here: any
41
+ let manager: any
42
+ let first: any
43
+ let second: any
44
+ let third: any
45
+ let outsider: any
46
+ let pool: any
47
+ let elsewhere: any
48
+ let activity: any
49
+
50
+ beforeAll(async () => {
51
+ db = createSqliteTestDataSource([Activity, ActivityInstance, ActivityThread, ActivityApproval])
52
+ await db.initialize()
53
+
54
+ here = await db.getRepository(Domain).save({ name: 'here', subdomain: 'here' })
55
+
56
+ const person = (name: string) =>
57
+ db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status: 'activated', userType: 'user', domains: [here] })
58
+
59
+ manager = await person('manager')
60
+ first = await person('first')
61
+ second = await person('second')
62
+ third = await person('third')
63
+ outsider = await person('outsider')
64
+
65
+ pool = await db.getRepository(Role).save({ domain: here, name: 'finance', users: [first, second, third] })
66
+ elsewhere = await db.getRepository(Role).save({ domain: here, name: 'support', users: [outsider] })
67
+
68
+ activity = await db.getRepository(Activity).save({ domain: here, name: 'tax-invoice', activityType: 'standard', startingType: 'issue' })
69
+ })
70
+
71
+ afterAll(async () => db?.destroy())
72
+
73
+ const as = (user: any, tx: any) => ({ state: { domain: here, user, tx }, t: (key: string) => key }) as any
74
+
75
+ const pickAs = (user: any, instance: any) => db.transaction((tx: any) => pick(instance.id, as(user, tx)))
76
+ const claimAs = (user: any, thread: any) => db.transaction((tx: any) => start(thread.id, {}, '', as(user, tx)))
77
+
78
+ const reread = (instance: any) => db.getRepository(ActivityInstance).findOneBy({ id: instance.id })
79
+
80
+ /** [state, holder] of every thread of the case, holders by name, in a fixed order. */
81
+ const threadsOf = async (instance: any) => {
82
+ const threads = await db.getRepository(ActivityThread).find({ where: { activityInstance: { id: instance.id } } })
83
+ const names = new Map([first, second, third, outsider, manager].map((user: any) => [user.id, user.name]))
84
+ return threads.map((thread: any) => [thread.state, names.get(thread.assigneeId) || null]).sort((a: any, b: any) => String(a).localeCompare(String(b)))
85
+ }
86
+
87
+ const waitingOf = async (instance: any) =>
88
+ (await db.getRepository(ActivityThread).find({ where: { activityInstance: { id: instance.id }, state: 'unassigned' } }))[0]
89
+
90
+ /** How many times the case is on this person's to-do list - the list's own condition (my-open-threads.ts). */
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
+ /** The case as each pool member and the outsider see it on their lists. */
101
+ const lists = async (instance: any) => ({
102
+ first: await onTheListOf(first, [pool], instance),
103
+ second: await onTheListOf(second, [pool], instance),
104
+ third: await onTheListOf(third, [pool], instance),
105
+ outsider: await onTheListOf(outsider, [elsewhere], instance)
106
+ })
107
+
108
+ const issued = (threadsMin: number) =>
109
+ db.transaction((tx: any) => issue({ activityId: activity.id, assignees: [], assigneeRoles: [pool], threadsMin }, as(manager, tx)))
110
+
111
+ const assigned = async () => {
112
+ const instance = await db.getRepository(ActivityInstance).save({ domain: here, name: 'case', state: 'issued', activity })
113
+ await db.transaction((tx: any) => assign({ id: instance.id, assigneeRoles: [pool.id] }, as(manager, tx)))
114
+ return instance
115
+ }
116
+
117
+ describe('a pool waiting for one person', () => {
118
+ it.each([
119
+ ['issued with an assignee role', () => issued(1)],
120
+ ['put in a pool by assign', assigned]
121
+ ])('%s, picked by a member: the waiting thread is theirs, and the rest of the pool no longer sees the case', async (_, made) => {
122
+ const instance = await made()
123
+ const [waiting] = await threadsOf(instance)
124
+ expect(waiting).toEqual(['unassigned', null])
125
+
126
+ await pickAs(first, instance)
127
+
128
+ expect(await threadsOf(instance)).toEqual([['assigned', 'first']])
129
+ expect((await reread(instance)).state).toBe('assigned')
130
+ expect(await lists(instance)).toEqual({ first: 1, second: 0, third: 0, outsider: 0 })
131
+ })
132
+
133
+ it('pick returns the waiting thread itself, now held', async () => {
134
+ const instance = await issued(1)
135
+ const waiting = await waitingOf(instance)
136
+
137
+ const picked = await pickAs(first, instance)
138
+
139
+ expect(picked.id).toBe(waiting.id)
140
+ expect([picked.state, picked.assigneeId]).toEqual(['assigned', first.id])
141
+ })
142
+
143
+ it('a second member picking after it is refused by name and the case is left as it was', async () => {
144
+ const instance = await issued(1)
145
+ await pickAs(first, instance)
146
+
147
+ await expect(pickAs(second, instance)).rejects.toThrow('error.activity-instance should not be assigned')
148
+ expect(await threadsOf(instance)).toEqual([['assigned', 'first']])
149
+ })
150
+
151
+ it('claimed by a member: as before, one thread, theirs, off the rest of the pool', async () => {
152
+ const instance = await issued(1)
153
+
154
+ await claimAs(first, await waitingOf(instance))
155
+
156
+ expect(await threadsOf(instance)).toEqual([['started', 'first']])
157
+ expect(await lists(instance)).toEqual({ first: 1, second: 0, third: 0, outsider: 0 })
158
+ })
159
+
160
+ it('a case with no waiting thread is picked as before - a new thread for the picker', async () => {
161
+ const instance = await db.getRepository(ActivityInstance).save({
162
+ domain: here,
163
+ name: 'bare',
164
+ state: 'issued',
165
+ activity,
166
+ assigneeRoles: [pool],
167
+ threadsMin: 1
168
+ })
169
+
170
+ await pickAs(first, instance)
171
+
172
+ expect(await threadsOf(instance)).toEqual([['assigned', 'first']])
173
+ })
174
+ })
175
+
176
+ describe('a pool waiting for two people (threadsMin 2)', () => {
177
+ it('the first pick takes the waiting thread and leaves one for the rest; the second fills it and none is left', async () => {
178
+ const instance = await issued(2)
179
+ expect(await threadsOf(instance)).toEqual([['unassigned', null]])
180
+
181
+ await pickAs(first, instance)
182
+
183
+ expect(await threadsOf(instance)).toEqual([
184
+ ['assigned', 'first'],
185
+ ['unassigned', null]
186
+ ])
187
+ expect((await reread(instance)).state).toBe('unassigned')
188
+ /* first sees their own thread once - not the pool's waiting thread beside it. */
189
+ expect(await lists(instance)).toEqual({ first: 1, second: 1, third: 1, outsider: 0 })
190
+
191
+ await pickAs(second, instance)
192
+
193
+ expect(await threadsOf(instance)).toEqual([
194
+ ['assigned', 'first'],
195
+ ['assigned', 'second']
196
+ ])
197
+ expect((await reread(instance)).state).toBe('assigned')
198
+ expect(await lists(instance)).toEqual({ first: 1, second: 1, third: 0, outsider: 0 })
199
+ })
200
+
201
+ it('the same by claiming the waiting thread each time', async () => {
202
+ const instance = await issued(2)
203
+
204
+ await claimAs(first, await waitingOf(instance))
205
+
206
+ expect(await threadsOf(instance)).toEqual([
207
+ ['started', 'first'],
208
+ ['unassigned', null]
209
+ ])
210
+ expect(await lists(instance)).toEqual({ first: 1, second: 1, third: 1, outsider: 0 })
211
+
212
+ await claimAs(second, await waitingOf(instance))
213
+
214
+ expect(await threadsOf(instance)).toEqual([
215
+ ['started', 'first'],
216
+ ['started', 'second']
217
+ ])
218
+ expect(await lists(instance)).toEqual({ first: 1, second: 1, third: 0, outsider: 0 })
219
+ })
220
+
221
+ it('one picks, the next claims: two holders, nothing left waiting', async () => {
222
+ const instance = await issued(2)
223
+
224
+ await pickAs(first, instance)
225
+ await claimAs(second, await waitingOf(instance))
226
+
227
+ expect(await threadsOf(instance)).toEqual([
228
+ ['assigned', 'first'],
229
+ ['started', 'second']
230
+ ])
231
+ })
232
+
233
+ it('someone who holds a thread already is refused by name a second one, by pick or by claim', async () => {
234
+ const instance = await issued(2)
235
+ await pickAs(first, instance)
236
+
237
+ await expect(pickAs(first, instance)).rejects.toThrow('error.activity-instance already yours')
238
+ await expect(claimAs(first, await waitingOf(instance))).rejects.toThrow('error.activity-instance already yours')
239
+
240
+ expect(await threadsOf(instance)).toEqual([
241
+ ['assigned', 'first'],
242
+ ['unassigned', null]
243
+ ])
244
+ })
245
+
246
+ it('someone outside the pool is refused the waiting thread, and it stays waiting', async () => {
247
+ const instance = await issued(2)
248
+
249
+ await expect(pickAs(outsider, instance)).rejects.toThrow('error.activity-instance is addressed to another role')
250
+ await expect(claimAs(outsider, await waitingOf(instance))).rejects.toThrow('error.not authorized to handle this task')
251
+
252
+ expect(await threadsOf(instance)).toEqual([['unassigned', null]])
253
+ })
254
+ })
@@ -54,9 +54,9 @@ beforeEach(() => (seen.length = 0))
54
54
  const handed = (name: string, on: string) => seen.find(one => one.name === name && one.on === on)?.entity
55
55
 
56
56
  it('a new instance carries its domain, its maker and the role it waits for', async () => {
57
- await db.getRepository(ActivityInstance).save({ domain, name: 'i', creator: user, assigneeRole: role, state: 'issued' })
57
+ await db.getRepository(ActivityInstance).save({ domain, name: 'i', creator: user, assigneeRoles: [role], state: 'issued' })
58
58
 
59
- expect(handed('ActivityInstance', 'insert')).toMatchObject({ domainId: domain.id, creatorId: user.id, assigneeRoleId: role.id })
59
+ expect(handed('ActivityInstance', 'insert')).toMatchObject({ domainId: domain.id, creatorId: user.id, assigneeRoleIds: [role.id] })
60
60
  })
61
61
 
62
62
  it('a new thread carries its domain and its assignee', async () => {
@@ -0,0 +1,182 @@
1
+ import { join } from 'path'
2
+
3
+ /**
4
+ * Putting a case in a role's pool at the same moment as something else, on postgres (ADR-0091 decision 9).
5
+ *
6
+ * Every way into a waiting case takes the case's row first and its threads after - pick, a pool assign,
7
+ * a member claiming the pool's waiting thread. sqlite holds one connection and cannot run two transactions
8
+ * side by side, so what it says about this is no evidence either way. Runs only when WORKLIST_PG_URL names a
9
+ * postgres server (see discard-versus-issue.pg.test.ts):
10
+ *
11
+ * docker run -d --name wl-ux-pg -e POSTGRES_PASSWORD=wlux -e POSTGRES_USER=wlux -e POSTGRES_DB=wlux \
12
+ * -p 55433:5432 postgres:14-alpine
13
+ * WORKLIST_PG_URL=postgres://wlux:wlux@localhost:55433/wlux npx jest packages/worklist/tests/role-pool-race.pg.test.ts
14
+ *
15
+ * Each round must end one way or the other, and whoever lost is refused by name - a deadlock, two waiting
16
+ * threads, a pool wiped by a stale save, or someone outside the role holding the case is the defect.
17
+ */
18
+
19
+ const url = process.env.WORKLIST_PG_URL
20
+ const maybe = url ? describe : describe.skip
21
+
22
+ /* Nobody here manages activity instances - the role is the only way in. */
23
+ jest.mock('@things-factory/auth-base', () => {
24
+ const actual = jest.requireActual('@things-factory/auth-base')
25
+ return { ...actual, checkPermission: async () => false }
26
+ })
27
+
28
+ maybe('a role pool at the same moment as something else, on postgres', () => {
29
+ const { DataSource } = require('typeorm')
30
+ const { entityClosure } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
31
+ const { NamingStrategy } = require('@things-factory/shell/dist-server/initializers/naming-strategy.js')
32
+ const DIST = join(__dirname, '..', 'dist-server')
33
+ const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
34
+ const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
35
+ const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
36
+ const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
37
+ const { assign } = require(join(DIST, 'controllers', 'activity-instance', 'assign.js'))
38
+ const { pick } = require(join(DIST, 'controllers', 'activity-instance', 'pick.js'))
39
+ const { start } = require(join(DIST, 'controllers', 'activity-thread', 'start.js'))
40
+ const { Domain } = require('@things-factory/shell')
41
+ const { Role, User } = require('@things-factory/auth-base')
42
+
43
+ /* The refusals a loser may get - by name, never a deadlock or a constraint error. */
44
+ const NAMED = [
45
+ 'error.activity-approval taken by someone else',
46
+ 'error.activity-instance is addressed to another role',
47
+ 'error.activity-instance should not be assigned',
48
+ 'error.not authorized to handle this task'
49
+ ]
50
+
51
+ let db: any
52
+ let domain: any
53
+ let manager: any
54
+ let finance: any
55
+ let support: any
56
+ let inFinance: any
57
+ let inSupport: any
58
+ let outsider: any
59
+ let activity: any
60
+
61
+ beforeAll(async () => {
62
+ db = new DataSource({
63
+ type: 'postgres',
64
+ url,
65
+ synchronize: true,
66
+ dropSchema: true,
67
+ entities: entityClosure([Activity, ActivityInstance, ActivityThread, ActivityApproval]),
68
+ namingStrategy: new NamingStrategy()
69
+ })
70
+ await db.initialize()
71
+
72
+ domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
73
+ const person = (name: string) =>
74
+ db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status: 'activated', userType: 'user', domains: [domain] })
75
+ manager = await person('manager')
76
+ inFinance = await person('in-finance')
77
+ inSupport = await person('in-support')
78
+ outsider = await person('outsider')
79
+ finance = await db.getRepository(Role).save({ domain, name: 'finance', users: [inFinance] })
80
+ support = await db.getRepository(Role).save({ domain, name: 'support', users: [inSupport] })
81
+ activity = await db.getRepository(Activity).save({ domain, name: 'tax-invoice', activityType: 'standard', startingType: 'issue' })
82
+ }, 60000)
83
+
84
+ afterAll(async () => db?.destroy())
85
+
86
+ const as = (user: any) => (tx: any) => ({ state: { domain, user, tx }, t: (key: string) => key }) as any
87
+ const aCase = (name: string) => db.getRepository(ActivityInstance).save({ domain, name, state: 'issued', activity })
88
+ const threadsOf = (instance: any) => db.getRepository(ActivityThread).find({ where: { activityInstance: { id: instance.id } } })
89
+ const reread = (instance: any) => db.getRepository(ActivityInstance).findOne({ where: { id: instance.id }, relations: ['assigneeRoles'] })
90
+ const poolOf = (row: any) => (row?.assigneeRoles || []).map((role: any) => role.id).sort()
91
+ const toPool = (instance: any, role: any) => (tx: any) => assign({ id: instance.id, assigneeRoles: [role.id] }, as(manager)(tx))
92
+
93
+ /** Every loser was refused by one of the named refusals. */
94
+ const refusedByName = (results: PromiseSettledResult<any>[]) =>
95
+ results
96
+ .filter(one => one.status === 'rejected')
97
+ .forEach((one: any) => expect(NAMED).toContain(one.reason?.message))
98
+
99
+ it('two pool assigns at once: one waiting thread, in one of the two pools', async () => {
100
+ const endings: string[] = []
101
+
102
+ for (let round = 0; round < 20; round++) {
103
+ const instance = await aCase(`two-${round}`)
104
+
105
+ const results = await Promise.allSettled([db.transaction(toPool(instance, finance)), db.transaction(toPool(instance, support))])
106
+ refusedByName(results)
107
+
108
+ const threads = await threadsOf(instance)
109
+ const after = await reread(instance)
110
+ endings.push(`${results.filter(one => one.status === 'fulfilled').length} won`)
111
+
112
+ expect(threads.map((thread: any) => thread.state)).toEqual(['unassigned'])
113
+ expect([[finance.id], [support.id]]).toContainEqual(poolOf(after))
114
+ expect(after.threadsMin).toBe(1)
115
+ }
116
+
117
+ console.log('endings', [...new Set(endings)].join(' · '))
118
+ }, 120000)
119
+
120
+ it('a pool assign and a pick by someone outside the role at once: exactly one wins', async () => {
121
+ const endings: string[] = []
122
+
123
+ for (let round = 0; round < 20; round++) {
124
+ const instance = await aCase(`pick-${round}`)
125
+
126
+ const [assigned, picked] = await Promise.allSettled([
127
+ db.transaction(toPool(instance, finance)),
128
+ db.transaction((tx: any) => pick(instance.id, as(outsider)(tx)))
129
+ ])
130
+ refusedByName([assigned, picked])
131
+
132
+ const threads = await threadsOf(instance)
133
+ const after = await reread(instance)
134
+ endings.push(`assign ${assigned.status} · pick ${picked.status}`)
135
+
136
+ expect([assigned.status, picked.status].filter(status => status === 'fulfilled').length).toBe(1)
137
+
138
+ if (picked.status === 'fulfilled') {
139
+ expect(threads.map((thread: any) => [thread.state, thread.assigneeId])).toEqual([['assigned', outsider.id]])
140
+ expect(poolOf(after)).toEqual([])
141
+ } else {
142
+ expect(threads.map((thread: any) => thread.state)).toEqual(['unassigned'])
143
+ expect(poolOf(after)).toEqual([finance.id])
144
+ }
145
+ }
146
+
147
+ console.log('endings', [...new Set(endings)].join(' · '))
148
+ }, 120000)
149
+
150
+ it('moving a pool while a member of it claims the waiting thread: exactly one wins', async () => {
151
+ const endings: string[] = []
152
+
153
+ for (let round = 0; round < 20; round++) {
154
+ const instance = await aCase(`claim-${round}`)
155
+ await db.transaction(toPool(instance, finance))
156
+ const [waiting] = await threadsOf(instance)
157
+
158
+ const [moved, claimed] = await Promise.allSettled([
159
+ db.transaction(toPool(instance, support)),
160
+ db.transaction((tx: any) => start(waiting.id, {}, '', as(inFinance)(tx)))
161
+ ])
162
+ refusedByName([moved, claimed])
163
+
164
+ const threads = await threadsOf(instance)
165
+ const after = await reread(instance)
166
+ endings.push(`move ${moved.status} · claim ${claimed.status}`)
167
+
168
+ expect([moved.status, claimed.status].filter(status => status === 'fulfilled').length).toBe(1)
169
+ expect(threads.length).toBe(1)
170
+
171
+ if (claimed.status === 'fulfilled') {
172
+ expect([threads[0].state, threads[0].assigneeId]).toEqual(['started', inFinance.id])
173
+ expect(poolOf(after)).toEqual([finance.id])
174
+ } else {
175
+ expect([threads[0].state, threads[0].assigneeId || null]).toEqual(['unassigned', null])
176
+ expect(poolOf(after)).toEqual([support.id])
177
+ }
178
+ }
179
+
180
+ console.log('endings', [...new Set(endings)].join(' · '))
181
+ }, 120000)
182
+ })
@@ -154,18 +154,18 @@ describe('work door standing', () => {
154
154
  * door reaches nobody. One shared sentence would leave the reader with the wrong action.
155
155
  */
156
156
  it('says an unassigned door reaches nobody, not that it passes through', () => {
157
- const door = judgeDoor(assignment('작업 지시 배정', { installed: { assigneeRole: undefined } }))
157
+ const door = judgeDoor(assignment('작업 지시 배정', { installed: { assigneeRoles: [] } }))
158
158
 
159
159
  assert.equal(door.standing, 'off')
160
160
  assert.equal(door.reason, 'no-assignee')
161
161
  })
162
162
 
163
163
  it('reads a bound role as standing and names it', () => {
164
- const door = judgeDoor(assignment('정비 배정', { installed: { assigneeRole: 'MAINTENANCE-LEAD' } }))
164
+ const door = judgeDoor(assignment('정비 배정', { installed: { assigneeRoles: ['MAINTENANCE-LEAD', 'SHIFT-LEAD'] } }))
165
165
 
166
166
  assert.equal(door.standing, 'on')
167
167
  assert.equal(door.reason, 'assignee-bound')
168
- assert.equal(door.assigneeRole, 'MAINTENANCE-LEAD')
168
+ assert.deepEqual(door.assigneeRoles, ['MAINTENANCE-LEAD', 'SHIFT-LEAD'])
169
169
  })
170
170
 
171
171
  /** An approval line on an assignment door is not what fills it, and must not read as standing. */
@@ -34,10 +34,16 @@
34
34
  "error.activity-approval taken by someone else": "someone else handled this approval first. reload the list to see where it stands.",
35
35
  "error.activity-instance belongs to someone else": "{id} is someone else's draft. Only the person who wrote it, or someone who manages activity instances, may edit or issue it.",
36
36
  "error.activity-instance is addressed to another role": "{id} is assigned to another role. only someone with that role can pick it.",
37
+ "error.activity-instance already yours": "you already hold this task ({id}).",
37
38
  "error.activity-instance is already issued": "this task ({id}) has already been issued with the {state} status.",
38
39
  "error.activity-instance is already terminated": "this task ({id}) has already been terminated with the {state} status.",
39
40
  "error.activity-instance approval line is set": "this task already has its approval line; it is set on the task's detail screen.",
40
41
  "error.activity-instance assignee not in this domain": "these are not people of this site: {emails}",
42
+ "error.activity-instance a role is not an assignee": "a role cannot be an assignee - it would go to one person of the role. to give it to anyone of the role, set the assignee role.",
43
+ "error.activity-instance assign people or a role, not both": "assign it to people or to a role, not both at once.",
44
+ "error.activity-instance role not in this domain": "this role is not of this site: {role}",
45
+ "error.activity-instance assignee role named twice": "the same role is named more than once: {role}. name each role once.",
46
+ "error.activity-instance role has no members": "nobody holds the role {role}, so nobody would see this task. add people to the role first.",
41
47
  "error.activity-instance draft is gone": "this draft went out or was thrown away in the meantime. please read the list again.",
42
48
  "error.activity-instance not found": "this task ({activityInstance}) cannot be found.",
43
49
  "error.activity-instance not a draft any more": "this draft has already gone out, so it cannot be thrown away.",
@@ -34,10 +34,16 @@
34
34
  "error.activity-approval taken by someone else": "otra persona resolvió esta aprobación antes. vuelva a cargar la lista para ver su estado.",
35
35
  "error.activity-instance belongs to someone else": "{id} es un borrador de otra persona. Solo quien lo redactó, o quien gestiona las instancias de actividad, puede editarlo o emitirlo.",
36
36
  "error.activity-instance is addressed to another role": "{id} está asignado a otro rol. solo alguien con ese rol puede seleccionarlo.",
37
+ "error.activity-instance already yours": "ya tiene esta tarea ({id}).",
37
38
  "error.activity-instance is already issued": "esta tarea ({id}) ya se emitió con el estado {state}.",
38
39
  "error.activity-instance is already terminated": "esta tarea ({id}) ya finalizó con el estado {state}.",
39
40
  "error.activity-instance approval line is set": "esta tarea ya tiene su línea de aprobación; se configura en la pantalla de detalle de la tarea.",
40
41
  "error.activity-instance assignee not in this domain": "estas personas no pertenecen a este sitio: {emails}",
42
+ "error.activity-instance a role is not an assignee": "un rol no puede ser asignado: llegaría a una sola persona del rol. para dárselo a cualquiera del rol, indique el rol asignado.",
43
+ "error.activity-instance assign people or a role, not both": "asígnelo a personas o a un rol, no a ambos a la vez.",
44
+ "error.activity-instance role not in this domain": "este rol no pertenece a este sitio: {role}",
45
+ "error.activity-instance assignee role named twice": "el mismo rol aparece más de una vez: {role}. indique cada rol una sola vez.",
46
+ "error.activity-instance role has no members": "nadie tiene el rol {role}, así que nadie vería esta tarea. añada personas al rol primero.",
41
47
  "error.activity-instance draft is gone": "este borrador se envió o se descartó mientras tanto. vuelva a cargar la lista.",
42
48
  "error.activity-instance not found": "no se encuentra esta tarea ({activityInstance}).",
43
49
  "error.activity-instance not a draft any more": "este borrador ya se envió, así que no se puede descartar.",
@@ -34,10 +34,16 @@
34
34
  "error.activity-approval taken by someone else": "他の方が先に処理しました。一覧を再読み込みしてください。",
35
35
  "error.activity-instance belongs to someone else": "{id} is someone else's draft. Only the person who wrote it, or someone who manages activity instances, may edit or issue it.",
36
36
  "error.activity-instance is addressed to another role": "{id} は別の役割に割り当てられた業務です。その役割を持つ人だけが選択できます。",
37
+ "error.activity-instance already yours": "この業務({id})はすでに担当しています。",
37
38
  "error.activity-instance is already issued": "このタスク({id})はすでに{state}状態で発行されています。",
38
39
  "error.activity-instance is already terminated": "このタスク({id})はすでに{state}状態で終了しています。",
39
40
  "error.activity-instance approval line is set": "この業務は決裁ラインが決まっています。決裁ラインは業務詳細で変更します。",
40
41
  "error.activity-instance assignee not in this domain": "このサイトの人ではありません: {emails}",
42
+ "error.activity-instance a role is not an assignee": "割当者に役割は指定できません。役割の一人にしか届きません。その役割の誰にでも任せるには割当役割を指定してください。",
43
+ "error.activity-instance assign people or a role, not both": "人か役割のどちらか一方に割り当ててください。",
44
+ "error.activity-instance role not in this domain": "このサイトの役割ではありません: {role}",
45
+ "error.activity-instance assignee role named twice": "同じ役割が複数回指定されています: {role}。役割はそれぞれ一度だけ指定してください。",
46
+ "error.activity-instance role has no members": "役割 {role} に人がいないため、誰もこの業務を見られません。先に役割に人を追加してください。",
41
47
  "error.activity-instance draft is gone": "この下書きはその間に発行されたか破棄されました。一覧を読み直してください。",
42
48
  "error.activity-instance not found": "このタスク({activityInstance})が見つかりません。",
43
49
  "error.activity-instance not a draft any more": "この下書きはすでに発行されたため、破棄できません。",