@things-factory/inquiry 0.0.0-stage → 10.1.251
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/bootstrap.ts +27 -0
- package/client/components/inquiry-thread.ts +277 -0
- package/client/components/inquiry-todo.ts +36 -0
- package/client/components/inquiry-writer.ts +208 -0
- package/client/index.ts +2 -0
- package/client/inquiry-watch.ts +45 -0
- package/client/pages/inquiry-list.ts +218 -0
- package/client/route.ts +8 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/inquiry-api.ts +103 -0
- package/client/utils/inquiry-badge.ts +46 -0
- package/client/utils/inquiry-words.ts +26 -0
- package/dist-client/bootstrap.d.ts +2 -0
- package/dist-client/bootstrap.js +25 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/inquiry-thread.d.ts +28 -0
- package/dist-client/components/inquiry-thread.js +303 -0
- package/dist-client/components/inquiry-thread.js.map +1 -0
- package/dist-client/components/inquiry-todo.d.ts +9 -0
- package/dist-client/components/inquiry-todo.js +43 -0
- package/dist-client/components/inquiry-todo.js.map +1 -0
- package/dist-client/components/inquiry-writer.d.ts +23 -0
- package/dist-client/components/inquiry-writer.js +230 -0
- package/dist-client/components/inquiry-writer.js.map +1 -0
- package/dist-client/index.d.ts +2 -0
- package/dist-client/index.js +3 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/inquiry-watch.d.ts +2 -0
- package/dist-client/inquiry-watch.js +41 -0
- package/dist-client/inquiry-watch.js.map +1 -0
- package/dist-client/pages/inquiry-list.d.ts +56 -0
- package/dist-client/pages/inquiry-list.js +241 -0
- package/dist-client/pages/inquiry-list.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +9 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/inquiry-api.d.ts +73 -0
- package/dist-client/utils/inquiry-api.js +34 -0
- package/dist-client/utils/inquiry-api.js.map +1 -0
- package/dist-client/utils/inquiry-badge.d.ts +4 -0
- package/dist-client/utils/inquiry-badge.js +47 -0
- package/dist-client/utils/inquiry-badge.js.map +1 -0
- package/dist-client/utils/inquiry-words.d.ts +13 -0
- package/dist-client/utils/inquiry-words.js +22 -0
- package/dist-client/utils/inquiry-words.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +21 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +22 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/inquiry-activity.d.ts +38 -0
- package/dist-server/inquiry-activity.js +38 -0
- package/dist-server/inquiry-activity.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +14 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +24 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/inquiry/index.d.ts +4 -0
- package/dist-server/service/inquiry/index.js +8 -0
- package/dist-server/service/inquiry/index.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-resolver.d.ts +29 -0
- package/dist-server/service/inquiry/inquiry-resolver.js +407 -0
- package/dist-server/service/inquiry/inquiry-resolver.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-rules.d.ts +88 -0
- package/dist-server/service/inquiry/inquiry-rules.js +157 -0
- package/dist-server/service/inquiry/inquiry-rules.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-service.d.ts +35 -0
- package/dist-server/service/inquiry/inquiry-service.js +143 -0
- package/dist-server/service/inquiry/inquiry-service.js.map +1 -0
- package/dist-server/service/inquiry/inquiry-types.d.ts +18 -0
- package/dist-server/service/inquiry/inquiry-types.js +69 -0
- package/dist-server/service/inquiry/inquiry-types.js.map +1 -0
- package/dist-server/service/inquiry/inquiry.d.ts +48 -0
- package/dist-server/service/inquiry/inquiry.js +232 -0
- package/dist-server/service/inquiry/inquiry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -4
- package/server/attachment-reader.ts +18 -0
- package/server/index.ts +18 -0
- package/server/inquiry-activity.ts +35 -0
- package/server/role-tasks.ts +13 -0
- package/server/service/index.ts +19 -0
- package/server/service/inquiry/index.ts +5 -0
- package/server/service/inquiry/inquiry-resolver.ts +321 -0
- package/server/service/inquiry/inquiry-rules.ts +173 -0
- package/server/service/inquiry/inquiry-service.ts +143 -0
- package/server/service/inquiry/inquiry-types.ts +45 -0
- package/server/service/inquiry/inquiry.ts +180 -0
- package/server/tsconfig.json +11 -0
- package/test/inquiry-persistence.test.ts +218 -0
- package/test/inquiry-rules.test.ts +121 -0
- package/test/open-doors.test.ts +44 -0
- package/test/translations.test.ts +53 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +93 -0
- package/translations/es.json +93 -0
- package/translations/ja.json +93 -0
- package/translations/ko.json +93 -0
- package/translations/ms.json +93 -0
- package/translations/zh.json +93 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/inquiry",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "10.1.251",
|
|
4
|
+
"main": "dist-server/index.js",
|
|
5
|
+
"browser": "dist-client/index.js",
|
|
6
|
+
"things-factory": true,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
9
|
+
"description": "Inquiries - a question, a bug report or a feature proposal, written from any screen and answered by the domain's handlers",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"@things-factory:registry": "https://registry.npmjs.org"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/hatiolab/things-factory.git",
|
|
17
|
+
"directory": "packages/inquiry"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "npm run build:server && npm run build:client",
|
|
21
|
+
"copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
|
|
22
|
+
"build:client": "npm run clean:client && npm run copy:files && npx tsc --p ./client/tsconfig.json",
|
|
23
|
+
"build:server": "npm run clean:server && npx tsc --p ./server/tsconfig.json",
|
|
24
|
+
"clean:server": "rm -rf dist-server",
|
|
25
|
+
"clean:client": "rm -rf dist-client",
|
|
26
|
+
"clean": "npm run clean:server && npm run clean:client"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@material/web": "^2.0.0",
|
|
30
|
+
"@operato/data-grist": "^10.0.0",
|
|
31
|
+
"@operato/graphql": "^10.29.39",
|
|
32
|
+
"@operato/i18n": "^10.0.0",
|
|
33
|
+
"@operato/input": "^10.29.43",
|
|
34
|
+
"@operato/layout": "^10.29.49",
|
|
35
|
+
"@operato/popup": "^10.29.39",
|
|
36
|
+
"@operato/shell": "^10.29.39",
|
|
37
|
+
"@operato/styles": "^10.29.1",
|
|
38
|
+
"@operato/utils": "^10.0.0",
|
|
39
|
+
"@things-factory/apptool-base": "^10.1.247",
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.250",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.250",
|
|
42
|
+
"@things-factory/env": "^10.1.149",
|
|
43
|
+
"@things-factory/i18n-base": "^10.1.250",
|
|
44
|
+
"@things-factory/notification": "^10.1.250",
|
|
45
|
+
"@things-factory/shell": "^10.1.247",
|
|
46
|
+
"@things-factory/worklist": "^10.1.250",
|
|
47
|
+
"graphql-scalars": "^1.22.0",
|
|
48
|
+
"graphql-tag": "^2.12.6",
|
|
49
|
+
"graphql-upload": "^15.0.2",
|
|
50
|
+
"lit": "^3.1.2"
|
|
51
|
+
},
|
|
52
|
+
"gitHead": "6a4c45983e1d8aa4193bbbca1bd79ea3596d2175"
|
|
53
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Who may open an inquiry's file by its path - whoever may read the inquiry (ADR-0184 decision 5), a reply's file as
|
|
3
|
+
* its inquiry's. attachment-base has already checked the file is of the request's domain.
|
|
4
|
+
*/
|
|
5
|
+
import { registerAttachmentReader } from '@things-factory/attachment-base'
|
|
6
|
+
import { getRepository } from '@things-factory/shell'
|
|
7
|
+
|
|
8
|
+
import { InquiryReply } from './service/inquiry/inquiry'
|
|
9
|
+
import { INQUIRY_REF_TYPE, INQUIRY_REPLY_REF_TYPE, visibleInquiry } from './service/inquiry/inquiry-service'
|
|
10
|
+
|
|
11
|
+
export function registerInquiryFiles(): void {
|
|
12
|
+
registerAttachmentReader(INQUIRY_REF_TYPE, async (attachment, context) => !!attachment.refBy && !!(await visibleInquiry(attachment.refBy, context)))
|
|
13
|
+
registerAttachmentReader(INQUIRY_REPLY_REF_TYPE, async (attachment, context) => {
|
|
14
|
+
if (!attachment.refBy) return false
|
|
15
|
+
const reply = await getRepository(InquiryReply).findOneBy({ id: attachment.refBy })
|
|
16
|
+
return !!reply?.inquiryId && !!(await visibleInquiry(reply.inquiryId, context))
|
|
17
|
+
})
|
|
18
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './service'
|
|
2
|
+
export { INQUIRY_ACTIVITY, INQUIRY_HANDLING } from './inquiry-activity'
|
|
3
|
+
|
|
4
|
+
import { registerRoleTask } from '@things-factory/auth-base'
|
|
5
|
+
import { ActivityInstallations } from '@things-factory/worklist'
|
|
6
|
+
|
|
7
|
+
import { registerInquiryFiles } from './attachment-reader'
|
|
8
|
+
import { INQUIRY_ACTIVITY } from './inquiry-activity'
|
|
9
|
+
import { ROLE_TASKS } from './role-tasks'
|
|
10
|
+
|
|
11
|
+
for (const task of ROLE_TASKS) registerRoleTask(task)
|
|
12
|
+
registerInquiryFiles()
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Importing this file registers the handlers' to-do (ADR-0184 decision 7) - a bootstrap hook would be too late for
|
|
16
|
+
* worklist to find it. Which domains have it is the application's call: it installs `INQUIRY_ACTIVITY`.
|
|
17
|
+
*/
|
|
18
|
+
ActivityInstallations.installActivityTemplate(INQUIRY_ACTIVITY as any)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 문의 처리 (ADR-0184 decision 7) - not an approval: while an inquiry waits on the handlers, one case of this activity is
|
|
3
|
+
* open, pooled on the roles that give inquiry handling. It shows the inquiry through this module's own element
|
|
4
|
+
* (`inquiry-todo`), which opens it; answering, deciding or closing the inquiry closes the case.
|
|
5
|
+
*
|
|
6
|
+
* Data only. An application installs it into its domains (Office `REQUIRED_ACTIVITIES`).
|
|
7
|
+
*/
|
|
8
|
+
export const INQUIRY_HANDLING = 'Inquiry Handling'
|
|
9
|
+
|
|
10
|
+
export const INQUIRY_ACTIVITY = {
|
|
11
|
+
name: INQUIRY_HANDLING,
|
|
12
|
+
label: 'inquiry handling',
|
|
13
|
+
description: 'Answering a question, a bug report or a feature proposal',
|
|
14
|
+
guide: [
|
|
15
|
+
'while an inquiry waits on the handlers, one case is open for the roles that give inquiry handling - whoever of them picks it.',
|
|
16
|
+
'it opens the inquiry; answering it, deciding a proposal or closing it closes the case.',
|
|
17
|
+
'the writer replying to an answer opens a new case.'
|
|
18
|
+
],
|
|
19
|
+
release: '1.0.0',
|
|
20
|
+
provider: 'hatiolab.com',
|
|
21
|
+
category: 'support',
|
|
22
|
+
activityType: 'user',
|
|
23
|
+
priority: 2,
|
|
24
|
+
startingType: 'issue',
|
|
25
|
+
multiple: 'single',
|
|
26
|
+
uiType: 'custom-element',
|
|
27
|
+
uiSource: 'inquiry-todo',
|
|
28
|
+
searchKeys: [{ name: 'inquiryId', description: 'The inquiry', inputKey: 'inquiryId', tKey: 'field.subject' }],
|
|
29
|
+
viewType: '',
|
|
30
|
+
viewSource: '',
|
|
31
|
+
model: [
|
|
32
|
+
{ name: 'inquiryId', description: 'The inquiry this to-do answers', tag: 'inquiryId', hidden: true, mandatory: true, inout: 'in', type: 'string', options: {}, unit: null, quantifier: [1], spec: {} }
|
|
33
|
+
],
|
|
34
|
+
standardTime: 3 * 24 * 60 * 60
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The work this module names, for the seats applications build (ADR-0139 decision 11).
|
|
3
|
+
*
|
|
4
|
+
* Writing an inquiry, and reading one's own and the open ones, is no task: anyone signed in to the domain asks
|
|
5
|
+
* (ADR-0184 decision 6). Taking, answering, deciding where one stands and reading the private ones is
|
|
6
|
+
* `inquiry.answering`.
|
|
7
|
+
*
|
|
8
|
+
* Data only - `server/index.ts` registers it - so an application's tests read it without starting the framework.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RoleTask } from '@things-factory/auth-base'
|
|
12
|
+
|
|
13
|
+
export const ROLE_TASKS: RoleTask[] = [{ id: 'inquiry.answering', grants: [{ category: 'inquiry', axes: ['mutation'] }] }]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* EXPORT ENTITY TYPES */
|
|
2
|
+
export * from './inquiry/inquiry'
|
|
3
|
+
export * from './inquiry/inquiry-rules'
|
|
4
|
+
export { INQUIRY_REF_TYPE, INQUIRY_REPLY_REF_TYPE, visibleInquiry } from './inquiry/inquiry-service'
|
|
5
|
+
|
|
6
|
+
/* IMPORT ENTITIES AND RESOLVERS */
|
|
7
|
+
import { entities as InquiryEntities, resolvers as InquiryResolvers } from './inquiry'
|
|
8
|
+
|
|
9
|
+
export const entities = [
|
|
10
|
+
/* ENTITIES */
|
|
11
|
+
...InquiryEntities
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export const schema = {
|
|
15
|
+
resolverClasses: [
|
|
16
|
+
/* RESOLVER CLASSES */
|
|
17
|
+
...InquiryResolvers
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
}
|