@things-factory/inquiry 0.0.0-stage → 10.1.251
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/bootstrap.ts +27 -0
- package/client/components/inquiry-thread.ts +277 -0
- package/client/components/inquiry-todo.ts +36 -0
- package/client/components/inquiry-writer.ts +208 -0
- package/client/index.ts +2 -0
- package/client/inquiry-watch.ts +45 -0
- package/client/pages/inquiry-list.ts +218 -0
- package/client/route.ts +8 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/inquiry-api.ts +103 -0
- package/client/utils/inquiry-badge.ts +46 -0
- package/client/utils/inquiry-words.ts +26 -0
- package/dist-client/bootstrap.d.ts +2 -0
- package/dist-client/bootstrap.js +25 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/inquiry-thread.d.ts +28 -0
- package/dist-client/components/inquiry-thread.js +303 -0
- package/dist-client/components/inquiry-thread.js.map +1 -0
- package/dist-client/components/inquiry-todo.d.ts +9 -0
- package/dist-client/components/inquiry-todo.js +43 -0
- package/dist-client/components/inquiry-todo.js.map +1 -0
- package/dist-client/components/inquiry-writer.d.ts +23 -0
- package/dist-client/components/inquiry-writer.js +230 -0
- package/dist-client/components/inquiry-writer.js.map +1 -0
- package/dist-client/index.d.ts +2 -0
- package/dist-client/index.js +3 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/inquiry-watch.d.ts +2 -0
- package/dist-client/inquiry-watch.js +41 -0
- package/dist-client/inquiry-watch.js.map +1 -0
- package/dist-client/pages/inquiry-list.d.ts +56 -0
- package/dist-client/pages/inquiry-list.js +241 -0
- package/dist-client/pages/inquiry-list.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +9 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/inquiry-api.d.ts +73 -0
- package/dist-client/utils/inquiry-api.js +34 -0
- package/dist-client/utils/inquiry-api.js.map +1 -0
- package/dist-client/utils/inquiry-badge.d.ts +4 -0
- package/dist-client/utils/inquiry-badge.js +47 -0
- package/dist-client/utils/inquiry-badge.js.map +1 -0
- package/dist-client/utils/inquiry-words.d.ts +13 -0
- package/dist-client/utils/inquiry-words.js +22 -0
- package/dist-client/utils/inquiry-words.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +21 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +22 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/inquiry-activity.d.ts +38 -0
- package/dist-server/inquiry-activity.js +38 -0
- package/dist-server/inquiry-activity.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +14 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +24 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/inquiry/index.d.ts +4 -0
- package/dist-server/service/inquiry/index.js +8 -0
- package/dist-server/service/inquiry/index.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-resolver.d.ts +29 -0
- package/dist-server/service/inquiry/inquiry-resolver.js +407 -0
- package/dist-server/service/inquiry/inquiry-resolver.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-rules.d.ts +88 -0
- package/dist-server/service/inquiry/inquiry-rules.js +157 -0
- package/dist-server/service/inquiry/inquiry-rules.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-service.d.ts +35 -0
- package/dist-server/service/inquiry/inquiry-service.js +143 -0
- package/dist-server/service/inquiry/inquiry-service.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-types.d.ts +18 -0
- package/dist-server/service/inquiry/inquiry-types.js +69 -0
- package/dist-server/service/inquiry/inquiry-types.js.map +1 -0
- package/dist-server/service/inquiry/inquiry.d.ts +48 -0
- package/dist-server/service/inquiry/inquiry.js +232 -0
- package/dist-server/service/inquiry/inquiry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -4
- package/server/attachment-reader.ts +18 -0
- package/server/index.ts +18 -0
- package/server/inquiry-activity.ts +35 -0
- package/server/role-tasks.ts +13 -0
- package/server/service/index.ts +19 -0
- package/server/service/inquiry/index.ts +5 -0
- package/server/service/inquiry/inquiry-resolver.ts +321 -0
- package/server/service/inquiry/inquiry-rules.ts +173 -0
- package/server/service/inquiry/inquiry-service.ts +143 -0
- package/server/service/inquiry/inquiry-types.ts +45 -0
- package/server/service/inquiry/inquiry.ts +180 -0
- package/server/tsconfig.json +11 -0
- package/test/inquiry-persistence.test.ts +218 -0
- package/test/inquiry-rules.test.ts +121 -0
- package/test/open-doors.test.ts +44 -0
- package/test/translations.test.ts +53 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +93 -0
- package/translations/es.json +93 -0
- package/translations/ja.json +93 -0
- package/translations/ko.json +93 -0
- package/translations/ms.json +93 -0
- package/translations/zh.json +93 -0
- 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,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
|
+
})
|