@things-factory/announcement 10.1.250 → 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/service/announcement/announcement-trigger.d.ts +55 -0
- package/dist-server/service/announcement/announcement-trigger.js +97 -0
- package/dist-server/service/announcement/announcement-trigger.js.map +1 -0
- package/dist-server/service/announcement/announcement.d.ts +3 -0
- package/dist-server/service/announcement/announcement.js +16 -0
- package/dist-server/service/announcement/announcement.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +1 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/server/service/announcement/announcement-trigger.ts +123 -0
- package/server/service/announcement/announcement.ts +13 -0
- package/server/service/index.ts +1 -0
- package/test/announcement-persistence.test.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/announcement",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.254",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
"@operato/shell": "^10.29.39",
|
|
37
37
|
"@operato/styles": "^10.29.1",
|
|
38
38
|
"@operato/utils": "^10.0.0",
|
|
39
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
39
|
+
"@things-factory/ai-assistant": "^10.1.254",
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.254",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.254",
|
|
42
42
|
"@things-factory/env": "^10.1.149",
|
|
43
|
-
"@things-factory/i18n-base": "^10.1.
|
|
44
|
-
"@things-factory/notification": "^10.1.
|
|
45
|
-
"@things-factory/organization": "^10.1.
|
|
46
|
-
"@things-factory/shell": "^10.1.
|
|
43
|
+
"@things-factory/i18n-base": "^10.1.254",
|
|
44
|
+
"@things-factory/notification": "^10.1.254",
|
|
45
|
+
"@things-factory/organization": "^10.1.254",
|
|
46
|
+
"@things-factory/shell": "^10.1.254",
|
|
47
47
|
"graphql-scalars": "^1.22.0",
|
|
48
48
|
"graphql-tag": "^2.12.6",
|
|
49
49
|
"graphql-upload": "^15.0.2",
|
|
50
50
|
"lit": "^3.1.2"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "793e2fd617db5bb917ec892e64956fb690f7e7b0"
|
|
53
53
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 계기 (ADR-0163 decision 7, the least of it) - a module tells of a fact of its own as an announcement: a company rule
|
|
3
|
+
* adopted, a training scheduled. The module names the trigger and gives the words and the record; the application says,
|
|
4
|
+
* once at boot, how an announcement of that trigger goes out (its kind, its weight, who reads it, draft or out at once).
|
|
5
|
+
* A trigger with no rule writes nothing - an application that has not decided tells nobody.
|
|
6
|
+
*
|
|
7
|
+
* The announcement keeps its trigger and record (`trigger`, `sourceKind`, `sourceId`), so the record finds the
|
|
8
|
+
* announcements of it and their readings - the reading is kept here once, never again by the module.
|
|
9
|
+
*/
|
|
10
|
+
import { EntityManager, In } from 'typeorm'
|
|
11
|
+
|
|
12
|
+
import { logger } from '@things-factory/env'
|
|
13
|
+
import { getRepository } from '@things-factory/shell'
|
|
14
|
+
|
|
15
|
+
import { Announcement, AnnouncementAudience, AnnouncementImportance, AnnouncementReceipt, AnnouncementStatus } from './announcement'
|
|
16
|
+
import { ANNOUNCEMENT_CATEGORIES, rankOf } from './announcement-rules'
|
|
17
|
+
import { sendOut, tellWhenCommitted } from './announcement-service'
|
|
18
|
+
|
|
19
|
+
export interface AnnouncementTrigger {
|
|
20
|
+
/** `compliance.regulation-adopted` - the module's name, a dot, the fact. */
|
|
21
|
+
name: string
|
|
22
|
+
description: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AnnouncementTriggerRule {
|
|
26
|
+
category?: (typeof ANNOUNCEMENT_CATEGORIES)[number]
|
|
27
|
+
importance?: AnnouncementImportance | 'NORMAL' | 'IMPORTANT' | 'URGENT'
|
|
28
|
+
requiresAcknowledgement?: boolean
|
|
29
|
+
popup?: boolean
|
|
30
|
+
sendEmail?: boolean
|
|
31
|
+
/** Who reads it when the trigger names nobody: the whole domain, or nobody (the trigger must name them). */
|
|
32
|
+
audience?: 'DOMAIN' | 'GIVEN'
|
|
33
|
+
/** Out at once; otherwise written as a draft for its writer to look over and send (ADR-0187 정한 것 2). */
|
|
34
|
+
publish?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AnnouncementOfTrigger {
|
|
38
|
+
trigger: string
|
|
39
|
+
sourceKind: string
|
|
40
|
+
sourceId: string
|
|
41
|
+
title: string
|
|
42
|
+
body?: string
|
|
43
|
+
links?: { label: string; url: string }[]
|
|
44
|
+
audiences?: { scope: 'DOMAIN' | 'DEPARTMENT' | 'ROLE' | 'USER'; targetId: string; targetName?: string }[]
|
|
45
|
+
/** The day by which the readers are asked to acknowledge it, where the rule asks for that. */
|
|
46
|
+
acknowledgeBy?: Date | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TRIGGERS = new Map<string, AnnouncementTrigger>()
|
|
50
|
+
const RULES = new Map<string, AnnouncementTriggerRule>()
|
|
51
|
+
|
|
52
|
+
/** What a module calls at load to declare a trigger of its own. */
|
|
53
|
+
export function registerAnnouncementTrigger(trigger: AnnouncementTrigger): void {
|
|
54
|
+
TRIGGERS.set(trigger.name, trigger)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What an application calls at boot: how announcements of a trigger go out. */
|
|
58
|
+
export function configureAnnouncementTrigger(name: string, rule: AnnouncementTriggerRule): void {
|
|
59
|
+
RULES.set(name, rule)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function announcementTriggers(): (AnnouncementTrigger & { rule?: AnnouncementTriggerRule })[] {
|
|
63
|
+
return [...TRIGGERS.values()].map(one => ({ ...one, rule: RULES.get(one.name) }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Writes the announcement of a trigger in the request's transaction, its writer the request's person. Answers it, or
|
|
68
|
+
* nothing where the application set no rule for the trigger.
|
|
69
|
+
*/
|
|
70
|
+
export async function announce(context: any, of: AnnouncementOfTrigger): Promise<Announcement | null> {
|
|
71
|
+
const { domain, user, tx } = context.state as { domain: any; user: any; tx: EntityManager }
|
|
72
|
+
if (!TRIGGERS.has(of.trigger)) throw new Error(`announcement trigger '${of.trigger}' is not registered`)
|
|
73
|
+
const rule = RULES.get(of.trigger)
|
|
74
|
+
if (!rule) {
|
|
75
|
+
logger.info(`[Announcement] ${domain?.subdomain}: no rule for '${of.trigger}' - nothing written`)
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
const importance = (rule.importance as AnnouncementImportance) ?? AnnouncementImportance.NORMAL
|
|
79
|
+
const requiresAcknowledgement = !!rule.requiresAcknowledgement
|
|
80
|
+
const repository = getRepository(Announcement, tx)
|
|
81
|
+
const saved = await repository.save({
|
|
82
|
+
domain,
|
|
83
|
+
title: of.title.trim().slice(0, 200),
|
|
84
|
+
body: of.body ?? null,
|
|
85
|
+
category: rule.category ?? 'GENERAL',
|
|
86
|
+
importance,
|
|
87
|
+
pinned: false,
|
|
88
|
+
rank: rankOf({ pinned: false, importance }),
|
|
89
|
+
requiresAcknowledgement,
|
|
90
|
+
acknowledgeBy: requiresAcknowledgement ? (of.acknowledgeBy ?? null) : null,
|
|
91
|
+
popup: !!rule.popup,
|
|
92
|
+
sendEmail: !!rule.sendEmail,
|
|
93
|
+
links: (of.links ?? []).slice(0, 10),
|
|
94
|
+
trigger: of.trigger,
|
|
95
|
+
sourceKind: of.sourceKind,
|
|
96
|
+
sourceId: of.sourceId,
|
|
97
|
+
status: AnnouncementStatus.DRAFT,
|
|
98
|
+
version: 1,
|
|
99
|
+
creator: user,
|
|
100
|
+
updater: user
|
|
101
|
+
} as any)
|
|
102
|
+
const audiences = of.audiences?.length ? of.audiences : rule.audience === 'GIVEN' ? [] : [{ scope: 'DOMAIN' as const, targetId: domain.id, targetName: domain.name }]
|
|
103
|
+
for (const target of audiences)
|
|
104
|
+
await getRepository(AnnouncementAudience, tx).save({ domain, announcement: { id: saved.id }, scope: target.scope, targetId: target.targetId, targetName: target.targetName?.slice(0, 100), creator: user, updater: user } as any)
|
|
105
|
+
if (!rule.publish || !audiences.length) return saved
|
|
106
|
+
const receivers = await sendOut(saved, domain, user, tx)
|
|
107
|
+
const fresh = (await repository.findOneBy({ id: saved.id }))!
|
|
108
|
+
await tellWhenCommitted(context, fresh, 'published', receivers, true)
|
|
109
|
+
return fresh
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The announcements of a record, newest first - drafts too, the record's keepers read where it stands. */
|
|
113
|
+
export async function announcementsOfSource(domain: { id: string }, sourceKind: string, sourceIds: string[], tx?: EntityManager): Promise<Announcement[]> {
|
|
114
|
+
if (!sourceIds.length) return []
|
|
115
|
+
return await getRepository(Announcement, tx).find({ where: { domain: { id: domain.id }, sourceKind, sourceId: In(sourceIds) } as any, order: { createdAt: 'DESC' } as any })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A person's readings of announcements, by announcement. */
|
|
119
|
+
export async function receiptsOf(announcementIds: string[], userId: string, tx?: EntityManager): Promise<Map<string, AnnouncementReceipt>> {
|
|
120
|
+
if (!announcementIds.length) return new Map()
|
|
121
|
+
const rows = await getRepository(AnnouncementReceipt, tx).find({ where: { announcement: { id: In(announcementIds) }, user: { id: userId } } as any })
|
|
122
|
+
return new Map(rows.map(one => [one.announcementId!, one]))
|
|
123
|
+
}
|
|
@@ -35,6 +35,7 @@ registerEnumType(AnnouncementImportance, { name: 'AnnouncementImportance', descr
|
|
|
35
35
|
|
|
36
36
|
@Entity()
|
|
37
37
|
@Index('ix_announcement_0', (one: Announcement) => [one.domain, one.status, one.startsAt])
|
|
38
|
+
@Index('ix_announcement_1', (one: Announcement) => [one.domain, one.sourceKind, one.sourceId])
|
|
38
39
|
@ObjectType({ description: 'An announcement of a domain - written once, addressed to an audience, read and acknowledged by each of its people.' })
|
|
39
40
|
export class Announcement {
|
|
40
41
|
@PrimaryGeneratedColumn('uuid')
|
|
@@ -111,6 +112,18 @@ export class Announcement {
|
|
|
111
112
|
@Field(type => GraphQLJSON, { nullable: true, description: 'Screens it points to - [{ label, url }], a screen of the application or an https address.' })
|
|
112
113
|
links?: { label: string; url: string }[]
|
|
113
114
|
|
|
115
|
+
@Column({ nullable: true })
|
|
116
|
+
@Field({ nullable: true, description: 'The trigger that wrote it (ADR-0163 decision 7) - `compliance.regulation-adopted`; empty for one a person wrote.' })
|
|
117
|
+
trigger?: string
|
|
118
|
+
|
|
119
|
+
@Column({ nullable: true })
|
|
120
|
+
@Field({ nullable: true, description: 'The kind of record it tells of - `RegulationVersion`; set by its trigger.' })
|
|
121
|
+
sourceKind?: string
|
|
122
|
+
|
|
123
|
+
@Column({ nullable: true })
|
|
124
|
+
@Field({ nullable: true, description: 'The record it tells of; set by its trigger.' })
|
|
125
|
+
sourceId?: string
|
|
126
|
+
|
|
114
127
|
@Column('int', { default: 1 })
|
|
115
128
|
@Field(type => Int, { description: 'The version - one more each time it is changed after it was published.' })
|
|
116
129
|
version: number
|
package/server/service/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from './announcement/announcement'
|
|
3
3
|
export * from './announcement/announcement-rules'
|
|
4
4
|
export { ANNOUNCEMENT_REF_TYPE, visibleAnnouncement } from './announcement/announcement-service'
|
|
5
|
+
export * from './announcement/announcement-trigger'
|
|
5
6
|
|
|
6
7
|
/* IMPORT ENTITIES AND RESOLVERS */
|
|
7
8
|
import { entities as AnnouncementEntities, resolvers as AnnouncementResolvers } from './announcement'
|
|
@@ -36,6 +36,7 @@ function loadCompiled(modulePath: string): any {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const service = loadCompiled('service/announcement/announcement-service')
|
|
39
|
+
const triggers = loadCompiled('service/announcement/announcement-trigger')
|
|
39
40
|
const { AnnouncementResolver } = loadCompiled('service/announcement/announcement-resolver')
|
|
40
41
|
const { Announcement, AnnouncementAudience, AnnouncementReceipt, AnnouncementRevision } = loadCompiled('service/announcement/announcement')
|
|
41
42
|
|
|
@@ -269,3 +270,28 @@ describe("the reader's list - what the screen and the assistant read", () => {
|
|
|
269
270
|
await expect(list('part', [{ name: 'readerState', operator: 'eq', value: 'lost' }])).rejects.toThrow()
|
|
270
271
|
})
|
|
271
272
|
})
|
|
273
|
+
|
|
274
|
+
describe('계기 (ADR-0163 decision 7)', () => {
|
|
275
|
+
const words = { sourceKind: 'RegulationVersion', sourceId: '11111111-1111-1111-1111-111111111111', title: '취업규칙 제2판', body: '시행일 2026-12-01' }
|
|
276
|
+
const txContextOf = (who: string) => ({ state: { domain, user: people[who], tx: dataSource.manager } })
|
|
277
|
+
|
|
278
|
+
it('★ a trigger the application set no rule for writes nothing', async () => {
|
|
279
|
+
triggers.registerAnnouncementTrigger({ name: 'test.unruled', description: 'no rule' })
|
|
280
|
+
expect(await triggers.announce(txContextOf('writer'), { trigger: 'test.unruled', ...words })).toBeNull()
|
|
281
|
+
expect(await dataSource.getRepository(Announcement).count()).toBe(0)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('★ writes a draft of its record to the whole domain by the rule, found again by its record', async () => {
|
|
285
|
+
triggers.registerAnnouncementTrigger({ name: 'test.adopted', description: 'adopted' })
|
|
286
|
+
triggers.configureAnnouncementTrigger('test.adopted', { category: 'HR', importance: 'IMPORTANT', requiresAcknowledgement: true, popup: true })
|
|
287
|
+
const one = await triggers.announce(txContextOf('writer'), { trigger: 'test.adopted', ...words })
|
|
288
|
+
expect(one).toMatchObject({ status: 'DRAFT', category: 'HR', importance: 'IMPORTANT', requiresAcknowledgement: true, popup: true, trigger: 'test.adopted', sourceKind: 'RegulationVersion' })
|
|
289
|
+
expect((await service.audiencesOf(one.id)).map((target: any) => [target.scope, target.targetId])).toEqual([['DOMAIN', domain.id]])
|
|
290
|
+
expect(told).toHaveLength(0)
|
|
291
|
+
expect((await triggers.announcementsOfSource(domain, 'RegulationVersion', [words.sourceId])).map((found: any) => found.id)).toEqual([one.id])
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('a trigger nobody registered is a mistake of the module, not a quiet nothing', async () => {
|
|
295
|
+
await expect(triggers.announce(txContextOf('writer'), { trigger: 'test.unknown', ...words })).rejects.toThrow(/not registered/)
|
|
296
|
+
})
|
|
297
|
+
})
|