@things-factory/inquiry 0.0.0-stage → 10.1.252

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 (106) hide show
  1. package/client/bootstrap.ts +27 -0
  2. package/client/components/inquiry-thread.ts +277 -0
  3. package/client/components/inquiry-todo.ts +36 -0
  4. package/client/components/inquiry-writer.ts +212 -0
  5. package/client/index.ts +2 -0
  6. package/client/inquiry-watch.ts +45 -0
  7. package/client/pages/inquiry-list.ts +218 -0
  8. package/client/route.ts +8 -0
  9. package/client/tsconfig.json +13 -0
  10. package/client/utils/inquiry-api.ts +103 -0
  11. package/client/utils/inquiry-badge.ts +46 -0
  12. package/client/utils/inquiry-words.ts +26 -0
  13. package/dist-client/bootstrap.d.ts +2 -0
  14. package/dist-client/bootstrap.js +25 -0
  15. package/dist-client/bootstrap.js.map +1 -0
  16. package/dist-client/components/inquiry-thread.d.ts +28 -0
  17. package/dist-client/components/inquiry-thread.js +303 -0
  18. package/dist-client/components/inquiry-thread.js.map +1 -0
  19. package/dist-client/components/inquiry-todo.d.ts +9 -0
  20. package/dist-client/components/inquiry-todo.js +43 -0
  21. package/dist-client/components/inquiry-todo.js.map +1 -0
  22. package/dist-client/components/inquiry-writer.d.ts +25 -0
  23. package/dist-client/components/inquiry-writer.js +233 -0
  24. package/dist-client/components/inquiry-writer.js.map +1 -0
  25. package/dist-client/index.d.ts +2 -0
  26. package/dist-client/index.js +3 -0
  27. package/dist-client/index.js.map +1 -0
  28. package/dist-client/inquiry-watch.d.ts +2 -0
  29. package/dist-client/inquiry-watch.js +41 -0
  30. package/dist-client/inquiry-watch.js.map +1 -0
  31. package/dist-client/pages/inquiry-list.d.ts +56 -0
  32. package/dist-client/pages/inquiry-list.js +241 -0
  33. package/dist-client/pages/inquiry-list.js.map +1 -0
  34. package/dist-client/route.d.ts +1 -0
  35. package/dist-client/route.js +9 -0
  36. package/dist-client/route.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -0
  38. package/dist-client/utils/inquiry-api.d.ts +73 -0
  39. package/dist-client/utils/inquiry-api.js +34 -0
  40. package/dist-client/utils/inquiry-api.js.map +1 -0
  41. package/dist-client/utils/inquiry-badge.d.ts +4 -0
  42. package/dist-client/utils/inquiry-badge.js +47 -0
  43. package/dist-client/utils/inquiry-badge.js.map +1 -0
  44. package/dist-client/utils/inquiry-words.d.ts +13 -0
  45. package/dist-client/utils/inquiry-words.js +22 -0
  46. package/dist-client/utils/inquiry-words.js.map +1 -0
  47. package/dist-server/attachment-reader.d.ts +1 -0
  48. package/dist-server/attachment-reader.js +21 -0
  49. package/dist-server/attachment-reader.js.map +1 -0
  50. package/dist-server/index.d.ts +2 -0
  51. package/dist-server/index.js +22 -0
  52. package/dist-server/index.js.map +1 -0
  53. package/dist-server/inquiry-activity.d.ts +38 -0
  54. package/dist-server/inquiry-activity.js +38 -0
  55. package/dist-server/inquiry-activity.js.map +1 -0
  56. package/dist-server/role-tasks.d.ts +2 -0
  57. package/dist-server/role-tasks.js +14 -0
  58. package/dist-server/role-tasks.js.map +1 -0
  59. package/dist-server/service/index.d.ts +7 -0
  60. package/dist-server/service/index.js +24 -0
  61. package/dist-server/service/index.js.map +1 -0
  62. package/dist-server/service/inquiry/index.d.ts +4 -0
  63. package/dist-server/service/inquiry/index.js +8 -0
  64. package/dist-server/service/inquiry/index.js.map +1 -0
  65. package/dist-server/service/inquiry/inquiry-resolver.d.ts +29 -0
  66. package/dist-server/service/inquiry/inquiry-resolver.js +407 -0
  67. package/dist-server/service/inquiry/inquiry-resolver.js.map +1 -0
  68. package/dist-server/service/inquiry/inquiry-rules.d.ts +88 -0
  69. package/dist-server/service/inquiry/inquiry-rules.js +157 -0
  70. package/dist-server/service/inquiry/inquiry-rules.js.map +1 -0
  71. package/dist-server/service/inquiry/inquiry-service.d.ts +35 -0
  72. package/dist-server/service/inquiry/inquiry-service.js +143 -0
  73. package/dist-server/service/inquiry/inquiry-service.js.map +1 -0
  74. package/dist-server/service/inquiry/inquiry-types.d.ts +18 -0
  75. package/dist-server/service/inquiry/inquiry-types.js +69 -0
  76. package/dist-server/service/inquiry/inquiry-types.js.map +1 -0
  77. package/dist-server/service/inquiry/inquiry.d.ts +48 -0
  78. package/dist-server/service/inquiry/inquiry.js +232 -0
  79. package/dist-server/service/inquiry/inquiry.js.map +1 -0
  80. package/dist-server/tsconfig.tsbuildinfo +1 -0
  81. package/package.json +51 -4
  82. package/server/attachment-reader.ts +18 -0
  83. package/server/index.ts +18 -0
  84. package/server/inquiry-activity.ts +35 -0
  85. package/server/role-tasks.ts +13 -0
  86. package/server/service/index.ts +19 -0
  87. package/server/service/inquiry/index.ts +5 -0
  88. package/server/service/inquiry/inquiry-resolver.ts +321 -0
  89. package/server/service/inquiry/inquiry-rules.ts +173 -0
  90. package/server/service/inquiry/inquiry-service.ts +143 -0
  91. package/server/service/inquiry/inquiry-types.ts +45 -0
  92. package/server/service/inquiry/inquiry.ts +180 -0
  93. package/server/tsconfig.json +11 -0
  94. package/test/inquiry-persistence.test.ts +218 -0
  95. package/test/inquiry-rules.test.ts +121 -0
  96. package/test/open-doors.test.ts +44 -0
  97. package/test/translations.test.ts +53 -0
  98. package/test/writer-popup-standard.test.ts +40 -0
  99. package/things-factory.config.js +8 -0
  100. package/translations/en.json +93 -0
  101. package/translations/es.json +93 -0
  102. package/translations/ja.json +93 -0
  103. package/translations/ko.json +93 -0
  104. package/translations/ms.json +93 -0
  105. package/translations/zh.json +93 -0
  106. package/README.md +0 -3
