@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.
@@ -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
- }