@things-factory/inquiry 10.1.252 → 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,321 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The doors of inquiries (ADR-0184 decisions 2-8).
|
|
3
|
-
*
|
|
4
|
-
* Writing, reading and replying is not a privilege: anyone signed in to the domain asks, reads the open inquiries and
|
|
5
|
-
* their own, and replies on their own - each door asks the inquiry itself (inquiry-rules.ts). Taking one is the
|
|
6
|
-
* handlers' alone - `inquiry:mutation`, the module's task `inquiry.answering`; the handlers' other moves go through the
|
|
7
|
-
* same doors as the writer's, which ask the rules who may.
|
|
8
|
-
*/
|
|
9
|
-
import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
|
|
10
|
-
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
|
|
11
|
-
import { Arg, Args, Ctx, Directive, FieldResolver, Mutation, Query, Resolver, Root } from 'type-graphql'
|
|
12
|
-
import { In, IsNull, Not } from 'typeorm'
|
|
13
|
-
|
|
14
|
-
import { Attachment, createAttachment } from '@things-factory/attachment-base'
|
|
15
|
-
import { User } from '@things-factory/auth-base'
|
|
16
|
-
import { appPackage } from '@things-factory/env'
|
|
17
|
-
import { getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
|
|
18
|
-
|
|
19
|
-
import { Inquiry, InquiryReply } from './inquiry'
|
|
20
|
-
import {
|
|
21
|
-
answerRefusal,
|
|
22
|
-
badgeOf,
|
|
23
|
-
handlersTurn,
|
|
24
|
-
HANDLERS_TURN,
|
|
25
|
-
INQUIRY_STATUSES,
|
|
26
|
-
inputRefusal,
|
|
27
|
-
moveRefusal,
|
|
28
|
-
newForAsker,
|
|
29
|
-
replyRefusal,
|
|
30
|
-
replyRefusalFor,
|
|
31
|
-
sideOf,
|
|
32
|
-
statusAfterReply
|
|
33
|
-
} from './inquiry-rules'
|
|
34
|
-
import {
|
|
35
|
-
closeTodo,
|
|
36
|
-
handlerUserIds,
|
|
37
|
-
INQUIRY_REF_TYPE,
|
|
38
|
-
INQUIRY_REPLY_REF_TYPE,
|
|
39
|
-
isHandler,
|
|
40
|
-
openTodo,
|
|
41
|
-
tellWhenCommitted,
|
|
42
|
-
viewerOf,
|
|
43
|
-
visibleInquiry
|
|
44
|
-
} from './inquiry-service'
|
|
45
|
-
import { InquiryInput, InquiryList, InquirySummary } from './inquiry-types'
|
|
46
|
-
|
|
47
|
-
const HANDLE = '@privilege(category: "inquiry", privilege: "mutation", domainOwnerGranted: true)'
|
|
48
|
-
|
|
49
|
-
/** The ways the list is narrowed: every one the person may read, their own, or those waiting on the handlers. */
|
|
50
|
-
const SCOPES = ['all', 'mine', 'handlers-turn']
|
|
51
|
-
|
|
52
|
-
/** A refusal said in the reader's words, with a code the screen may read. */
|
|
53
|
-
function refuse(context: ResolverContext, code: string, fallback: string): never {
|
|
54
|
-
const message = context.t ? String(context.t(`inquiry.refusal.${code}`, { defaultValue: fallback })) : fallback
|
|
55
|
-
const error: any = new Error(message)
|
|
56
|
-
error.extensions = { code: `inquiry.${code}` }
|
|
57
|
-
throw error
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const NOT_FOUND = 'there is no such inquiry.'
|
|
61
|
-
|
|
62
|
-
async function visibleOrRefuse(id: string, context: ResolverContext, tx?: any) {
|
|
63
|
-
const visible = await visibleInquiry(id, context, tx)
|
|
64
|
-
if (!visible) refuse(context, 'unknown-inquiry', NOT_FOUND)
|
|
65
|
-
return visible
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function attachFiles(refType: string, refBy: string, files: FileUpload[] | undefined, context: ResolverContext) {
|
|
69
|
-
for (const file of files ?? []) {
|
|
70
|
-
await createAttachment(null, { attachment: { file, refType, refBy } } as any, context)
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The fields that change with a move to `status`. */
|
|
75
|
-
function stampsOf(status: string, now: Date) {
|
|
76
|
-
return {
|
|
77
|
-
status,
|
|
78
|
-
...(status === 'ANSWERED' ? { answeredAt: now } : {}),
|
|
79
|
-
...(status === 'CLOSED' ? { closedAt: now } : {})
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Keeps the handlers' to-do in line with whose turn it is: open while it waits on them, closed otherwise. */
|
|
84
|
-
async function alignTodo(inquiry: Inquiry, context: ResolverContext) {
|
|
85
|
-
if (handlersTurn(inquiry.status)) await openTodo(inquiry, context)
|
|
86
|
-
else await closeTodo(inquiry, context, inquiry.status)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
@Resolver(Inquiry)
|
|
90
|
-
export class InquiryResolver {
|
|
91
|
-
/* ---------------------------------------------------------------- reading */
|
|
92
|
-
|
|
93
|
-
@Query(returns => InquiryList, {
|
|
94
|
-
description:
|
|
95
|
-
"The inquiries the request's person may read - every open one of the domain, their own, and for a handler the private ones too - the newest first. `scope` narrows it: all (the default), mine, or handlers-turn (received or in progress, the oldest first)."
|
|
96
|
-
})
|
|
97
|
-
async inquiries(@Args(type => ListParam) params: ListParam, @Arg('scope', { nullable: true }) scope: string, @Ctx() context: ResolverContext): Promise<InquiryList> {
|
|
98
|
-
const { domain, user } = context.state
|
|
99
|
-
const narrowing = scope || 'all'
|
|
100
|
-
if (!SCOPES.includes(narrowing)) refuse(context, 'unknown-scope', `the list cannot be narrowed so (${narrowing}).`)
|
|
101
|
-
const handler = await isHandler(context)
|
|
102
|
-
const qb = getQueryBuilderFromListParams({
|
|
103
|
-
domain,
|
|
104
|
-
params: { ...params, sortings: [] },
|
|
105
|
-
repository: getRepository(Inquiry),
|
|
106
|
-
alias: 'I',
|
|
107
|
-
searchables: ['title', 'body']
|
|
108
|
-
})
|
|
109
|
-
if (!handler) qb.andWhere("(I.visibility <> 'PRIVATE' OR I.creator = :me)", { me: user.id })
|
|
110
|
-
if (narrowing === 'mine') qb.andWhere('I.creator = :me', { me: user.id })
|
|
111
|
-
if (narrowing === 'handlers-turn') qb.andWhere('I.status IN (:...turn)', { turn: [...HANDLERS_TURN] }).orderBy('I.createdAt', 'ASC')
|
|
112
|
-
else qb.orderBy('I.createdAt', 'DESC')
|
|
113
|
-
const [items, total] = await qb.getManyAndCount()
|
|
114
|
-
return { items, total }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
@Query(returns => Inquiry, { nullable: true, description: 'One inquiry, when the request may read it.' })
|
|
118
|
-
async inquiry(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Inquiry | undefined> {
|
|
119
|
-
return (await visibleInquiry(id, context))?.inquiry
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
@Query(returns => InquirySummary, { description: 'What the menu counts: new words on the person\'s own inquiries, and for a handler those waiting on the handlers.' })
|
|
123
|
-
async inquirySummary(@Ctx() context: ResolverContext): Promise<InquirySummary> {
|
|
124
|
-
const { domain, user } = context.state
|
|
125
|
-
const handler = await isHandler(context)
|
|
126
|
-
const own = await getRepository(Inquiry).find({ where: { domain: { id: domain.id }, creator: { id: user.id }, handlerActivityAt: Not(IsNull()) } as any })
|
|
127
|
-
const newAnswers = own.filter(one => newForAsker(one)).length
|
|
128
|
-
const unhandled = handler ? await getRepository(Inquiry).count({ where: { domain: { id: domain.id }, status: In([...HANDLERS_TURN]) } as any }) : 0
|
|
129
|
-
return { newAnswers, unhandled, handler, badge: badgeOf({ newAnswers, unhandled, handler }) }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/* ---------------------------------------------------------------- writing and replying */
|
|
133
|
-
|
|
134
|
-
@Directive('@transaction')
|
|
135
|
-
@Mutation(returns => Inquiry, {
|
|
136
|
-
description: 'Writes an inquiry. The application and its version, the writer and the domain are taken from the request; the screen it was written from is sent by the browser. The handlers are told.'
|
|
137
|
-
})
|
|
138
|
-
async createInquiry(
|
|
139
|
-
@Arg('input', type => InquiryInput) input: InquiryInput,
|
|
140
|
-
@Arg('attachments', type => [GraphQLUpload], { nullable: true }) attachments: FileUpload[],
|
|
141
|
-
@Ctx() context: ResolverContext
|
|
142
|
-
): Promise<Inquiry> {
|
|
143
|
-
const { domain, user, tx } = context.state as any
|
|
144
|
-
const refusal = inputRefusal(input)
|
|
145
|
-
if (refusal) refuse(context, refusal, `the inquiry cannot be kept (${refusal}).`)
|
|
146
|
-
const { name, version } = (appPackage as any) ?? {}
|
|
147
|
-
const saved = await getRepository(Inquiry, tx).save({
|
|
148
|
-
domain,
|
|
149
|
-
kind: input.kind,
|
|
150
|
-
title: input.title.trim(),
|
|
151
|
-
body: input.body ?? null,
|
|
152
|
-
status: 'RECEIVED',
|
|
153
|
-
visibility: input.visibility || 'DOMAIN',
|
|
154
|
-
pageUrl: input.pageUrl?.trim() || null,
|
|
155
|
-
appName: name ?? null,
|
|
156
|
-
appVersion: version ?? null,
|
|
157
|
-
replyCount: 0,
|
|
158
|
-
creator: user,
|
|
159
|
-
updater: user
|
|
160
|
-
} as any)
|
|
161
|
-
await attachFiles(INQUIRY_REF_TYPE, saved.id, attachments, context)
|
|
162
|
-
const inquiry = await getRepository(Inquiry, tx).findOneBy({ id: saved.id })
|
|
163
|
-
await openTodo(inquiry!, context)
|
|
164
|
-
await tellWhenCommitted(context, inquiry!, 'received', await handlerUserIds(domain, tx), { body: inquiry!.body })
|
|
165
|
-
return inquiry!
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
@Directive('@transaction')
|
|
169
|
-
@Mutation(returns => Inquiry, {
|
|
170
|
-
description:
|
|
171
|
-
"Replies on an inquiry - its writer or a handler. A handler may send the reply as the answer; the writer replying to an answer or a decision hands it back to the handlers. The other side is told."
|
|
172
|
-
})
|
|
173
|
-
async replyInquiry(
|
|
174
|
-
@Arg('id') id: string,
|
|
175
|
-
@Arg('body', { nullable: true }) body: string,
|
|
176
|
-
@Arg('answer', { nullable: true }) answer: boolean,
|
|
177
|
-
@Arg('attachments', type => [GraphQLUpload], { nullable: true }) attachments: FileUpload[],
|
|
178
|
-
@Ctx() context: ResolverContext
|
|
179
|
-
): Promise<Inquiry> {
|
|
180
|
-
const { domain, user, tx } = context.state as any
|
|
181
|
-
const { inquiry, viewer } = await visibleOrRefuse(id, context, tx)
|
|
182
|
-
const refusal = replyRefusalFor(inquiry, viewer) ?? replyRefusal(body, attachments?.length ?? 0)
|
|
183
|
-
if (refusal) refuse(context, refusal, `the reply cannot be kept (${refusal}).`)
|
|
184
|
-
const side = sideOf(inquiry, viewer)
|
|
185
|
-
const asAnswer = !!answer
|
|
186
|
-
const answerRefused = answerRefusal(side, asAnswer)
|
|
187
|
-
if (answerRefused) refuse(context, answerRefused, 'only a handler sends the answer.')
|
|
188
|
-
const status = statusAfterReply(inquiry, side, asAnswer)
|
|
189
|
-
const now = new Date()
|
|
190
|
-
const reply = await getRepository(InquiryReply, tx).save({
|
|
191
|
-
domain,
|
|
192
|
-
inquiry: { id },
|
|
193
|
-
body: body?.trim() || null,
|
|
194
|
-
byHandler: side === 'handler',
|
|
195
|
-
answer: asAnswer,
|
|
196
|
-
status: status !== inquiry.status ? status : null,
|
|
197
|
-
creator: user,
|
|
198
|
-
updater: user
|
|
199
|
-
} as any)
|
|
200
|
-
await attachFiles(INQUIRY_REPLY_REF_TYPE, reply.id, attachments, context)
|
|
201
|
-
await getRepository(Inquiry, tx).update(id, {
|
|
202
|
-
...stampsOf(status, now),
|
|
203
|
-
replyCount: inquiry.replyCount + 1,
|
|
204
|
-
...(side === 'handler' ? { handlerActivityAt: now, ...(inquiry.assigneeId ? {} : { assignee: user }) } : { askerSeenAt: now }),
|
|
205
|
-
updater: user
|
|
206
|
-
} as any)
|
|
207
|
-
const fresh = (await getRepository(Inquiry, tx).findOneBy({ id }))!
|
|
208
|
-
await alignTodo(fresh, context)
|
|
209
|
-
if (side === 'handler') await tellWhenCommitted(context, fresh, asAnswer ? 'answered' : 'replied', [fresh.creatorId!], { body })
|
|
210
|
-
else await tellWhenCommitted(context, fresh, 'asker-replied', fresh.assigneeId ? [fresh.assigneeId] : await handlerUserIds(domain, tx), { body })
|
|
211
|
-
return fresh
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
@Directive('@transaction')
|
|
215
|
-
@Mutation(returns => Inquiry, {
|
|
216
|
-
description:
|
|
217
|
-
'Moves an inquiry - its writer closes it; a handler puts it in progress, answered, planned or declined (a proposal only) or closed. The reason, when given, stays on the thread. The writer is told of a handler\'s move.'
|
|
218
|
-
})
|
|
219
|
-
async moveInquiry(@Arg('id') id: string, @Arg('status') status: string, @Arg('reason', { nullable: true }) reason: string, @Ctx() context: ResolverContext): Promise<Inquiry> {
|
|
220
|
-
const { domain, user, tx } = context.state as any
|
|
221
|
-
const { inquiry, viewer } = await visibleOrRefuse(id, context, tx)
|
|
222
|
-
const refusal = moveRefusal(inquiry, status, viewer)
|
|
223
|
-
if (refusal) refuse(context, refusal, `the inquiry cannot be moved so (${refusal}).`)
|
|
224
|
-
const side = sideOf(inquiry, viewer)
|
|
225
|
-
const now = new Date()
|
|
226
|
-
await getRepository(InquiryReply, tx).save({ domain, inquiry: { id }, body: reason?.trim() || null, byHandler: side === 'handler', answer: false, status, creator: user, updater: user } as any)
|
|
227
|
-
await getRepository(Inquiry, tx).update(id, {
|
|
228
|
-
...stampsOf(status, now),
|
|
229
|
-
replyCount: inquiry.replyCount + 1,
|
|
230
|
-
...(side === 'handler' ? { handlerActivityAt: now, ...(inquiry.assigneeId ? {} : { assignee: user }) } : { askerSeenAt: now }),
|
|
231
|
-
updater: user
|
|
232
|
-
} as any)
|
|
233
|
-
const fresh = (await getRepository(Inquiry, tx).findOneBy({ id }))!
|
|
234
|
-
await alignTodo(fresh, context)
|
|
235
|
-
if (side === 'handler') await tellWhenCommitted(context, fresh, 'moved', [fresh.creatorId!], { body: reason, status })
|
|
236
|
-
return fresh
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
@Directive(HANDLE)
|
|
240
|
-
@Directive('@transaction')
|
|
241
|
-
@Mutation(returns => Inquiry, { description: 'A handler takes an inquiry - it is theirs to answer, and in progress when it was received.' })
|
|
242
|
-
async takeInquiry(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Inquiry> {
|
|
243
|
-
const { user, tx } = context.state as any
|
|
244
|
-
const { inquiry } = await visibleOrRefuse(id, context, tx)
|
|
245
|
-
if (inquiry.status === 'CLOSED') refuse(context, 'closed', 'a closed inquiry is not taken.')
|
|
246
|
-
await getRepository(Inquiry, tx).update(id, { assignee: user, ...(inquiry.status === 'RECEIVED' ? { status: 'IN_PROGRESS' } : {}), updater: user } as any)
|
|
247
|
-
return (await getRepository(Inquiry, tx).findOneBy({ id }))!
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
@Directive('@transaction')
|
|
251
|
-
@Mutation(returns => Inquiry, { description: "Records that its writer opened it - the handlers' words on it are no longer new. Anyone else opening it leaves no trace." })
|
|
252
|
-
async readInquiry(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Inquiry> {
|
|
253
|
-
const { user, tx } = context.state as any
|
|
254
|
-
const { inquiry } = await visibleOrRefuse(id, context, tx)
|
|
255
|
-
if (inquiry.creatorId !== user.id) return inquiry
|
|
256
|
-
await getRepository(Inquiry, tx).update(id, { askerSeenAt: new Date() } as any)
|
|
257
|
-
return (await getRepository(Inquiry, tx).findOneBy({ id }))!
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/* ---------------------------------------------------------------- fields */
|
|
261
|
-
|
|
262
|
-
@FieldResolver(type => [InquiryReply], { description: 'Its replies, the oldest first.' })
|
|
263
|
-
async replies(@Root() one: Inquiry): Promise<InquiryReply[]> {
|
|
264
|
-
return await getRepository(InquiryReply).find({ where: { inquiry: { id: one.id } } as any, order: { createdAt: 'ASC' } as any })
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
@FieldResolver(type => [Attachment], { description: 'Its files.' })
|
|
268
|
-
async attachments(@Root() one: Inquiry, @Ctx() context: ResolverContext): Promise<Attachment[]> {
|
|
269
|
-
return await getRepository(Attachment).find({ where: { domain: { id: context.state.domain.id }, refType: INQUIRY_REF_TYPE, refBy: one.id } as any, order: { createdAt: 'ASC' } as any })
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
@FieldResolver(type => Boolean, { description: "Whether the request's person wrote it." })
|
|
273
|
-
mine(@Root() one: Inquiry, @Ctx() context: ResolverContext): boolean {
|
|
274
|
-
return one.creatorId === context.state.user.id
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
@FieldResolver(type => Boolean, { description: "Whether it has a handler's word its writer has not seen - for its writer." })
|
|
278
|
-
isNew(@Root() one: Inquiry, @Ctx() context: ResolverContext): boolean {
|
|
279
|
-
return one.creatorId === context.state.user.id && newForAsker(one)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
@FieldResolver(type => Boolean, { description: 'Whether the request may reply on it.' })
|
|
283
|
-
async canReply(@Root() one: Inquiry, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
284
|
-
return !replyRefusalFor(one, await viewerOf(context))
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
@FieldResolver(type => Boolean, { description: 'Whether the request replies on it as a handler - and so may send the answer.' })
|
|
288
|
-
async asHandler(@Root() one: Inquiry, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
289
|
-
const viewer = await viewerOf(context)
|
|
290
|
-
return viewer.handler && sideOf(one, viewer) === 'handler'
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
@FieldResolver(type => [String], { description: 'Where the request may move it.' })
|
|
294
|
-
async moves(@Root() one: Inquiry, @Ctx() context: ResolverContext): Promise<string[]> {
|
|
295
|
-
const viewer = await viewerOf(context)
|
|
296
|
-
return INQUIRY_STATUSES.filter(status => !moveRefusal(one, status, viewer))
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
@FieldResolver(type => User, { nullable: true, description: 'Who wrote it.' })
|
|
300
|
-
async creator(@Root() one: Inquiry): Promise<User | null> {
|
|
301
|
-
return one.creatorId ? await getRepository(User).findOneBy({ id: one.creatorId }) : null
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
@FieldResolver(type => User, { nullable: true, description: 'The handler who took it.' })
|
|
305
|
-
async assignee(@Root() one: Inquiry): Promise<User | null> {
|
|
306
|
-
return one.assigneeId ? await getRepository(User).findOneBy({ id: one.assigneeId }) : null
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
@Resolver(InquiryReply)
|
|
311
|
-
export class InquiryReplyResolver {
|
|
312
|
-
@FieldResolver(type => [Attachment], { description: 'Its files.' })
|
|
313
|
-
async attachments(@Root() one: InquiryReply, @Ctx() context: ResolverContext): Promise<Attachment[]> {
|
|
314
|
-
return await getRepository(Attachment).find({ where: { domain: { id: context.state.domain.id }, refType: INQUIRY_REPLY_REF_TYPE, refBy: one.id } as any, order: { createdAt: 'ASC' } as any })
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
@FieldResolver(type => User, { nullable: true, description: 'Who wrote it.' })
|
|
318
|
-
async creator(@Root() one: InquiryReply): Promise<User | null> {
|
|
319
|
-
return one.creatorId ? await getRepository(User).findOneBy({ id: one.creatorId }) : null
|
|
320
|
-
}
|
|
321
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The rules of an inquiry (ADR-0184 decisions 1-8) - what may be written, who reads and answers it, where it stands
|
|
3
|
-
* after a reply or a move, whose turn it is, and what its notice says.
|
|
4
|
-
*
|
|
5
|
-
* Pure - the rules are tested without a database. The doors (inquiry-resolver.ts) ask them.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** The kinds of inquiry - a question, a bug report, a feature proposal (`inquiry.kind.<code>`). */
|
|
9
|
-
export const INQUIRY_KINDS = ['QUESTION', 'BUG', 'PROPOSAL'] as const
|
|
10
|
-
|
|
11
|
-
/** Where an inquiry stands (`inquiry.status.<code>`). PLANNED and DECLINED are a proposal's only. */
|
|
12
|
-
export const INQUIRY_STATUSES = ['RECEIVED', 'IN_PROGRESS', 'ANSWERED', 'PLANNED', 'DECLINED', 'CLOSED'] as const
|
|
13
|
-
|
|
14
|
-
/** Who reads it: everyone of the domain, or its writer and the handlers (`inquiry.visibility.<code>`). */
|
|
15
|
-
export const INQUIRY_VISIBILITIES = ['DOMAIN', 'PRIVATE'] as const
|
|
16
|
-
|
|
17
|
-
export type InquiryKind = (typeof INQUIRY_KINDS)[number]
|
|
18
|
-
export type InquiryStatus = (typeof INQUIRY_STATUSES)[number]
|
|
19
|
-
|
|
20
|
-
export const MAX_TITLE = 200
|
|
21
|
-
export const MAX_BODY = 20000
|
|
22
|
-
export const MAX_PAGE_URL = 2000
|
|
23
|
-
|
|
24
|
-
/** The states that wait on the handlers - a to-do is open for them, and the handlers' badge counts them. */
|
|
25
|
-
export const HANDLERS_TURN: readonly string[] = ['RECEIVED', 'IN_PROGRESS']
|
|
26
|
-
|
|
27
|
-
export interface InquiryFacts {
|
|
28
|
-
kind: string
|
|
29
|
-
status: string
|
|
30
|
-
visibility: string
|
|
31
|
-
creatorId?: string | null
|
|
32
|
-
handlerActivityAt?: Date | string | null
|
|
33
|
-
askerSeenAt?: Date | string | null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The request's person as an inquiry sees them. */
|
|
37
|
-
export interface Viewer {
|
|
38
|
-
userId: string
|
|
39
|
-
/** Holds `inquiry:mutation` (the task `inquiry.answering`), or owns the domain. */
|
|
40
|
-
handler: boolean
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface InquiryInputFacts {
|
|
44
|
-
kind?: string | null
|
|
45
|
-
title?: string | null
|
|
46
|
-
body?: string | null
|
|
47
|
-
visibility?: string | null
|
|
48
|
-
pageUrl?: string | null
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Why what was written cannot be kept, or nothing. */
|
|
52
|
-
export function inputRefusal(input: InquiryInputFacts): string | undefined {
|
|
53
|
-
if (!(INQUIRY_KINDS as readonly string[]).includes(String(input.kind ?? ''))) return 'unknown-kind'
|
|
54
|
-
const title = String(input.title ?? '').trim()
|
|
55
|
-
if (!title) return 'no-title'
|
|
56
|
-
if (title.length > MAX_TITLE) return 'title-too-long'
|
|
57
|
-
if (String(input.body ?? '').length > MAX_BODY) return 'body-too-long'
|
|
58
|
-
if (input.visibility && !(INQUIRY_VISIBILITIES as readonly string[]).includes(input.visibility)) return 'unknown-visibility'
|
|
59
|
-
if (String(input.pageUrl ?? '').length > MAX_PAGE_URL) return 'page-url-too-long'
|
|
60
|
-
return undefined
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Whether a reply says something - words, or files. */
|
|
64
|
-
export function replyRefusal(body: string | null | undefined, files: number): string | undefined {
|
|
65
|
-
const text = String(body ?? '').trim()
|
|
66
|
-
if (!text && !files) return 'empty-reply'
|
|
67
|
-
if (text.length > MAX_BODY) return 'body-too-long'
|
|
68
|
-
return undefined
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const isAuthor = (one: InquiryFacts, viewer: Viewer) => !!one.creatorId && one.creatorId === viewer.userId
|
|
72
|
-
|
|
73
|
-
/** An open inquiry is read by everyone of the domain; a private one by its writer and the handlers (decision 5). */
|
|
74
|
-
export function mayRead(one: InquiryFacts, viewer: Viewer): boolean {
|
|
75
|
-
return one.visibility !== 'PRIVATE' || isAuthor(one, viewer) || viewer.handler
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Replies go back and forth between the writer and the handlers, until it is closed (decision 3). */
|
|
79
|
-
export function replyRefusalFor(one: InquiryFacts, viewer: Viewer): string | undefined {
|
|
80
|
-
if (!isAuthor(one, viewer) && !viewer.handler) return 'not-a-party'
|
|
81
|
-
if (one.status === 'CLOSED') return 'closed'
|
|
82
|
-
return undefined
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Which side a reply is from - the writer's own reply is the asker's, even when the writer is a handler. */
|
|
86
|
-
export function sideOf(one: InquiryFacts, viewer: Viewer): 'asker' | 'handler' {
|
|
87
|
-
return isAuthor(one, viewer) ? 'asker' : 'handler'
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Where it stands after a reply (decision 2). A handler's reply marked as the answer answers it; any other handler's
|
|
92
|
-
* reply puts a received one in progress. The asker replying to an answer or a decision hands it back to the handlers.
|
|
93
|
-
*/
|
|
94
|
-
export function statusAfterReply(one: InquiryFacts, side: 'asker' | 'handler', asAnswer: boolean): string {
|
|
95
|
-
if (side === 'handler') {
|
|
96
|
-
if (asAnswer) return 'ANSWERED'
|
|
97
|
-
return one.status === 'RECEIVED' ? 'IN_PROGRESS' : one.status
|
|
98
|
-
}
|
|
99
|
-
return ['ANSWERED', 'PLANNED', 'DECLINED'].includes(one.status) ? 'IN_PROGRESS' : one.status
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Whether `side` may mark a reply as the answer - the handlers' only. */
|
|
103
|
-
export function answerRefusal(side: 'asker' | 'handler', asAnswer: boolean): string | undefined {
|
|
104
|
-
return asAnswer && side !== 'handler' ? 'handlers-only' : undefined
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Why the request may not move it to `to`, or nothing (decision 2). The writer closes their own; everything else is the
|
|
109
|
-
* handlers'. A closed one stays closed; PLANNED and DECLINED are a proposal's; nothing goes back to RECEIVED.
|
|
110
|
-
*/
|
|
111
|
-
export function moveRefusal(one: InquiryFacts, to: string, viewer: Viewer): string | undefined {
|
|
112
|
-
if (!(INQUIRY_STATUSES as readonly string[]).includes(to)) return 'unknown-status'
|
|
113
|
-
if (one.status === 'CLOSED') return 'closed'
|
|
114
|
-
if (to === one.status) return 'same-status'
|
|
115
|
-
if (to === 'RECEIVED') return 'not-a-move'
|
|
116
|
-
if ((to === 'PLANNED' || to === 'DECLINED') && one.kind !== 'PROPOSAL') return 'not-a-proposal'
|
|
117
|
-
if (to === 'CLOSED' && isAuthor(one, viewer)) return undefined
|
|
118
|
-
if (!viewer.handler) return 'handlers-only'
|
|
119
|
-
return undefined
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Whether it waits on the handlers. */
|
|
123
|
-
export const handlersTurn = (status: string) => HANDLERS_TURN.includes(status)
|
|
124
|
-
|
|
125
|
-
/** Whether the writer has a handler's word they have not seen - a reply or a move since they last opened it. */
|
|
126
|
-
export function newForAsker(one: InquiryFacts): boolean {
|
|
127
|
-
const said = one.handlerActivityAt ? new Date(one.handlerActivityAt).getTime() : undefined
|
|
128
|
-
if (said === undefined) return false
|
|
129
|
-
const seen = one.askerSeenAt ? new Date(one.askerSeenAt).getTime() : undefined
|
|
130
|
-
return seen === undefined || said > seen
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The count on the menu entry (decision 8): new words on one's own inquiries, and for a handler those waiting on them. */
|
|
134
|
-
export function badgeOf(summary: { newAnswers: number; unhandled: number; handler: boolean }): number {
|
|
135
|
-
return summary.newAnswers + (summary.handler ? summary.unhandled : 0)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The screen an inquiry opens on. */
|
|
139
|
-
export const viewAddressOf = (id: string) => `inquiry-list?id=${id}`
|
|
140
|
-
|
|
141
|
-
/** Why the people are told. */
|
|
142
|
-
export type NoticeKind = 'received' | 'replied' | 'answered' | 'moved' | 'asker-replied'
|
|
143
|
-
|
|
144
|
-
/** A sentence in one language, with the English one when that language has none. */
|
|
145
|
-
export type Say = (key: string, english: string, params?: Record<string, unknown>) => string
|
|
146
|
-
|
|
147
|
-
/** The body as one line of plain words - the notice's summary. */
|
|
148
|
-
export function plainSummary(body: string | null | undefined, max = 120): string {
|
|
149
|
-
const text = String(body ?? '')
|
|
150
|
-
.replace(/\s+/g, ' ')
|
|
151
|
-
.trim()
|
|
152
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Who is told of what (decision 7): the handlers of a new one, the writer of a handler's word, the handler of the writer's. */
|
|
156
|
-
export function noticeWordsOf(one: { title: string; kind: string }, kind: NoticeKind, said: { body?: string | null; status?: string }, say: Say) {
|
|
157
|
-
const kindWord = say(`inquiry.kind.${one.kind}`, one.kind)
|
|
158
|
-
const title = one.title
|
|
159
|
-
const subject = {
|
|
160
|
-
received: say('inquiry.notice.received-subject', 'New {kind}: {title}', { kind: kindWord, title }),
|
|
161
|
-
replied: say('inquiry.notice.replied-subject', 'A reply to your {kind}: {title}', { kind: kindWord, title }),
|
|
162
|
-
answered: say('inquiry.notice.answered-subject', 'Answered: {title}', { title }),
|
|
163
|
-
moved: say('inquiry.notice.moved-subject', '{status}: {title}', { status: say(`inquiry.status.${said.status}`, String(said.status ?? '')), title }),
|
|
164
|
-
'asker-replied': say('inquiry.notice.asker-replied-subject', 'The writer replied: {title}', { title })
|
|
165
|
-
}[kind]
|
|
166
|
-
return {
|
|
167
|
-
subject,
|
|
168
|
-
summary: plainSummary(said.body) || subject,
|
|
169
|
-
body: said.body ?? '',
|
|
170
|
-
action: say('inquiry.notice.action', 'Open the inquiry'),
|
|
171
|
-
note: say('inquiry.notice.note', 'Open Inquiries in the application to read it.')
|
|
172
|
-
}
|
|
173
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
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
|
-
}
|