@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/announcement",
3
- "version": "10.1.250",
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.250",
40
- "@things-factory/attachment-base": "^10.1.250",
41
- "@things-factory/auth-base": "^10.1.250",
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.250",
44
- "@things-factory/notification": "^10.1.250",
45
- "@things-factory/organization": "^10.1.250",
46
- "@things-factory/shell": "^10.1.247",
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": "4fdb6d24ef24f43d9e158d4c6d6396ad3097bb9e"
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
@@ -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
+ })