@things-factory/worklist 10.1.37 → 10.1.38

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 (113) hide show
  1. package/dist-client/components/activity-instance-preview.js +2 -13
  2. package/dist-client/components/activity-instance-preview.js.map +1 -1
  3. package/dist-client/components/activity-thread-timeline.d.ts +2 -2
  4. package/dist-client/components/can-pick.d.ts +15 -0
  5. package/dist-client/components/can-pick.js +16 -0
  6. package/dist-client/components/can-pick.js.map +1 -0
  7. package/dist-client/pages/activity-catalog/activity-detail-page.d.ts +23 -2
  8. package/dist-client/pages/activity-catalog/activity-detail-page.js +69 -10
  9. package/dist-client/pages/activity-catalog/activity-detail-page.js.map +1 -1
  10. package/dist-client/pages/activity-catalog/activity-panels.d.ts +28 -0
  11. package/dist-client/pages/activity-catalog/activity-panels.js +19 -0
  12. package/dist-client/pages/activity-catalog/activity-panels.js.map +1 -1
  13. package/dist-client/pages/activity-instance/activity-instance-start-page.d.ts +1 -0
  14. package/dist-client/pages/activity-instance/activity-instance-start-page.js +33 -1
  15. package/dist-client/pages/activity-instance/activity-instance-start-page.js.map +1 -1
  16. package/dist-client/pages/activity-instance/discard-offer.d.ts +11 -0
  17. package/dist-client/pages/activity-instance/discard-offer.js +11 -0
  18. package/dist-client/pages/activity-instance/discard-offer.js.map +1 -0
  19. package/dist-client/pages/activity-thread/activity-thread-view.d.ts +1 -1
  20. package/dist-client/pages/todo/approval-done-list-page.d.ts +5 -1
  21. package/dist-client/pages/todo/approval-done-list-page.js +8 -1
  22. package/dist-client/pages/todo/approval-done-list-page.js.map +1 -1
  23. package/dist-client/pages/todo/approval-pending-list-page.d.ts +5 -1
  24. package/dist-client/pages/todo/approval-pending-list-page.js +8 -1
  25. package/dist-client/pages/todo/approval-pending-list-page.js.map +1 -1
  26. package/dist-client/pages/todo/done-list-page.d.ts +5 -1
  27. package/dist-client/pages/todo/done-list-page.js +8 -1
  28. package/dist-client/pages/todo/done-list-page.js.map +1 -1
  29. package/dist-client/pages/todo/draft-list-page.d.ts +5 -1
  30. package/dist-client/pages/todo/draft-list-page.js +18 -12
  31. package/dist-client/pages/todo/draft-list-page.js.map +1 -1
  32. package/dist-client/pages/todo/list-thumbnail.d.ts +25 -0
  33. package/dist-client/pages/todo/list-thumbnail.js +25 -0
  34. package/dist-client/pages/todo/list-thumbnail.js.map +1 -0
  35. package/dist-client/pages/todo/todo-list-page.d.ts +5 -1
  36. package/dist-client/pages/todo/todo-list-page.js +8 -1
  37. package/dist-client/pages/todo/todo-list-page.js.map +1 -1
  38. package/dist-client/pages/work-door/door-links.d.ts +30 -0
  39. package/dist-client/pages/work-door/door-links.js +30 -0
  40. package/dist-client/pages/work-door/door-links.js.map +1 -0
  41. package/dist-client/pages/work-door/work-doors-page.js +15 -5
  42. package/dist-client/pages/work-door/work-doors-page.js.map +1 -1
  43. package/dist-client/route.d.ts +1 -1
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/dist-server/controllers/activity-approval/delegate.js +27 -2
  46. package/dist-server/controllers/activity-approval/delegate.js.map +1 -1
  47. package/dist-server/controllers/activity-approval/delegation.d.ts +31 -0
  48. package/dist-server/controllers/activity-approval/delegation.js +31 -0
  49. package/dist-server/controllers/activity-approval/delegation.js.map +1 -0
  50. package/dist-server/controllers/activity-instance/discard-draft.d.ts +22 -0
  51. package/dist-server/controllers/activity-instance/discard-draft.js +54 -0
  52. package/dist-server/controllers/activity-instance/discard-draft.js.map +1 -0
  53. package/dist-server/controllers/activity-read/assert-may-read.d.ts +15 -0
  54. package/dist-server/controllers/activity-read/assert-may-read.js +99 -0
  55. package/dist-server/controllers/activity-read/assert-may-read.js.map +1 -0
  56. package/dist-server/controllers/activity-read/participates.d.ts +41 -0
  57. package/dist-server/controllers/activity-read/participates.js +42 -0
  58. package/dist-server/controllers/activity-read/participates.js.map +1 -0
  59. package/dist-server/controllers/activity-thread/delegate.js +23 -1
  60. package/dist-server/controllers/activity-thread/delegate.js.map +1 -1
  61. package/dist-server/service/activity/activity-mutation.js +1 -0
  62. package/dist-server/service/activity/activity-mutation.js.map +1 -1
  63. package/dist-server/service/activity-approval/activity-approval-query.js +7 -1
  64. package/dist-server/service/activity-approval/activity-approval-query.js.map +1 -1
  65. package/dist-server/service/activity-approval/activity-approval-subscription.js +2 -11
  66. package/dist-server/service/activity-approval/activity-approval-subscription.js.map +1 -1
  67. package/dist-server/service/activity-instance/activity-instance-mutation.js +12 -20
  68. package/dist-server/service/activity-instance/activity-instance-mutation.js.map +1 -1
  69. package/dist-server/service/activity-instance/activity-instance-query.js +9 -2
  70. package/dist-server/service/activity-instance/activity-instance-query.js.map +1 -1
  71. package/dist-server/service/activity-instance/activity-instance-subscription.js +4 -12
  72. package/dist-server/service/activity-instance/activity-instance-subscription.js.map +1 -1
  73. package/dist-server/service/activity-instance/index.d.ts +1 -2
  74. package/dist-server/service/activity-instance/index.js +1 -2
  75. package/dist-server/service/activity-instance/index.js.map +1 -1
  76. package/dist-server/service/activity-template/activity-template-mutation.js +6 -0
  77. package/dist-server/service/activity-template/activity-template-mutation.js.map +1 -1
  78. package/dist-server/service/activity-thread/activity-thread-mutation.d.ts +0 -3
  79. package/dist-server/service/activity-thread/activity-thread-mutation.js +0 -19
  80. package/dist-server/service/activity-thread/activity-thread-mutation.js.map +1 -1
  81. package/dist-server/service/activity-thread/activity-thread-query.js +7 -1
  82. package/dist-server/service/activity-thread/activity-thread-query.js.map +1 -1
  83. package/dist-server/service/activity-thread/activity-thread-subscription.js +2 -8
  84. package/dist-server/service/activity-thread/activity-thread-subscription.js.map +1 -1
  85. package/dist-server/service/index.d.ts +2 -2
  86. package/dist-server/service/subscription/subscription-filters.d.ts +36 -0
  87. package/dist-server/service/subscription/subscription-filters.js +41 -0
  88. package/dist-server/service/subscription/subscription-filters.js.map +1 -0
  89. package/dist-server/service/work-door/work-door-declaration.d.ts +15 -0
  90. package/dist-server/service/work-door/work-door-declaration.js +18 -0
  91. package/dist-server/service/work-door/work-door-declaration.js.map +1 -1
  92. package/dist-server/service/work-door/work-door-resolver.d.ts +3 -0
  93. package/dist-server/service/work-door/work-door-resolver.js +21 -1
  94. package/dist-server/service/work-door/work-door-resolver.js.map +1 -1
  95. package/dist-server/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +10 -10
  97. package/tests/delegate-approval.test.ts +112 -0
  98. package/tests/discard-draft.test.ts +145 -0
  99. package/tests/may-read-case.test.ts +166 -0
  100. package/tests/open-doors.test.ts +274 -0
  101. package/tests/published-shape.test.ts +85 -0
  102. package/tests/work-door-not-used-refuse.test.ts +110 -0
  103. package/translations/en.json +15 -2
  104. package/translations/ja.json +15 -2
  105. package/translations/ko.json +15 -2
  106. package/translations/ms.json +15 -2
  107. package/translations/zh.json +15 -2
  108. package/dist-server/service/activity-instance/activity-instance-history-query.d.ts +0 -12
  109. package/dist-server/service/activity-instance/activity-instance-history-query.js +0 -79
  110. package/dist-server/service/activity-instance/activity-instance-history-query.js.map +0 -1
  111. package/dist-server/service/activity-instance/activity-instance-history-type.d.ts +0 -5
  112. package/dist-server/service/activity-instance/activity-instance-history-type.js +0 -21
  113. package/dist-server/service/activity-instance/activity-instance-history-type.js.map +0 -1
