@things-factory/inquiry 10.1.253 → 10.1.254
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/client/bootstrap.ts +0 -27
- package/client/components/inquiry-thread.ts +0 -277
- package/client/components/inquiry-todo.ts +0 -36
- package/client/components/inquiry-writer.ts +0 -212
- package/client/index.ts +0 -2
- package/client/inquiry-watch.ts +0 -45
- package/client/pages/inquiry-list.ts +0 -218
- package/client/route.ts +0 -8
- package/client/tsconfig.json +0 -13
- package/client/utils/inquiry-api.ts +0 -103
- package/client/utils/inquiry-badge.ts +0 -46
- package/client/utils/inquiry-words.ts +0 -26
- package/server/attachment-reader.ts +0 -18
- package/server/index.ts +0 -18
- package/server/inquiry-activity.ts +0 -35
- package/server/role-tasks.ts +0 -13
- package/server/service/index.ts +0 -19
- package/server/service/inquiry/index.ts +0 -5
- package/server/service/inquiry/inquiry-resolver.ts +0 -321
- package/server/service/inquiry/inquiry-rules.ts +0 -173
- package/server/service/inquiry/inquiry-service.ts +0 -143
- package/server/service/inquiry/inquiry-types.ts +0 -45
- package/server/service/inquiry/inquiry.ts +0 -180
- package/server/tsconfig.json +0 -11
- package/test/inquiry-persistence.test.ts +0 -218
- package/test/inquiry-rules.test.ts +0 -121
- package/test/open-doors.test.ts +0 -44
- package/test/translations.test.ts +0 -53
- package/test/writer-popup-standard.test.ts +0 -40
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
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
|
-
}
|
package/server/tsconfig.json
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The rules of an inquiry (ADR-0184 decisions 1-8), without a database.
|
|
3
|
-
*/
|
|
4
|
-
import {
|
|
5
|
-
answerRefusal,
|
|
6
|
-
badgeOf,
|
|
7
|
-
handlersTurn,
|
|
8
|
-
inputRefusal,
|
|
9
|
-
mayRead,
|
|
10
|
-
moveRefusal,
|
|
11
|
-
newForAsker,
|
|
12
|
-
noticeWordsOf,
|
|
13
|
-
replyRefusal,
|
|
14
|
-
replyRefusalFor,
|
|
15
|
-
sideOf,
|
|
16
|
-
statusAfterReply
|
|
17
|
-
} from '../server/service/inquiry/inquiry-rules'
|
|
18
|
-
|
|
19
|
-
const writer = { userId: 'w', handler: false }
|
|
20
|
-
const handler = { userId: 'h', handler: true }
|
|
21
|
-
const neighbour = { userId: 'n', handler: false }
|
|
22
|
-
const one = (status = 'RECEIVED', extra: any = {}) => ({ kind: 'QUESTION', status, visibility: 'DOMAIN', creatorId: 'w', ...extra })
|
|
23
|
-
|
|
24
|
-
describe('what may be written', () => {
|
|
25
|
-
it('★ a kind of the three and a title', () => {
|
|
26
|
-
expect(inputRefusal({ kind: 'QUESTION', title: '휴가 신청은 어디서?' })).toBeUndefined()
|
|
27
|
-
expect(inputRefusal({ kind: 'COMPLAINT', title: 'x' })).toBe('unknown-kind')
|
|
28
|
-
expect(inputRefusal({ kind: 'BUG', title: ' ' })).toBe('no-title')
|
|
29
|
-
expect(inputRefusal({ kind: 'BUG', title: 'x'.repeat(201) })).toBe('title-too-long')
|
|
30
|
-
expect(inputRefusal({ kind: 'BUG', title: 'x', visibility: 'SECRET' })).toBe('unknown-visibility')
|
|
31
|
-
expect(inputRefusal({ kind: 'BUG', title: 'x', pageUrl: 'h'.repeat(2001) })).toBe('page-url-too-long')
|
|
32
|
-
expect(inputRefusal({ kind: 'BUG', title: 'x', body: 'b'.repeat(20001) })).toBe('body-too-long')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('a reply says something - words or a file', () => {
|
|
36
|
-
expect(replyRefusal(' ', 0)).toBe('empty-reply')
|
|
37
|
-
expect(replyRefusal('', 1)).toBeUndefined()
|
|
38
|
-
expect(replyRefusal('네', 0)).toBeUndefined()
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
describe('who reads and replies', () => {
|
|
43
|
-
it('★ an open one is read by everyone of the domain; a private one by its writer and the handlers', () => {
|
|
44
|
-
expect(mayRead(one(), neighbour)).toBe(true)
|
|
45
|
-
const hidden = one('RECEIVED', { visibility: 'PRIVATE' })
|
|
46
|
-
expect(mayRead(hidden, neighbour)).toBe(false)
|
|
47
|
-
expect(mayRead(hidden, writer)).toBe(true)
|
|
48
|
-
expect(mayRead(hidden, handler)).toBe(true)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('★ replies go between the writer and the handlers, until it is closed', () => {
|
|
52
|
-
expect(replyRefusalFor(one(), writer)).toBeUndefined()
|
|
53
|
-
expect(replyRefusalFor(one(), handler)).toBeUndefined()
|
|
54
|
-
expect(replyRefusalFor(one(), neighbour)).toBe('not-a-party')
|
|
55
|
-
expect(replyRefusalFor(one('CLOSED'), writer)).toBe('closed')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it("the writer's own reply is the asker's, even when the writer handles", () => {
|
|
59
|
-
expect(sideOf(one(), { userId: 'w', handler: true })).toBe('asker')
|
|
60
|
-
expect(sideOf(one(), handler)).toBe('handler')
|
|
61
|
-
expect(answerRefusal('asker', true)).toBe('handlers-only')
|
|
62
|
-
expect(answerRefusal('handler', true)).toBeUndefined()
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
describe('where it stands', () => {
|
|
67
|
-
it('★ a handler answers it, or puts a received one in progress', () => {
|
|
68
|
-
expect(statusAfterReply(one('RECEIVED'), 'handler', true)).toBe('ANSWERED')
|
|
69
|
-
expect(statusAfterReply(one('RECEIVED'), 'handler', false)).toBe('IN_PROGRESS')
|
|
70
|
-
expect(statusAfterReply(one('ANSWERED'), 'handler', false)).toBe('ANSWERED')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('★ the writer replying to an answer or a decision hands it back to the handlers', () => {
|
|
74
|
-
for (const status of ['ANSWERED', 'PLANNED', 'DECLINED']) expect(statusAfterReply(one(status), 'asker', false)).toBe('IN_PROGRESS')
|
|
75
|
-
expect(statusAfterReply(one('RECEIVED'), 'asker', false)).toBe('RECEIVED')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('★ the writer closes their own; the rest is the handlers\'', () => {
|
|
79
|
-
expect(moveRefusal(one('ANSWERED'), 'CLOSED', writer)).toBeUndefined()
|
|
80
|
-
expect(moveRefusal(one('ANSWERED'), 'CLOSED', neighbour)).toBe('handlers-only')
|
|
81
|
-
expect(moveRefusal(one('RECEIVED'), 'ANSWERED', writer)).toBe('handlers-only')
|
|
82
|
-
expect(moveRefusal(one('RECEIVED'), 'IN_PROGRESS', handler)).toBeUndefined()
|
|
83
|
-
expect(moveRefusal(one('CLOSED'), 'IN_PROGRESS', handler)).toBe('closed')
|
|
84
|
-
expect(moveRefusal(one('IN_PROGRESS'), 'RECEIVED', handler)).toBe('not-a-move')
|
|
85
|
-
expect(moveRefusal(one('IN_PROGRESS'), 'IN_PROGRESS', handler)).toBe('same-status')
|
|
86
|
-
expect(moveRefusal(one('IN_PROGRESS'), 'DONE', handler)).toBe('unknown-status')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('★ planned and declined are a proposal\'s', () => {
|
|
90
|
-
expect(moveRefusal(one('IN_PROGRESS'), 'PLANNED', handler)).toBe('not-a-proposal')
|
|
91
|
-
expect(moveRefusal(one('IN_PROGRESS', { kind: 'PROPOSAL' }), 'PLANNED', handler)).toBeUndefined()
|
|
92
|
-
expect(moveRefusal(one('IN_PROGRESS', { kind: 'PROPOSAL' }), 'DECLINED', handler)).toBeUndefined()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('it waits on the handlers while received or in progress', () => {
|
|
96
|
-
expect(['RECEIVED', 'IN_PROGRESS', 'ANSWERED', 'PLANNED', 'DECLINED', 'CLOSED'].filter(handlersTurn)).toEqual(['RECEIVED', 'IN_PROGRESS'])
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
describe('the badge', () => {
|
|
101
|
-
it("★ a handler's word is new to the writer until they open it", () => {
|
|
102
|
-
expect(newForAsker(one('ANSWERED'))).toBe(false)
|
|
103
|
-
expect(newForAsker(one('ANSWERED', { handlerActivityAt: '2026-11-04T10:00:00Z' }))).toBe(true)
|
|
104
|
-
expect(newForAsker(one('ANSWERED', { handlerActivityAt: '2026-11-04T10:00:00Z', askerSeenAt: '2026-11-04T09:00:00Z' }))).toBe(true)
|
|
105
|
-
expect(newForAsker(one('ANSWERED', { handlerActivityAt: '2026-11-04T10:00:00Z', askerSeenAt: '2026-11-04T11:00:00Z' }))).toBe(false)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('★ new answers for everyone, and those waiting for a handler', () => {
|
|
109
|
-
expect(badgeOf({ newAnswers: 2, unhandled: 5, handler: false })).toBe(2)
|
|
110
|
-
expect(badgeOf({ newAnswers: 2, unhandled: 5, handler: true })).toBe(7)
|
|
111
|
-
})
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
describe('the notice', () => {
|
|
115
|
-
const say = (key: string, english: string, params: Record<string, unknown> = {}) => english.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''))
|
|
116
|
-
|
|
117
|
-
it('names the kind to the handlers and the move to the writer', () => {
|
|
118
|
-
expect(noticeWordsOf({ title: '엑셀 내보내기', kind: 'PROPOSAL' }, 'received', { body: '목록을 엑셀로' }, say)).toMatchObject({ subject: 'New PROPOSAL: 엑셀 내보내기', summary: '목록을 엑셀로' })
|
|
119
|
-
expect(noticeWordsOf({ title: '엑셀 내보내기', kind: 'PROPOSAL' }, 'moved', { status: 'PLANNED' }, say).subject).toBe('PLANNED: 엑셀 내보내기')
|
|
120
|
-
})
|
|
121
|
-
})
|
package/test/open-doors.test.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Every door of inquiry is open on purpose but one: anyone signed in to the domain writes, reads and replies, and each
|
|
3
|
-
* door asks the inquiry itself (ADR-0184 decision 6). Taking one is the handlers' - `inquiry:mutation`, the domain owner
|
|
4
|
-
* admitted.
|
|
5
|
-
*/
|
|
6
|
-
import { gatesOf, openFields } from '../../../test/helpers/open-doors'
|
|
7
|
-
|
|
8
|
-
const ASKS_THE_INQUIRY = 'anyone of the domain - the door asks the inquiry who may (ADR-0184 decision 6)'
|
|
9
|
-
|
|
10
|
-
const OPEN: Record<'Query' | 'Mutation', Record<string, string>> = {
|
|
11
|
-
Query: {
|
|
12
|
-
inquiries: ASKS_THE_INQUIRY,
|
|
13
|
-
inquiry: ASKS_THE_INQUIRY,
|
|
14
|
-
inquirySummary: ASKS_THE_INQUIRY
|
|
15
|
-
},
|
|
16
|
-
Mutation: {
|
|
17
|
-
createInquiry: ASKS_THE_INQUIRY,
|
|
18
|
-
replyInquiry: ASKS_THE_INQUIRY,
|
|
19
|
-
moveInquiry: ASKS_THE_INQUIRY,
|
|
20
|
-
readInquiry: ASKS_THE_INQUIRY
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let gates: Awaited<ReturnType<typeof gatesOf>>
|
|
25
|
-
|
|
26
|
-
beforeAll(async () => {
|
|
27
|
-
gates = await gatesOf('inquiry')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it.each(['Query', 'Mutation'] as const)('★ every open %s door has a written reason', root => {
|
|
31
|
-
expect(openFields(gates[root])).toEqual(Object.keys(OPEN[root]).sort())
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it.each(['Query', 'Mutation'] as const)('★ every other %s door asks for inquiry:mutation and admits the domain owner', root => {
|
|
35
|
-
for (const [field, gate] of Object.entries(gates[root])) {
|
|
36
|
-
if (field in OPEN[root]) continue
|
|
37
|
-
expect({ field, ...gate }).toEqual({ field, category: 'inquiry', privilege: 'mutation', owner: true, super: false })
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('the walk reaches every door', () => {
|
|
42
|
-
expect(Object.keys(gates.Query)).toHaveLength(3)
|
|
43
|
-
expect(Object.keys(gates.Mutation)).toHaveLength(5)
|
|
44
|
-
})
|