@@ -0,0 +1,143 @@
1
+ /*
2
+ * What the doors of inquiries do with the database (ADR-0184 decisions 5-7): who is a handler, who reads one, the
3
+ * handlers' turn as a worklist to-do, and telling people of it through notification.
4
+ */
5
+ import { EntityManager, In } from 'typeorm'
6
+
7
+ import { checkPermission, Role, User, UserStatus } from '@things-factory/auth-base'
8
+ import { logger } from '@things-factory/env'
9
+ import { translatorFor } from '@things-factory/i18n-base/translator'
10
+ import { noticeLinkOf, sendNoticeWhenCommitted, type Notice } from '@things-factory/notification'
11
+ import { Domain, getRepository } from '@things-factory/shell'
12
+ import { abort, Activity, ActivityThread, issue } from '@things-factory/worklist'
13
+
14
+ import { INQUIRY_HANDLING } from '../../inquiry-activity'
15
+ import { Inquiry } from './inquiry'
16
+ import { mayRead, noticeWordsOf, viewAddressOf, type NoticeKind, type Say, type Viewer } from './inquiry-rules'
17
+
18
+ /** The attachments of an inquiry hang on it under this `refType`, a reply's under the next (attachment-base). */
19
+ export const INQUIRY_REF_TYPE = 'Inquiry'
20
+ export const INQUIRY_REPLY_REF_TYPE = 'InquiryReply'
21
+
22
+ /** Whether the request handles inquiries - the one privilege of the module (`inquiry.answering`). */
23
+ export async function isHandler(context: any): Promise<boolean> {
24
+ const state = context?.state ?? {}
25
+ const { domain, user, unsafeIP, prohibitedPrivileges } = state
26
+ if (!domain || !user) return false
27
+ /* Asked once per request - a list asks it for each row. */
28
+ state.__inquiryHandler ??= checkPermission({ category: 'inquiry', privilege: 'mutation', owner: true } as any, user, domain, unsafeIP, prohibitedPrivileges).then(Boolean)
29
+ return await state.__inquiryHandler
30
+ }
31
+
32
+ export async function viewerOf(context: any): Promise<Viewer> {
33
+ return { userId: context.state.user.id, handler: await isHandler(context) }
34
+ }
35
+
36
+ /** The inquiry as the request may read it, or nothing - one answer for "none" and "not yours". */
37
+ export async function visibleInquiry(id: string, context: any, tx?: EntityManager): Promise<{ inquiry: Inquiry; viewer: Viewer } | undefined> {
38
+ const { domain, user } = context?.state ?? {}
39
+ if (!id || !domain || !user || !/^[0-9a-f-]{36}$/i.test(id)) return undefined
40
+ const inquiry = await getRepository(Inquiry, tx).findOne({ where: { id, domain: { id: domain.id } } as any })
41
+ if (!inquiry) return undefined
42
+ const viewer = await viewerOf(context)
43
+ return mayRead(inquiry, viewer) ? { inquiry, viewer } : undefined
44
+ }
45
+
46
+ /** The roles of the domain that give `inquiry:mutation` - the handlers' pool. */
47
+ export async function handlerRoleIds(domain: Domain, tx?: EntityManager): Promise<string[]> {
48
+ const roles = await getRepository(Role, tx)
49
+ .createQueryBuilder('R')
50
+ .innerJoin('R.privileges', 'P')
51
+ .where('R.domain = :domain', { domain: domain.id })
52
+ .andWhere('P.category = :category', { category: 'inquiry' })
53
+ /* A privilege row's axis is its name. */
54
+ .andWhere('P.name = :axis', { axis: 'mutation' })
55
+ .getMany()
56
+ return roles.map(one => one.id)
57
+ }
58
+
59
+ /** The handlers to tell - those in a handler role, and the domain's owner, who handles as owners do. */
60
+ export async function handlerUserIds(domain: Domain, tx?: EntityManager): Promise<string[]> {
61
+ const roleIds = await handlerRoleIds(domain, tx)
62
+ const roles = roleIds.length ? await getRepository(Role, tx).find({ where: { id: In(roleIds) } as any, relations: ['users'] }) : []
63
+ const ids = new Set<string>(roles.flatMap(one => (one.users ?? []).map(user => user.id)))
64
+ if (domain.owner) ids.add(domain.owner)
65
+ return [...ids]
66
+ }
67
+
68
+ /*
69
+ * ---------------------------------------------------------------- the handlers' turn, as worklist holds it (decision 7)
70
+ */
71
+
72
+ /**
73
+ * Opens the handlers' to-do on it, unless one is open: one case, pooled on the handler roles - whoever of them picks it.
74
+ * With the activity not installed in the domain, or no handler role, there is no to-do: the notice and the badge still
75
+ * reach the handlers.
76
+ */
77
+ export async function openTodo(inquiry: Inquiry, context: any): Promise<void> {
78
+ const { domain, tx } = context.state
79
+ if (inquiry.todoInstanceId) return
80
+ const activity = await tx.getRepository(Activity).findOne({ where: { domain: { id: domain.id }, name: INQUIRY_HANDLING } as any })
81
+ if (!activity) {
82
+ logger.warn(`[inquiry] ${domain.subdomain}: the activity "${INQUIRY_HANDLING}" is not installed - no to-do for inquiry ${inquiry.id}`)
83
+ return
84
+ }
85
+ const pool = await handlerRoleIds(domain, tx)
86
+ if (!pool.length) {
87
+ logger.warn(`[inquiry] ${domain.subdomain}: no role gives inquiry handling - no to-do for inquiry ${inquiry.id}`)
88
+ return
89
+ }
90
+ const name = inquiry.title.slice(0, 200)
91
+ const instance = await issue({ name, description: name, activityId: activity.id, input: { inquiryId: inquiry.id }, assignees: [], assigneeRoles: pool, threadsMin: 1 } as any, context)
92
+ await tx.getRepository(Inquiry).update(inquiry.id, { todoInstanceId: instance.id })
93
+ inquiry.todoInstanceId = instance.id
94
+ }
95
+
96
+ /** Closes the handlers' to-do when it is no longer their turn - left alone when it is closed already. */
97
+ export async function closeTodo(inquiry: Inquiry, context: any, reason: string): Promise<void> {
98
+ const { tx } = context.state
99
+ if (!inquiry.todoInstanceId) return
100
+ const open = await tx.getRepository(ActivityThread).count({ where: { activityInstance: { id: inquiry.todoInstanceId }, state: In(['assigned', 'started', 'unassigned']) } as any })
101
+ if (open) await abort({ id: inquiry.todoInstanceId, reason } as any, context)
102
+ await tx.getRepository(Inquiry).update(inquiry.id, { todoInstanceId: null } as any)
103
+ inquiry.todoInstanceId = null as any
104
+ }
105
+
106
+ /*
107
+ * ---------------------------------------------------------------- telling people (decision 7)
108
+ */
109
+
110
+ /** The sentences in `lng` from this module's translations, the English one where `lng` has none. */
111
+ const sayIn =
112
+ (lng: string): Say =>
113
+ (key, english, params = {}) =>
114
+ translatorFor(lng)(key, { ...params, defaultValue: english.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? '')) })
115
+
116
+ /** The people to tell, as notification takes them - only those who can sign in, each with their mail and language. */
117
+ async function receiversOf(userIds: string[], tx?: EntityManager) {
118
+ if (!userIds.length) return []
119
+ const people = await getRepository(User, tx).find({ where: { id: In(userIds) } as any })
120
+ return people.filter(one => one.status === UserStatus.ACTIVATED).map(one => ({ id: one.id, email: one.email, locale: (one as any).locale }))
121
+ }
122
+
123
+ /**
124
+ * Tells `userIds` - never the request's own person - once the request's transaction has committed. A notice that does
125
+ * not arrive undoes nothing: the list and the badge still reach them.
126
+ */
127
+ export async function tellWhenCommitted(context: any, one: Inquiry, kind: NoticeKind, userIds: string[], said: { body?: string | null; status?: string } = {}): Promise<void> {
128
+ const receivers = await receiversOf(
129
+ userIds.filter(id => id !== context.state.user?.id),
130
+ context.state.tx
131
+ )
132
+ if (!receivers.length) return
133
+ const notice: Notice = {
134
+ type: 'inquiry',
135
+ level: 'info',
136
+ receivers,
137
+ words: lng => noticeWordsOf(one, kind, said, sayIn(lng)),
138
+ address: viewAddressOf(one.id),
139
+ link: noticeLinkOf(context, viewAddressOf(one.id)),
140
+ label: `inquiry ${kind} ${one.id}`
141
+ } as Notice
142
+ sendNoticeWhenCommitted(context, notice)
143
+ }
@@ -0,0 +1,45 @@
1
+ import { Field, InputType, Int, ObjectType } from 'type-graphql'
2
+
3
+ import { Inquiry } from './inquiry'
4
+
5
+ @InputType({ description: 'What an inquiry asks, and who reads it.' })
6
+ export class InquiryInput {
7
+ @Field({ description: 'QUESTION, BUG or PROPOSAL.' })
8
+ kind: string
9
+
10
+ @Field({ description: 'The title.' })
11
+ title: string
12
+
13
+ @Field({ nullable: true, description: 'The body, plain text.' })
14
+ body?: string
15
+
16
+ @Field({ nullable: true, description: 'DOMAIN (everyone of the domain reads it, the default) or PRIVATE (its writer and the handlers).' })
17
+ visibility?: string
18
+
19
+ @Field({ nullable: true, description: 'The address of the screen it is written from - the browser sends it.' })
20
+ pageUrl?: string
21
+ }
22
+
23
+ @ObjectType({ description: 'A page of inquiries.' })
24
+ export class InquiryList {
25
+ @Field(type => [Inquiry], { description: 'The inquiries of the page.' })
26
+ items: Inquiry[]
27
+
28
+ @Field(type => Int, { description: 'How many there are in all.' })
29
+ total: number
30
+ }
31
+
32
+ @ObjectType({ description: "What the menu counts for the request's person." })
33
+ export class InquirySummary {
34
+ @Field(type => Int, { description: 'Their own inquiries with a handler\'s word they have not seen.' })
35
+ newAnswers: number
36
+
37
+ @Field(type => Int, { description: 'For a handler, the inquiries waiting on the handlers - received or in progress; 0 otherwise.' })
38
+ unhandled: number
39
+
40
+ @Field({ description: 'Whether the person handles inquiries.' })
41
+ handler: boolean
42
+
43
+ @Field(type => Int, { description: 'The count the menu shows - new answers, and for a handler those waiting too.' })
44
+ badge: number
45
+ }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * The tables of inquiries (ADR-0184 decisions 1-4): one thread - a question, a bug report or a feature proposal - and
3
+ * its replies. What was on the screen when it was written, and which application, are kept on it.
4
+ */
5
+ import { Column, CreateDateColumn, DeleteDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm'
6
+ import { Field, ID, Int, ObjectType } from 'type-graphql'
7
+
8
+ import { Domain } from '@things-factory/shell'
9
+ import { User } from '@things-factory/auth-base'
10
+
11
+ @Entity()
12
+ @Index('ix_inquiry_0', (one: Inquiry) => [one.domain, one.status])
13
+ @Index('ix_inquiry_1', (one: Inquiry) => [one.domain, one.creator])
14
+ @ObjectType({ description: "An inquiry of a domain - a question, a bug report or a feature proposal, answered by the domain's handlers." })
15
+ export class Inquiry {
16
+ @PrimaryGeneratedColumn('uuid')
17
+ @Field(type => ID, { description: 'Inquiry id.' })
18
+ readonly id: string
19
+
20
+ @ManyToOne(type => Domain)
21
+ @Field(type => Domain, { nullable: true, description: 'The domain the inquiry is in.' })
22
+ domain?: Domain
23
+
24
+ @RelationId((one: Inquiry) => one.domain)
25
+ domainId?: string
26
+
27
+ @Column()
28
+ @Field({ description: 'QUESTION, BUG or PROPOSAL.' })
29
+ kind: string
30
+
31
+ @Column()
32
+ @Field({ description: 'The title.' })
33
+ title: string
34
+
35
+ @Column({ type: 'text', nullable: true })
36
+ @Field({ nullable: true, description: 'The body, plain text.' })
37
+ body?: string
38
+
39
+ @Column({ default: 'RECEIVED' })
40
+ @Field({ description: 'RECEIVED, IN_PROGRESS, ANSWERED, PLANNED, DECLINED or CLOSED - PLANNED and DECLINED for a proposal only.' })
41
+ status: string
42
+
43
+ @Column({ default: 'DOMAIN' })
44
+ @Field({ description: 'DOMAIN - everyone of the domain reads it; PRIVATE - its writer and the handlers only.' })
45
+ visibility: string
46
+
47
+ @Column({ length: 2000, nullable: true })
48
+ @Field({ nullable: true, description: 'The address of the screen it was written from.' })
49
+ pageUrl?: string
50
+
51
+ @Column({ nullable: true })
52
+ @Field({ nullable: true, description: 'The application it was written in.' })
53
+ appName?: string
54
+
55
+ @Column({ nullable: true })
56
+ @Field({ nullable: true, description: 'The version of that application.' })
57
+ appVersion?: string
58
+
59
+ @ManyToOne(type => User, { nullable: true })
60
+ @Field(type => User, { nullable: true, description: 'The handler who took it.' })
61
+ assignee?: User
62
+
63
+ @RelationId((one: Inquiry) => one.assignee)
64
+ assigneeId?: string
65
+
66
+ @Column('int', { default: 0 })
67
+ @Field(type => Int, { description: 'How many replies it has.' })
68
+ replyCount: number
69
+
70
+ @Column({ nullable: true })
71
+ @Field({ nullable: true, description: 'When a handler last replied or moved it - the writer sees it as new until they open it.' })
72
+ handlerActivityAt?: Date
73
+
74
+ @Column({ nullable: true })
75
+ @Field({ nullable: true, description: 'When its writer last opened it.' })
76
+ askerSeenAt?: Date
77
+
78
+ @Column({ nullable: true })
79
+ @Field({ nullable: true, description: 'When it was last answered.' })
80
+ answeredAt?: Date
81
+
82
+ @Column({ nullable: true })
83
+ @Field({ nullable: true, description: 'When it was closed.' })
84
+ closedAt?: Date
85
+
86
+ @Column({ nullable: true })
87
+ @Field({ nullable: true, description: "The worklist case that holds the handlers' turn on it, while there is one." })
88
+ todoInstanceId?: string
89
+
90
+ @ManyToOne(type => User, { nullable: true })
91
+ @Field(type => User, { nullable: true, description: 'Who wrote it.' })
92
+ creator?: User
93
+
94
+ @RelationId((one: Inquiry) => one.creator)
95
+ creatorId?: string
96
+
97
+ @ManyToOne(type => User, { nullable: true })
98
+ @Field(type => User, { nullable: true, description: 'Who changed it last.' })
99
+ updater?: User
100
+
101
+ @RelationId((one: Inquiry) => one.updater)
102
+ updaterId?: string
103
+
104
+ @CreateDateColumn()
105
+ @Field({ nullable: true, description: 'When it was written.' })
106
+ createdAt?: Date
107
+
108
+ @UpdateDateColumn()
109
+ @Field({ nullable: true, description: 'Last update timestamp.' })
110
+ updatedAt?: Date
111
+
112
+ @DeleteDateColumn()
113
+ @Field({ nullable: true, description: 'Soft delete timestamp.' })
114
+ deletedAt?: Date
115
+ }
116
+
117
+ @Entity()
118
+ @Index('ix_inquiry_reply_0', (one: InquiryReply) => [one.inquiry, one.createdAt])
119
+ @ObjectType({ description: 'One reply on an inquiry - the writer\'s or a handler\'s words, or a move of where it stands.' })
120
+ export class InquiryReply {
121
+ @PrimaryGeneratedColumn('uuid')
122
+ @Field(type => ID, { description: 'Reply id.' })
123
+ readonly id: string
124
+
125
+ @ManyToOne(type => Domain)
126
+ @Field(type => Domain, { nullable: true, description: 'The domain.' })
127
+ domain?: Domain
128
+
129
+ @RelationId((one: InquiryReply) => one.domain)
130
+ domainId?: string
131
+
132
+ @ManyToOne(type => Inquiry, { onDelete: 'CASCADE' })
133
+ inquiry?: Inquiry
134
+
135
+ @RelationId((one: InquiryReply) => one.inquiry)
136
+ @Field({ description: 'The inquiry.' })
137
+ inquiryId?: string
138
+
139
+ @Column({ type: 'text', nullable: true })
140
+ @Field({ nullable: true, description: 'The words, plain text.' })
141
+ body?: string
142
+
143
+ @Column({ default: false })
144
+ @Field({ description: 'From a handler - not the writer.' })
145
+ byHandler: boolean
146
+
147
+ @Column({ default: false })
148
+ @Field({ description: 'Sent as the answer.' })
149
+ answer: boolean
150
+
151
+ @Column({ nullable: true })
152
+ @Field({ nullable: true, description: 'Where it moved the inquiry, when it moved it.' })
153
+ status?: string
154
+
155
+ @ManyToOne(type => User, { nullable: true })
156
+ @Field(type => User, { nullable: true, description: 'Who wrote it.' })
157
+ creator?: User
158
+
159
+ @RelationId((one: InquiryReply) => one.creator)
160
+ creatorId?: string
161
+
162
+ @ManyToOne(type => User, { nullable: true })
163
+ @Field(type => User, { nullable: true, description: 'Who changed it last.' })
164
+ updater?: User
165
+
166
+ @RelationId((one: InquiryReply) => one.updater)
167
+ updaterId?: string
168
+
169
+ @CreateDateColumn()
170
+ @Field({ nullable: true, description: 'When it was written.' })
171
+ createdAt?: Date
172
+
173
+ @UpdateDateColumn()
174
+ @Field({ nullable: true, description: 'Last update timestamp.' })
175
+ updatedAt?: Date
176
+
177
+ @DeleteDateColumn()
178
+ @Field({ nullable: true, description: 'Soft delete timestamp.' })
179
+ deletedAt?: Date
180
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "strict": false,
5
+ "module": "commonjs",
6
+ "outDir": "../dist-server",
7
+ "baseUrl": "./"
8
+ },
9
+ "include": ["./**/*"],
10
+ "exclude": ["**/*.spec.ts", "**/*.test.ts"]
11
+ }
@@ -0,0 +1,218 @@
1
+ /*
2
+ * An inquiry on sqlite with the real entities (ADR-0184 decisions 2-8): what writing one keeps and whom it tells, who
3
+ * reads it, the replies back and forth with where it stands after each, the handlers' to-do opened and closed with
4
+ * their turn, and the badge's counts.
5
+ *
6
+ * Reads the built server (`dist-server`) - type-graphql needs tsc's decorator metadata. worklist's issue and abort are
7
+ * recorded, not run: the cases themselves are worklist's tests.
8
+ */
9
+ import 'reflect-metadata'
10
+
11
+ const { existsSync } = require('fs')
12
+ const { join } = require('path')
13
+ const { DataSource } = require('typeorm')
14
+
15
+ const told: { label: string; receivers: string[]; subject: string }[] = []
16
+ jest.mock('@things-factory/notification', () => ({
17
+ sendNoticeWhenCommitted: (_context: any, notice: any) =>
18
+ told.push({ label: notice.label, receivers: notice.receivers.map((one: any) => one.id), subject: notice.words('en').subject }),
19
+ noticeLinkOf: () => undefined
20
+ }))
21
+
22
+ const cases: { op: string; id: string; pool?: string[]; reason?: string }[] = []
23
+ jest.mock('@things-factory/worklist', () => {
24
+ const actual = jest.requireActual('@things-factory/worklist')
25
+ return {
26
+ ...actual,
27
+ /* A pooled case has one unassigned thread, as worklist's issue leaves it - closing it is what abort is asked for. */
28
+ issue: async (one: any, context: any) => {
29
+ const id = `00000000-0000-4000-8000-${String(cases.length + 1).padStart(12, '0')}`
30
+ cases.push({ op: 'issue', id, pool: one.assigneeRoles })
31
+ await context.state.tx.getRepository(actual.ActivityInstance).save({ id, domain: context.state.domain, name: one.name, state: 'issued' })
32
+ await context.state.tx.getRepository(actual.ActivityThread).save({ domain: context.state.domain, activityInstance: { id }, state: 'unassigned' })
33
+ return { id }
34
+ },
35
+ abort: async ({ id, reason }: any, context: any) => {
36
+ cases.push({ op: 'abort', id, reason })
37
+ await context.state.tx.getRepository(actual.ActivityThread).update({ activityInstance: { id } }, { state: 'aborted' })
38
+ }
39
+ }
40
+ })
41
+
42
+ const shell = require('@things-factory/shell')
43
+ const authBase = require('@things-factory/auth-base')
44
+ const attachmentBase = require('@things-factory/attachment-base')
45
+ const worklist = require('@things-factory/worklist')
46
+
47
+ function loadCompiled(modulePath: string): any {
48
+ const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
49
+ if (!existsSync(resolved)) throw new Error(`inquiry build output is missing: ${resolved}\nBuild it first: npm run build:server`)
50
+ return require(resolved)
51
+ }
52
+
53
+ const { InquiryResolver } = loadCompiled('service/inquiry/inquiry-resolver')
54
+ const { Inquiry, InquiryReply } = loadCompiled('service/inquiry/inquiry')
55
+ const { INQUIRY_HANDLING } = loadCompiled('inquiry-activity')
56
+
57
+ let dataSource: any
58
+ let domain: any
59
+ let people: Record<string, any>
60
+ let desk: any
61
+ const resolver = new InquiryResolver()
62
+
63
+ beforeAll(async () => {
64
+ dataSource = new DataSource({
65
+ type: 'sqlite',
66
+ database: ':memory:',
67
+ synchronize: true,
68
+ dropSchema: true,
69
+ namingStrategy: new shell.NamingStrategy(),
70
+ entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...worklist.entities, Inquiry, InquiryReply],
71
+ logging: false
72
+ })
73
+ await dataSource.initialize()
74
+ shell.addDataSource('default', dataSource)
75
+ shell.addDataSource('tx', dataSource)
76
+ })
77
+
78
+ afterAll(async () => {
79
+ if (!dataSource) return
80
+ shell.removeDataSource('default')
81
+ shell.removeDataSource('tx')
82
+ await dataSource.destroy()
83
+ })
84
+
85
+ /* writer · neighbour - members; desk - holds the role that gives inquiry:mutation; owner - owns the domain. */
86
+ beforeEach(async () => {
87
+ told.length = 0
88
+ cases.length = 0
89
+ await dataSource.synchronize(true)
90
+ const user = (name: string) => dataSource.getRepository(authBase.User).save({ name, email: `${name}@inquiry.test`, status: 'activated' })
91
+ people = { writer: await user('writer'), neighbour: await user('neighbour'), desk: await user('desk'), owner: await user('owner') }
92
+ domain = await dataSource.getRepository(shell.Domain).save({ name: 'ask', subdomain: 'ask', owner: people.owner.id })
93
+ const privilege = await dataSource.getRepository(authBase.Privilege).save({ name: 'mutation', category: 'inquiry' })
94
+ desk = await dataSource.getRepository(authBase.Role).save({ name: 'DESK', domain, privileges: [privilege], users: [people.desk] })
95
+ await dataSource.getRepository(authBase.Role).save({ name: 'OTHER', domain, users: [people.neighbour] })
96
+ await dataSource.getRepository(worklist.Activity).save({ domain, name: INQUIRY_HANDLING, activityType: 'user', startingType: 'issue' })
97
+ })
98
+
99
+ const contextOf = (who: string, handler = false) => ({ state: { domain, user: people[who], tx: dataSource.manager, __inquiryHandler: Promise.resolve(handler) } })
100
+ const as = (who: string) => contextOf(who, who === 'desk' || who === 'owner')
101
+ const ask = (input: any = {}) =>
102
+ resolver.createInquiry({ kind: 'QUESTION', title: '휴가는 어디서 신청하나요?', body: '메뉴에서 못 찾겠습니다', pageUrl: 'https://office.test/ask/leave-list', ...input }, undefined, as('writer'))
103
+ const fresh = (id: string) => dataSource.getRepository(Inquiry).findOneBy({ id })
104
+ const list = async (who: string, scope?: string) => (await resolver.inquiries({ filters: [], pagination: { page: 1, limit: 50 } }, scope, as(who))).items.map((one: any) => one.title).sort()
105
+
106
+ describe('writing one', () => {
107
+ it('★ keeps the screen and the writer, tells the handlers and opens their to-do on the handler roles', async () => {
108
+ const one = await ask()
109
+ expect(one).toMatchObject({ status: 'RECEIVED', visibility: 'DOMAIN', pageUrl: 'https://office.test/ask/leave-list', creatorId: people.writer.id, domainId: domain.id })
110
+ expect(told).toEqual([{ label: `inquiry received ${one.id}`, receivers: [people.desk.id, people.owner.id].sort(), subject: 'New QUESTION: 휴가는 어디서 신청하나요?' }].map(t => ({ ...t, receivers: expect.arrayContaining(t.receivers) })))
111
+ expect(cases).toEqual([{ op: 'issue', id: expect.any(String), pool: [desk.id] }])
112
+ expect((await fresh(one.id)).todoInstanceId).toBe(cases[0].id)
113
+ })
114
+
115
+ it('opens no to-do where the domain has not installed it - the notice still goes', async () => {
116
+ await dataSource.getRepository(worklist.Activity).delete({ name: INQUIRY_HANDLING })
117
+ await ask()
118
+ expect(cases).toEqual([])
119
+ expect(told).toHaveLength(1)
120
+ })
121
+ })
122
+
123
+ describe('who reads it', () => {
124
+ it('★ an open one everyone of the domain, a private one its writer and the handlers', async () => {
125
+ await ask({ title: 'open' })
126
+ await ask({ title: 'private', visibility: 'PRIVATE' })
127
+ expect(await list('neighbour')).toEqual(['open'])
128
+ expect(await list('writer')).toEqual(['open', 'private'])
129
+ expect(await list('desk')).toEqual(['open', 'private'])
130
+ expect(await list('neighbour', 'mine')).toEqual([])
131
+ })
132
+
133
+ it('the handlers see those waiting on them', async () => {
134
+ const one = await ask({ title: 'answered' })
135
+ await ask({ title: 'waiting' })
136
+ await resolver.replyInquiry(one.id, '근태 › 휴가에 있습니다', true, undefined, as('desk'))
137
+ expect(await list('desk', 'handlers-turn')).toEqual(['waiting'])
138
+ })
139
+ })
140
+
141
+ describe('the replies', () => {
142
+ it('★ a handler answers: answered, the writer told and shown it as new, the to-do closed', async () => {
143
+ const one = await ask()
144
+ told.length = 0
145
+ const answered = await resolver.replyInquiry(one.id, '근태 › 휴가에 있습니다', true, undefined, as('desk'))
146
+ expect(answered).toMatchObject({ status: 'ANSWERED', assigneeId: people.desk.id, replyCount: 1, todoInstanceId: null })
147
+ expect(cases.map(one => one.op)).toEqual(['issue', 'abort'])
148
+ expect(told).toEqual([{ label: `inquiry answered ${one.id}`, receivers: [people.writer.id], subject: 'Answered: 휴가는 어디서 신청하나요?' }])
149
+ expect(await resolver.inquirySummary(as('writer'))).toMatchObject({ newAnswers: 1, badge: 1, handler: false })
150
+ await resolver.readInquiry(one.id, as('writer'))
151
+ expect(await resolver.inquirySummary(as('writer'))).toMatchObject({ newAnswers: 0, badge: 0 })
152
+ })
153
+
154
+ it('★ the writer replying to an answer hands it back: in progress, a new to-do, the handler who answered told', async () => {
155
+ const one = await ask()
156
+ await resolver.replyInquiry(one.id, '근태 › 휴가에 있습니다', true, undefined, as('desk'))
157
+ told.length = 0
158
+ const back = await resolver.replyInquiry(one.id, '거기 없는데요', false, undefined, as('writer'))
159
+ expect(back.status).toBe('IN_PROGRESS')
160
+ expect(cases.map(one => one.op)).toEqual(['issue', 'abort', 'issue'])
161
+ expect(told).toEqual([{ label: `inquiry asker-replied ${one.id}`, receivers: [people.desk.id], subject: 'The writer replied: 휴가는 어디서 신청하나요?' }])
162
+ const replies = await dataSource.getRepository(InquiryReply).find({ where: { inquiry: { id: one.id } }, order: { createdAt: 'ASC' } })
163
+ expect(replies.map((one: any) => [one.byHandler, one.answer, one.status])).toEqual([
164
+ [true, true, 'ANSWERED'],
165
+ [false, false, 'IN_PROGRESS']
166
+ ])
167
+ })
168
+
169
+ it('★ someone else of the domain reads but does not reply; the writer may not send the answer', async () => {
170
+ const one = await ask()
171
+ await expect(resolver.replyInquiry(one.id, '저도 궁금해요', false, undefined, as('neighbour'))).rejects.toMatchObject({ extensions: { code: 'inquiry.not-a-party' } })
172
+ await expect(resolver.replyInquiry(one.id, '스스로 답', true, undefined, as('writer'))).rejects.toMatchObject({ extensions: { code: 'inquiry.handlers-only' } })
173
+ await expect(resolver.replyInquiry(one.id, ' ', false, undefined, as('writer'))).rejects.toMatchObject({ extensions: { code: 'inquiry.empty-reply' } })
174
+ })
175
+
176
+ it('a private one is not there for someone else', async () => {
177
+ const one = await ask({ visibility: 'PRIVATE' })
178
+ expect(await resolver.inquiry(one.id, as('neighbour'))).toBeUndefined()
179
+ await expect(resolver.replyInquiry(one.id, 'x', false, undefined, as('neighbour'))).rejects.toMatchObject({ extensions: { code: 'inquiry.unknown-inquiry' } })
180
+ })
181
+ })
182
+
183
+ describe('the moves', () => {
184
+ it('★ a handler plans a proposal: the writer told, the reason on the thread, the to-do closed', async () => {
185
+ const one = await ask({ kind: 'PROPOSAL', title: '엑셀 내보내기' })
186
+ told.length = 0
187
+ const planned = await resolver.moveInquiry(one.id, 'PLANNED', '다음 달 반영', as('desk'))
188
+ expect(planned).toMatchObject({ status: 'PLANNED', todoInstanceId: null })
189
+ expect(told).toEqual([{ label: `inquiry moved ${one.id}`, receivers: [people.writer.id], subject: 'PLANNED: 엑셀 내보내기' }])
190
+ expect(await dataSource.getRepository(InquiryReply).findOneBy({ inquiry: { id: one.id } })).toMatchObject({ status: 'PLANNED', body: '다음 달 반영', byHandler: true })
191
+ })
192
+
193
+ it('★ the writer closes their own; nothing replies on it after', async () => {
194
+ const one = await ask()
195
+ const closed = await resolver.moveInquiry(one.id, 'CLOSED', undefined, as('writer'))
196
+ expect(closed.status).toBe('CLOSED')
197
+ expect(closed.closedAt).toBeTruthy()
198
+ expect(cases.map(one => one.op)).toEqual(['issue', 'abort'])
199
+ await expect(resolver.replyInquiry(one.id, '하나 더', false, undefined, as('desk'))).rejects.toMatchObject({ extensions: { code: 'inquiry.closed' } })
200
+ await expect(resolver.moveInquiry(one.id, 'ANSWERED', undefined, as('writer'))).rejects.toMatchObject({ extensions: { code: 'inquiry.closed' } })
201
+ })
202
+
203
+ it('only a handler moves the rest', async () => {
204
+ const one = await ask()
205
+ await expect(resolver.moveInquiry(one.id, 'IN_PROGRESS', undefined, as('writer'))).rejects.toMatchObject({ extensions: { code: 'inquiry.handlers-only' } })
206
+ await expect(resolver.moveInquiry(one.id, 'PLANNED', undefined, as('desk'))).rejects.toMatchObject({ extensions: { code: 'inquiry.not-a-proposal' } })
207
+ })
208
+ })
209
+
210
+ describe('the badge of a handler', () => {
211
+ it('★ counts those waiting on the handlers', async () => {
212
+ await ask()
213
+ const two = await ask()
214
+ await resolver.replyInquiry(two.id, '답', true, undefined, as('desk'))
215
+ expect(await resolver.inquirySummary(as('desk'))).toMatchObject({ unhandled: 1, handler: true, badge: 1 })
216
+ expect(await resolver.inquirySummary(as('neighbour'))).toMatchObject({ unhandled: 0, badge: 0 })
217
+ })
218
+ })