@@ -0,0 +1,274 @@
1
+ import { existsSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ import { buildSchema } from 'type-graphql'
5
+ import { GraphQLSchema } from 'graphql'
6
+
7
+ /**
8
+ * Which of worklist's doors are open, and why each one is allowed to be.
9
+ *
10
+ * ── The thing this catches ──────────────────────────────────────────────────
11
+ * `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
12
+ * indistinguishable from a field somebody decided to open. `deleteActivities` went in that way: its
13
+ * neighbours `deleteActivity` and `createActivity` ask for the activity privilege, it does not.
14
+ *
15
+ * Every open field must be on a list below, with its reason. A new open field fails. A field that
16
+ * gets closed fails too, until it is moved to CLOSED - so closing is visible as well.
17
+ *
18
+ * ── Why measured and not read off the source ────────────────────────────────
19
+ * The schema is built from `service/index.ts`'s own `resolverClasses`, the list the server boots
20
+ * from, and the directive fills the privilege table while it walks it. A pattern over the files
21
+ * answered 45 where the schema has 47 - it missed fields declared with `@Args(type => ListParam)`.
22
+ *
23
+ * ── Why the compiled output ──────────────────────────────────────────────────
24
+ * The entities use type-graphql decorators with no explicit type, which need
25
+ * `emitDecoratorMetadata`; jest's babel transform does not emit it (auth-base `tests/compiled.ts`).
26
+ */
27
+
28
+ const DIST = join(__dirname, '..', 'dist-server')
29
+ const AUTH_BASE_DIST = join(__dirname, '..', '..', 'auth-base', 'dist-server')
30
+
31
+ function loadCompiled<T = any>(file: string): T {
32
+ if (!existsSync(file)) {
33
+ throw new Error(
34
+ `compiled output missing: ${file}\n` +
35
+ `this test reads the tsc output (see the header). build first:\n` +
36
+ ` yarn workspace @things-factory/worklist build:server`
37
+ )
38
+ }
39
+
40
+ return require(file)
41
+ }
42
+
43
+ const { schema: declared } = loadCompiled(join(DIST, 'service', 'index.js'))
44
+ const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled(
45
+ join(AUTH_BASE_DIST, 'service', 'privilege', 'privilege-directive.js')
46
+ )
47
+
48
+ type Root = 'Query' | 'Mutation' | 'Subscription'
49
+
50
+ /**
51
+ * Open on purpose. Each one answers about the caller, is a catalogue of this domain, or checks
52
+ * who may act inside its own body.
53
+ */
54
+ const OPEN_BY_DECISION: Record<Root, Record<string, string>> = {
55
+ Query: {
56
+ /* The caller's own. */
57
+ todoList: "the caller's open threads - myOpenThreadsWhere(user, roles)",
58
+ doneList: "threads the caller ended - at.assignee = the caller",
59
+ draftList: 'instances the caller made - ai.creator = the caller',
60
+ pickableActivityList: "instances waiting for a role the caller holds, or for nobody's role",
61
+ approvalPendingList: "approvals waiting for the caller or the caller's roles - myPendingApprovalsWhere",
62
+ approvalDoneList: 'approvals the caller judged - ap.approver = the caller',
63
+ approvalReferenceList: 'approvals the caller is a reference on - ap.approver = the caller',
64
+ approvalCompletedList: 'approvals the caller took part in - ap.approver = the caller',
65
+ activitySummary: "counts of the caller's own threads and approvals",
66
+ activityEvents: "the caller's own threads and approvals, for the calendar",
67
+
68
+ /* A catalogue of this domain - what can be started, not anybody's case. */
69
+ activities: 'activity definitions of this domain',
70
+ activity: 'one activity definition of this domain',
71
+ activityByName: 'one activity definition of this domain, by name',
72
+ startableActivities: 'definitions the caller may start here',
73
+ supervisableActivities: 'definitions the caller supervises here',
74
+ installableActivities: 'what the installed modules declare - the same for every domain',
75
+ installableActivity: 'one declaration of an installed module',
76
+ activityTemplates: 'activity templates of this domain',
77
+ activityTemplate: 'one activity template of this domain'
78
+ },
79
+ Mutation: {
80
+ /* Checked in the body: checkAuthority refuses anyone who does not hold the seat. */
81
+ startActivityThread: 'checkAuthority - only the assignee',
82
+ restartActivityThread: 'checkAuthority - only the assignee',
83
+ saveActivityThread: 'checkAuthority - only the assignee',
84
+ submitActivityThread: 'checkAuthority - only the assignee',
85
+ endActivityThread: 'checkAuthority - only the assignee',
86
+ abortActivityThread: 'checkAuthority - only the assignee',
87
+ delegateActivityThread: 'checkAuthority - only the assignee',
88
+ approveActivityApproval: 'checkAuthority - only the approver, never the issuer (self-approval)',
89
+ rejectActivityApproval: 'checkAuthority - only the approver',
90
+ saveActivityApproval: 'checkAuthority - only the approver',
91
+ delegateActivityApproval: 'checkAuthority - only the approver',
92
+
93
+ /* Checked in the body: entitlement.ts, with the reason in its header. */
94
+ draftActivityInstance: 'entitlement - the caller may start this activity',
95
+ issueActivityInstance: 'entitlement - the caller may start this activity; refuses an ungated issue',
96
+ pickActivityInstance: "entitlement - the instance waits for a role the caller holds",
97
+ deleteDraftActivityInstance:
98
+ 'entitlement - the writer or a manager, drafts only, the state checked in the delete itself (ruling 2026-09-24)'
99
+ },
100
+ Subscription: {
101
+ /* Each filter compares the event's domain first, then the person - test/subscription-filters.test.ts. */
102
+ activityThread: 'events of threads assigned to the caller, in this domain',
103
+ activityApproval: 'events of approvals waiting for the caller, in this domain',
104
+ activityInstance: "events of instances the caller made or whose role the caller holds, in this domain"
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Open and read in the body: one case by id. The thread screen and the approval screen open a case for
110
+ * the people working on it, who mostly hold no management privilege, so the body decides - the domain
111
+ * first, then the case's participants, else a named refusal (controllers/activity-read). Ruled
112
+ * 2026-09-24.
113
+ */
114
+ const READ_IN_THE_BODY: Record<Root, Record<string, string>> = {
115
+ Query: {
116
+ activityInstance:
117
+ 'assert-may-read - participants of the instance, or the activity-instance query privilege. Asked the privilege until 2026-09-24, which kept OPERATOR from reopening a draft of their own',
118
+ activityThread: 'assert-may-read - participants of the thread, or the activity-instance query privilege',
119
+ activityApproval: 'assert-may-read - read through its thread, the same people'
120
+ },
121
+ Mutation: {},
122
+ Subscription: {}
123
+ }
124
+
125
+ /**
126
+ * Closed doors, by name, so that reopening one is a visible act. All were open until 2026-09-24,
127
+ * reachable by anyone signed in to the domain.
128
+ *
129
+ * deleteActivities deleteActivity beside it already asked the activity privilege
130
+ * the six template writes definition writes asked a privilege; the templates they come from did not
131
+ * activityThreads every thread of the domain, while activityInstances asked a privilege
132
+ * activityApprovals every approval of the domain, likewise
133
+ */
134
+ const CLOSED: Record<Root, string[]> = {
135
+ Query: ['activityThreads', 'activityApprovals'],
136
+ Mutation: [
137
+ 'deleteActivities',
138
+ 'createActivityTemplate',
139
+ 'updateActivityTemplate',
140
+ 'updateMultipleActivityTemplate',
141
+ 'deleteActivityTemplate',
142
+ 'deleteActivityTemplates',
143
+ 'importActivityTemplates'
144
+ ],
145
+ Subscription: []
146
+ }
147
+
148
+ /**
149
+ * Taken out of the schema on 2026-09-24 because nothing called them. A door that comes back under the
150
+ * same name comes back open, so it is listed here to fail loudly instead.
151
+ *
152
+ * updateActivityThreadOutput overwrote any thread's output in the domain
153
+ * activityInstanceHistory(-ies) every instance history of the domain
154
+ */
155
+ const REMOVED: Record<Root, string[]> = {
156
+ Query: ['activityInstanceHistory', 'activityInstanceHistories'],
157
+ Mutation: ['updateActivityThreadOutput'],
158
+ Subscription: []
159
+ }
160
+
161
+ /*
162
+ * worklist-client declares deleteActivities, createActivity and the rest under the same names. Those are
163
+ * empty bodies that write nothing (a remote call that was never written), not this door, and they are
164
+ * not in this package's schema.
165
+ */
166
+
167
+ let schema: GraphQLSchema
168
+
169
+ beforeAll(async () => {
170
+ schema = await buildSchema({
171
+ resolvers: declared.resolverClasses,
172
+ validate: false,
173
+ /* The subscriptions name their own subscribe; the walk only needs a pubSub to accept them. */
174
+ pubSub: { publish() {}, subscribe() {} } as any
175
+ })
176
+
177
+ privilegeDirectiveResolver(schema)
178
+ })
179
+
180
+ function rootType(typeName: Root) {
181
+ return typeName === 'Query'
182
+ ? schema.getQueryType()
183
+ : typeName === 'Mutation'
184
+ ? schema.getMutationType()
185
+ : schema.getSubscriptionType()
186
+ }
187
+
188
+ function openFields(typeName: Root): string[] {
189
+ return Object.keys(rootType(typeName)?.getFields() || {}).filter(field => {
190
+ const declaredGate = privilegeOfField(typeName, field)
191
+
192
+ return declaredGate.known && declaredGate['gate'] === null
193
+ })
194
+ }
195
+
196
+ describe('every open door has a written reason', () => {
197
+ it.each(['Query', 'Mutation', 'Subscription'] as const)('%s', typeName => {
198
+ const listed = [...Object.keys(OPEN_BY_DECISION[typeName]), ...Object.keys(READ_IN_THE_BODY[typeName])]
199
+
200
+ expect(openFields(typeName).sort()).toEqual(listed.sort())
201
+ })
202
+
203
+ it('no door is on both lists', () => {
204
+ for (const typeName of ['Query', 'Mutation', 'Subscription'] as const) {
205
+ const both = Object.keys(OPEN_BY_DECISION[typeName]).filter(name => name in READ_IN_THE_BODY[typeName])
206
+
207
+ expect(both).toEqual([])
208
+ }
209
+ })
210
+ })
211
+
212
+ describe('the doors that were closed', () => {
213
+ const closed = Object.entries(CLOSED).flatMap(([typeName, fields]) => fields.map(field => [typeName, field]))
214
+
215
+ it.each(closed)('%s.%s', (typeName, field) => {
216
+ const declaredGate = privilegeOfField(typeName, field)
217
+
218
+ expect(declaredGate.known).toBe(true)
219
+ expect(declaredGate['gate']).not.toBeNull()
220
+ })
221
+ })
222
+
223
+ describe('the doors that were taken out', () => {
224
+ const removed = Object.entries(REMOVED).flatMap(([typeName, fields]) => fields.map(field => [typeName, field]))
225
+
226
+ it.each(removed)('%s.%s is not in the schema', (typeName, field) => {
227
+ expect(Object.keys(rootType(typeName as Root)?.getFields() || {})).not.toContain(field)
228
+ })
229
+ })
230
+
231
+ describe('a case read under its parent still rides on the parent', () => {
232
+ /*
233
+ * dataset's four screens (data-ooc, data-sample) read activityInstance { activityThreads {
234
+ * activityApprovals } } under their own records. Closing the root lists must not close this path:
235
+ * these are field resolvers, reached only through a parent the caller could already read.
236
+ */
237
+ it.each([
238
+ ['ActivityInstance', 'activityThreads'],
239
+ ['ActivityThread', 'activityApprovals']
240
+ ])('%s.%s is still a field', (typeName, field) => {
241
+ const type = schema.getType(typeName) as any
242
+
243
+ /*
244
+ * Only that the field is there. A @privilege on a field resolver does not reach the schema at all
245
+ * (measured 2026-09-24: no AST node, not in the privilege table), so a gate cannot close this path -
246
+ * only its body can. tests/may-read-case.test.ts calls the two bodies as a stranger and expects rows.
247
+ */
248
+ expect(type?.getFields?.()[field]).toBeDefined()
249
+ })
250
+ })
251
+
252
+ describe('what the walk itself has to be true for', () => {
253
+ it('reaches the whole schema, not a handful of fields', () => {
254
+ /* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
255
+ expect(Object.keys(schema.getQueryType()?.getFields() || {}).length).toBeGreaterThan(25)
256
+ expect(Object.keys(schema.getMutationType()?.getFields() || {}).length).toBeGreaterThan(35)
257
+ expect(Object.keys(schema.getSubscriptionType()?.getFields() || {}).length).toBe(3)
258
+ })
259
+
260
+ it('tells an undeclared field from one the schema does not have', () => {
261
+ expect(privilegeOfField('Query', 'noSuchField')).toEqual({ known: false })
262
+ })
263
+
264
+ it('the census, in plain numbers', () => {
265
+ /* 2026-09-24 after the rulings: 34 open by reason, 3 read in the body, 9 closed, 3 removed. */
266
+ const count = (lists: Record<Root, Record<string, string>>) =>
267
+ Object.keys(lists.Query).length + Object.keys(lists.Mutation).length
268
+
269
+ expect(count(OPEN_BY_DECISION)).toBe(34)
270
+ expect(count(READ_IN_THE_BODY)).toBe(3)
271
+ expect(CLOSED.Query.length + CLOSED.Mutation.length).toBe(9)
272
+ expect(REMOVED.Query.length + REMOVED.Mutation.length).toBe(3)
273
+ })
274
+ })
@@ -0,0 +1,85 @@
1
+ import { join } from 'path'
2
+
3
+ import { EventSubscriber } from 'typeorm'
4
+
5
+ /**
6
+ * What the three subscriptions filter on is really on the entity they are handed.
7
+ *
8
+ * Each subscriber publishes `event.entity`, and the filters (subscription-filters.ts) read its
9
+ * @RelationId fields - domainId first, then the person. If TypeORM left those empty on a new row,
10
+ * every new thread, approval and instance would be dropped without a sound, and the filter tests
11
+ * would still be green. Measured here with the real entities on sqlite, on insert and on update.
12
+ *
13
+ * Saved the way the controllers save: with the relation objects, not the ids.
14
+ */
15
+
16
+ const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
17
+ const DIST = join(__dirname, '..', 'dist-server', 'service')
18
+ const { ActivityApproval } = require(join(DIST, 'activity-approval', 'activity-approval.js'))
19
+ const { ActivityThread } = require(join(DIST, 'activity-thread', 'activity-thread.js'))
20
+ const { ActivityInstance } = require(join(DIST, 'activity-instance', 'activity-instance.js'))
21
+ const { Domain } = require('@things-factory/shell')
22
+ const { Role, User } = require('@things-factory/auth-base')
23
+
24
+ const seen: { on: string; name: string; entity: any }[] = []
25
+
26
+ class HandedToSubscribers {
27
+ afterInsert(event: any) {
28
+ event.entity && seen.push({ on: 'insert', name: event.metadata.name, entity: { ...event.entity } })
29
+ }
30
+ afterUpdate(event: any) {
31
+ event.entity && seen.push({ on: 'update', name: event.metadata.name, entity: { ...event.entity } })
32
+ }
33
+ }
34
+ EventSubscriber()(HandedToSubscribers)
35
+
36
+ let db: any
37
+ let domain: any
38
+ let user: any
39
+ let role: any
40
+
41
+ beforeAll(async () => {
42
+ db = createSqliteTestDataSource([ActivityApproval, ActivityThread, ActivityInstance], { subscribers: [HandedToSubscribers] })
43
+ await db.initialize()
44
+
45
+ domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
46
+ user = await db.getRepository(User).save({ name: 'u', email: 'u@example.com', password: 'x', domains: [domain] })
47
+ role = await db.getRepository(Role).save({ name: 'r', domain })
48
+ })
49
+
50
+ afterAll(async () => db?.destroy())
51
+
52
+ beforeEach(() => (seen.length = 0))
53
+
54
+ const handed = (name: string, on: string) => seen.find(one => one.name === name && one.on === on)?.entity
55
+
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' })
58
+
59
+ expect(handed('ActivityInstance', 'insert')).toMatchObject({ domainId: domain.id, creatorId: user.id, assigneeRoleId: role.id })
60
+ })
61
+
62
+ it('a new thread carries its domain and its assignee', async () => {
63
+ await db.getRepository(ActivityThread).save({ domain, name: 't', round: 1, assignee: user, state: 'assigned' })
64
+
65
+ expect(handed('ActivityThread', 'insert')).toMatchObject({ domainId: domain.id, assigneeId: user.id })
66
+ })
67
+
68
+ it('a new approval carries its domain and its approver; so does an update of a loaded one', async () => {
69
+ const thread = await db.getRepository(ActivityThread).save({ domain, name: 't2', round: 1, state: 'started' })
70
+ const saved = await db.getRepository(ActivityApproval).save({
71
+ domain,
72
+ activityThread: thread,
73
+ approver: user,
74
+ order: 1,
75
+ round: 1,
76
+ judgment: 'pending'
77
+ })
78
+
79
+ expect(handed('ActivityApproval', 'insert')).toMatchObject({ domainId: domain.id, approverId: user.id })
80
+
81
+ const loaded = await db.getRepository(ActivityApproval).findOne({ where: { id: saved.id } })
82
+ await db.getRepository(ActivityApproval).save({ ...loaded, judgment: 'approved' })
83
+
84
+ expect(handed('ActivityApproval', 'update')).toMatchObject({ domainId: domain.id, approverId: user.id })
85
+ })
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ /**
5
+ * A door that refuses on an empty approval line cannot be declared unused (ADR-0077 decision 3).
6
+ *
7
+ * "We do not use this door" means the work runs with no approval step. `whenNoLine: 'refuse'` means the
8
+ * work cannot stand without one. Accepted together, the door screen shows the door as off while the
9
+ * product still demands approval - and a product that honoured it would scrap, release a hold or lower
10
+ * a control unapproved.
11
+ *
12
+ * Both sides of the axis are walked through the real resolver on sqlite, inside a real transaction:
13
+ * the refusing doors are refused and write nothing, the `run` / `as-established` / assignment doors are
14
+ * accepted as before.
15
+ */
16
+
17
+ const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
18
+ const DIST = join(__dirname, '..', 'dist-server', 'service', 'work-door')
19
+ const { WorkDoorResolver } = require(join(DIST, 'work-door-resolver.js'))
20
+ const { WorkDoorNotUsed } = require(join(DIST, 'work-door-not-used.js'))
21
+ const { WorkDoorJournal } = require(join(DIST, 'work-door-journal.js'))
22
+ const { registerWorkDoors, whyNotUsedCannotBeDeclared } = require(join(DIST, 'work-door-declaration.js'))
23
+ const { Activity } = require(join(__dirname, '..', 'dist-server', 'service', 'activity', 'activity.js'))
24
+ const { Domain, addDataSource, removeDataSource } = require('@things-factory/shell')
25
+ const { User } = require('@things-factory/auth-base')
26
+
27
+ const REFUSING = [
28
+ { name: 'Scrap', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'irreversible' },
29
+ { name: 'Release', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'approval-is-the-act' },
30
+ { name: 'Defer', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'lowers-a-control' }
31
+ ]
32
+ const ACCEPTED = [
33
+ { name: 'Priority', labelKey: 'x', kind: 'approval', whenNoLine: 'run' },
34
+ { name: 'Withdraw', labelKey: 'x', kind: 'approval', whenNoLine: 'as-established', because: 'approval-is-the-act' },
35
+ { name: 'Dispatch', labelKey: 'x', kind: 'assignment' }
36
+ ]
37
+
38
+ let db: any
39
+ let domain: any
40
+ let user: any
41
+
42
+ beforeAll(async () => {
43
+ registerWorkDoors({ module: 'not-used-test', doors: [...REFUSING, ...ACCEPTED] as any })
44
+
45
+ db = createSqliteTestDataSource([Activity, WorkDoorNotUsed, WorkDoorJournal])
46
+ await db.initialize()
47
+ /* After saving, the resolver answers the whole door list, and that read goes through the default source. */
48
+ addDataSource('default', db)
49
+
50
+ domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
51
+ user = await db.getRepository(User).save({ name: 'u', email: 'u@example.com', password: 'x', domains: [domain] })
52
+ })
53
+
54
+ afterAll(async () => {
55
+ removeDataSource('default')
56
+ await db?.destroy()
57
+ })
58
+
59
+ const declare = (doorName: string) =>
60
+ db.transaction((tx: any) =>
61
+ new WorkDoorResolver().declareWorkDoorNotUsed(doorName, 'we do not do this here', {
62
+ state: { domain, user, tx },
63
+ t: (key: string) => key
64
+ } as any)
65
+ )
66
+
67
+ const declared = (doorName: string) => db.getRepository(WorkDoorNotUsed).count({ where: { doorName } })
68
+
69
+ describe('the judgement', () => {
70
+ it('★ a refusing door answers its reason; the others answer nothing', () => {
71
+ expect(REFUSING.map(door => whyNotUsedCannotBeDeclared(door as any))).toEqual([
72
+ 'irreversible',
73
+ 'approval-is-the-act',
74
+ 'lowers-a-control'
75
+ ])
76
+ expect(ACCEPTED.map(door => whyNotUsedCannotBeDeclared(door as any))).toEqual([undefined, undefined, undefined])
77
+ })
78
+ })
79
+
80
+ describe('declaring a door unused, through the resolver', () => {
81
+ it.each(REFUSING.map(door => [door.name, door.because]))(
82
+ '★ %s refuses on an empty line, so it cannot be turned off — the refusal names the reason (%s)',
83
+ async (name, because) => {
84
+ await expect(declare(name as string)).rejects.toMatchObject({ code: `work-door-not-used-refused-${because}` })
85
+ expect(await declared(name as string)).toBe(0)
86
+ }
87
+ )
88
+
89
+ it.each(ACCEPTED.map(door => [door.name, door.whenNoLine ?? 'assignment']))(
90
+ '%s (%s) is accepted as before',
91
+ async name => {
92
+ await declare(name as string)
93
+ expect(await declared(name as string)).toBe(1)
94
+ }
95
+ )
96
+ })
97
+
98
+ describe('every language says it', () => {
99
+ const languages = ['en', 'ko', 'ja', 'zh', 'ms']
100
+
101
+ it.each(languages)('★ %s has a sentence for each reason — a missing key prints the key', language => {
102
+ const words = JSON.parse(readFileSync(join(__dirname, '..', 'translations', `${language}.json`), 'utf-8'))
103
+
104
+ for (const door of REFUSING) {
105
+ const sentence = words[`error.work-door-not-used-refused-${door.because}`]
106
+
107
+ expect(sentence).toEqual(expect.stringContaining('{name}'))
108
+ }
109
+ })
110
+ })
@@ -1,4 +1,6 @@
1
1
  {
2
+ "button.try-again": "try again",
3
+ "button.discard-draft": "discard draft",
2
4
  "button.abort": "abort",
3
5
  "button.activity-install": "use it here",
4
6
  "button.activity-set-approval-line": "set the approval line",
@@ -28,13 +30,18 @@
28
30
  "error.activity-approval is already terminated": "this approval task ({activityApproval}) has already been terminated ({actual}).",
29
31
  "error.activity-approval is reference only": "you are a reference on this item, so you cannot approve or reject it. you can leave a comment.",
30
32
  "error.activity-approval not found": "this approval task ({activityApproval}) cannot be found.",
33
+ "error.activity-approval delegate to self": "the task is already yours; choose someone else to hand it to.",
34
+ "error.activity-approval delegate outside domain": "that person is not in this site, so the task cannot be handed to them.",
35
+ "error.activity-approval delegate to issuer": "the person who raised this cannot approve it, so it cannot be handed to them.",
31
36
  "error.activity-approval taken by someone else": "someone else handled this approval first. reload the list to see where it stands.",
32
37
  "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.",
33
38
  "error.activity-instance is addressed to another role": "{id} is assigned to another role. only someone with that role can pick it.",
34
39
  "error.activity-instance is already issued": "this task ({id}) has already been issued with the {state} status.",
35
40
  "error.activity-instance is already terminated": "this task ({id}) has already been terminated with the {state} status.",
36
41
  "error.activity-instance not found": "this task ({activityInstance}) cannot be found.",
42
+ "error.activity-instance not a draft any more": "this draft has already gone out, so it cannot be thrown away.",
37
43
  "error.activity-instance should not be assigned": "this task ({id}) is already in {actual} status and cannot be assigned.",
44
+ "error.activity-case not a participant": "you do not take part in this case, so it cannot be opened here.",
38
45
  "error.activity-instance unknown state": "{state} is not a state a task can be in. available: {known}",
39
46
  "error.activity-thread is already terminated": "this task thread ({id}) has already been terminated with the {state} status.",
40
47
  "error.activity-thread not found": "this task thread ({activityThread}) cannot be found.",
@@ -47,6 +54,9 @@
47
54
  "error.approval line names a role that nobody holds": "{seats}: nobody holds the role named for that step. add someone to that role.",
48
55
  "error.not authorized to handle this task": "you are not authorized to perform this task.",
49
56
  "error.work-door-not-used-reason-missing": "enter why this task is not used.",
57
+ "error.work-door-not-used-refused-irreversible": "\"{name}\" cannot be turned off. what it does cannot be undone, so it only runs through approval. set its approval line instead.",
58
+ "error.work-door-not-used-refused-approval-is-the-act": "\"{name}\" cannot be turned off. its approval is the work itself. set its approval line instead.",
59
+ "error.work-door-not-used-refused-lowers-a-control": "\"{name}\" cannot be turned off. it lowers a control that stands, so it only runs through approval. set its approval line instead.",
50
60
  "error.work-door-unknown": "unknown task \"{name}\". use a task name from the status screen.",
51
61
  "field.activity-type": "activity type",
52
62
  "field.approval-changed-at": "last change",
@@ -214,12 +224,15 @@
214
224
  "label.what-it-was-judged-from": "what it was judged from",
215
225
  "label.yes": "yes",
216
226
  "menu.work-doors": "approval and assignment forms",
227
+ "text.discard-draft-confirm": "discard this draft?",
228
+ "text.discard-draft-for-good": "a discarded draft cannot be brought back.",
229
+ "text.draft-discarded": "the draft was discarded.",
217
230
  "text.activity-activated": "{name} is now on for this domain.",
218
231
  "text.activity-change-failed": "{name} was not changed: {why}",
219
232
  "text.activity-change-not-allowed": "you are not allowed to change {name}. ask an administrator for the activity privilege.",
220
233
  "text.activity-deactivated": "{name} is off. this work will no longer be raised.",
221
234
  "text.activity-draft-how": "enter a title and description, then choose save draft to open the editor.",
222
- "text.activity-draft-where": "drafts are in the drafts list and can be edited until you {action}.",
235
+ "text.activity-draft-where": "drafts are in the “raised by me” list and can be edited until you {action}.",
223
236
  "text.activity-guide-unwritten": "an explanation of this work has not been written yet.",
224
237
  "text.activity-in-use-here": "in use",
225
238
  "text.activity-line-none": "no approval line",
@@ -344,7 +357,7 @@
344
357
  "title.approval pending list": "my approval inbox",
345
358
  "title.assigned list": "assigned list",
346
359
  "title.done list": "done",
347
- "title.draft list": "drafts",
360
+ "title.draft list": "raised by me",
348
361
  "title.edit duration": "edit duration",
349
362
  "title.import activity-approval": "import approvals",
350
363
  "title.import activity-stats": "import task statistics",
@@ -1,4 +1,6 @@
1
1
  {
2
+ "button.try-again": "再試行",
3
+ "button.discard-draft": "下書きを破棄",
2
4
  "button.abort": "中断",
3
5
  "button.activity-install": "ここで使う",
4
6
  "button.activity-set-approval-line": "決裁ラインを決める",
@@ -28,13 +30,18 @@
28
30
  "error.activity-approval is already terminated": "この承認タスク({activityApproval})はすでに終了({actual})しています。",
29
31
  "error.activity-approval is reference only": "参照として受け取った件のため、承認・却下はできません。意見は残せます。",
30
32
  "error.activity-approval not found": "この承認タスク({activityApproval})が見つかりません。",
33
+ "error.activity-approval delegate to self": "すでにご自身の決裁です。渡す別の人を選んでください。",
34
+ "error.activity-approval delegate outside domain": "このサイトにいない人には渡せません。",
35
+ "error.activity-approval delegate to issuer": "起票した人はこの案件を承認できないため、その人には渡せません。",
31
36
  "error.activity-approval taken by someone else": "他の方が先に処理しました。一覧を再読み込みしてください。",
32
37
  "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.",
33
38
  "error.activity-instance is addressed to another role": "{id} は別の役割に割り当てられた業務です。その役割を持つ人だけが選択できます。",
34
39
  "error.activity-instance is already issued": "このタスク({id})はすでに{state}状態で発行されています。",
35
40
  "error.activity-instance is already terminated": "このタスク({id})はすでに{state}状態で終了しています。",
36
41
  "error.activity-instance not found": "このタスク({activityInstance})が見つかりません。",
42
+ "error.activity-instance not a draft any more": "この下書きはすでに発行されたため、破棄できません。",
37
43
  "error.activity-instance should not be assigned": "このタスク({id})はすでに{actual}状態のため、割り当てできません。",
44
+ "error.activity-case not a participant": "この案件に参加していないため、開けません。",
38
45
  "error.activity-instance unknown state": "{state} は業務の状態ではありません。使用できる状態: {known}",
39
46
  "error.activity-thread is already terminated": "このタスクスレッド({id})はすでに{state}状態で終了しています。",
40
47
  "error.activity-thread not found": "このタスクスレッド({activityThread})が見つかりません。",
@@ -47,6 +54,9 @@
47
54
  "error.approval line names a role that nobody holds": "{seats} の段階に指定した役割に人がいません。その役割に人を追加してください。",
48
55
  "error.not authorized to handle this task": "このタスクを処理する権限がありません。",
49
56
  "error.work-door-not-used-reason-missing": "使用しない理由を入力してください。",
57
+ "error.work-door-not-used-refused-irreversible": "「{name}」は無効にできません。元に戻せない業務のため、承認を経る必要があります。代わりに承認ラインを設定してください。",
58
+ "error.work-door-not-used-refused-approval-is-the-act": "「{name}」は無効にできません。この業務は承認そのものが作業です。代わりに承認ラインを設定してください。",
59
+ "error.work-door-not-used-refused-lowers-a-control": "「{name}」は無効にできません。設定されている統制を緩める業務のため、承認を経る必要があります。代わりに承認ラインを設定してください。",
50
60
  "error.work-door-unknown": "「{name}」は不明な業務です。状況画面の業務名を使用してください。",
51
61
  "field.activity-type": "業務タイプ",
52
62
  "field.approval-changed-at": "最終変更",
@@ -214,12 +224,15 @@
214
224
  "label.what-it-was-judged-from": "判断の根拠",
215
225
  "label.yes": "はい",
216
226
  "menu.work-doors": "承認・割当様式の状況",
227
+ "text.discard-draft-confirm": "この下書きを破棄しますか?",
228
+ "text.discard-draft-for-good": "破棄した下書きは元に戻せません。",
229
+ "text.draft-discarded": "下書きを破棄しました。",
217
230
  "text.activity-activated": "{name} をこの$t(term.domain)で使用するようにしました。",
218
231
  "text.activity-change-failed": "{name} を変更できませんでした: {why}",
219
232
  "text.activity-change-not-allowed": "{name} を変更する権限がありません。管理者に業務活動の権限を依頼してください。",
220
233
  "text.activity-deactivated": "{name} をオフにしました。この業務は今後上がりません。",
221
234
  "text.activity-draft-how": "タイトルと説明を入力し、下書き保存を選択すると編集画面に移動します。",
222
- "text.activity-draft-where": "下書きは「作成中」一覧にあり、{action} するまで編集できます。",
235
+ "text.activity-draft-where": "下書きは「自分の起案」一覧にあり、{action} するまで編集できます。",
223
236
  "text.activity-guide-unwritten": "この業務の説明はまだ用意されていません。",
224
237
  "text.activity-in-use-here": "使用中",
225
238
  "text.activity-line-none": "決裁ラインがありません",
@@ -344,7 +357,7 @@
344
357
  "title.approval pending list": "私の決裁箱",
345
358
  "title.assigned list": "マイ関連業務現況",
346
359
  "title.done list": "完了",
347
- "title.draft list": "作成中",
360
+ "title.draft list": "自分の起案",
348
361
  "title.edit duration": "所要時間編集",
349
362
  "title.import activity-approval": "決裁の取り込み",
350
363
  "title.import activity-stats": "業務統計の取り込み",