@things-factory/announcement 10.1.265 → 10.1.269

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.265",
3
+ "version": "10.1.269",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -30,24 +30,24 @@
30
30
  "@operato/data-grist": "^10.29.69",
31
31
  "@operato/graphql": "^10.29.69",
32
32
  "@operato/i18n": "^10.0.0",
33
- "@operato/input": "^10.29.43",
33
+ "@operato/input": "^10.29.79",
34
34
  "@operato/layout": "^10.29.49",
35
35
  "@operato/popup": "^10.29.39",
36
36
  "@operato/shell": "^10.29.69",
37
37
  "@operato/styles": "^10.29.1",
38
38
  "@operato/utils": "^10.0.0",
39
- "@things-factory/ai-assistant": "^10.1.265",
40
- "@things-factory/attachment-base": "^10.1.265",
41
- "@things-factory/auth-base": "^10.1.265",
39
+ "@things-factory/ai-assistant": "^10.1.269",
40
+ "@things-factory/attachment-base": "^10.1.269",
41
+ "@things-factory/auth-base": "^10.1.269",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/i18n-base": "^10.1.265",
44
- "@things-factory/notification": "^10.1.265",
45
- "@things-factory/organization": "^10.1.265",
46
- "@things-factory/shell": "^10.1.265",
43
+ "@things-factory/i18n-base": "^10.1.269",
44
+ "@things-factory/notification": "^10.1.269",
45
+ "@things-factory/organization": "^10.1.269",
46
+ "@things-factory/shell": "^10.1.267",
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": "c192450de7dba8d27ec97c34e57d9b9860ebb317"
52
+ "gitHead": "d4fbddbb97458c60e7c32a2b3c8e4d04bab95464"
53
53
  }
@@ -13,7 +13,7 @@ import { In, IsNull, MoreThan, Not } from 'typeorm'
13
13
  import { Attachment, createAttachment, deleteAttachment, deleteAttachmentsByRef } from '@things-factory/attachment-base'
14
14
  import { getDomainAssignees, User } from '@things-factory/auth-base'
15
15
  import { audienceTargetRefusal, audienceUserIds, Department, departmentTreeOf, domainRolesOf } from '@things-factory/organization'
16
- import { getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
16
+ import { getQueryBuilderFromListParams, getRepository, ListParam, tellChange } from '@things-factory/shell'
17
17
 
18
18
  import { Announcement, AnnouncementAudience, AnnouncementImportance, AnnouncementReceipt, AnnouncementRevision, AnnouncementStatus } from './announcement'
19
19
  import { inputRefusal, isCurrent, owedReminder, periodOf, popupDue, publishedStatusOf, publishRefusal, rankOf, readerNarrowingOf, readerStateOf } from './announcement-rules'
@@ -26,6 +26,7 @@ import {
26
26
  memberOf,
27
27
  reachingIds,
28
28
  sendOut,
29
+ tellRosterChange,
29
30
  tellWhenCommitted,
30
31
  visibleAnnouncement
31
32
  } from './announcement-service'
@@ -243,8 +244,11 @@ export class AnnouncementResolver {
243
244
  const patch: any = {}
244
245
  if (!receipt?.readAt) patch.readAt = now
245
246
  if (acknowledge && !receipt?.acknowledgedAt) patch.acknowledgedAt = now
247
+ if (!Object.keys(patch).length) return visible.announcement
246
248
  if (!receipt) await repository.save({ domain, announcement: { id }, user: { id: user.id }, listed: false, ...patch, creator: user, updater: user } as any)
247
- else if (Object.keys(patch).length) await repository.update(receipt.id, { ...patch, updater: user })
249
+ else await repository.update(receipt.id, { ...patch, updater: user })
250
+ /* The reader's other windows - their badge asks again. */
251
+ tellChange(context as any, { kind: 'announcement', id, userIds: [user.id] })
248
252
  return visible.announcement
249
253
  }
250
254
 
@@ -375,6 +379,7 @@ export class AnnouncementResolver {
375
379
  if (notifyAgain) await getRepository(AnnouncementReceipt, tx).update({ announcement: { id }, listed: true } as any, { notifiedAt: new Date() } as any)
376
380
  /* Told again, everyone hears it - the channel too; people the audience newly names are told one by one. */
377
381
  await tellWhenCommitted(context, fresh, notifyAgain ? 'changed' : 'published', receivers.filter(one => one !== user.id), !!notifyAgain)
382
+ await tellRosterChange(context, id, tx)
378
383
  return fresh
379
384
  }
380
385
 
@@ -395,6 +400,7 @@ export class AnnouncementResolver {
395
400
  const receivers = await sendOut(announcement, domain, user, tx)
396
401
  const fresh = (await repository.findOneBy({ id }))!
397
402
  await tellWhenCommitted(context, fresh, 'published', receivers, true)
403
+ await tellRosterChange(context, id, tx)
398
404
  return fresh
399
405
  }
400
406
 
@@ -408,6 +414,7 @@ export class AnnouncementResolver {
408
414
  if (!reason?.trim()) refuse(context, 'no-reason', 'say why it is withdrawn.')
409
415
  const repository = getRepository(Announcement, tx)
410
416
  await repository.update(id, { status: AnnouncementStatus.WITHDRAWN, withdrawnAt: new Date(), withdrawReason: reason.trim().slice(0, 500), updater: user } as any)
417
+ await tellRosterChange(context, id, tx)
411
418
  return (await repository.findOneBy({ id }))!
412
419
  }
413
420
 
@@ -2,14 +2,14 @@
2
2
  * What the doors of announcements do with the database (ADR-0163 decisions 1-5): who may read one, who is on its roster,
3
3
  * sending it out, telling people of it, and publishing the scheduled ones when their start comes.
4
4
  */
5
- import { EntityManager, In, IsNull, LessThanOrEqual } from 'typeorm'
5
+ import { Between, EntityManager, In, IsNull, LessThanOrEqual } from 'typeorm'
6
6
 
7
7
  import { requestHolds, User, UserStatus } from '@things-factory/auth-base'
8
8
  import { logger } from '@things-factory/env'
9
9
  import { translatorFor } from '@things-factory/i18n-base/translator'
10
10
  import { keepNoticeDelivery, noticeDeliveryOf, noticeLinkOf, sendNotice, sendNoticeWhenCommitted, type Notice } from '@things-factory/notification'
11
11
  import { audienceMemberOf, audienceUserIds, type AudienceMember } from '@things-factory/organization'
12
- import { Domain, getDataSource, getRepository } from '@things-factory/shell'
12
+ import { Domain, getDataSource, getRepository, tellChange } from '@things-factory/shell'
13
13
 
14
14
  import { Announcement, AnnouncementAudience, AnnouncementReceipt, AnnouncementRevision, AnnouncementStatus } from './announcement'
15
15
  import { broadcastOf, noticeWordsOf, viewAddressOf, type NoticeKind, type Say } from './announcement-rules'
@@ -182,6 +182,16 @@ export async function tellWhenCommitted(context: any, one: Announcement, kind: N
182
182
  )
183
183
  }
184
184
 
185
+ /**
186
+ * Tells everyone on its roster - those it names now and those it named before - that it changed, so their badge and
187
+ * list ask again (shell's myChange). No notice and no words: a notice is only for what people are to be told of.
188
+ * `context` is a request's, or `{ state: { domain } }` outside one.
189
+ */
190
+ export async function tellRosterChange(context: any, announcementId: string, tx?: EntityManager): Promise<void> {
191
+ const receipts = await getRepository(AnnouncementReceipt, tx).find({ where: { announcement: { id: announcementId } } as any })
192
+ tellChange(context, { kind: 'announcement', id: announcementId, userIds: receipts.map(one => one.userId) })
193
+ }
194
+
185
195
  /**
186
196
  * Publishes the scheduled announcements whose start has come. Each is taken by one update from SCHEDULED, so a server
187
197
  * among several that loses the race does not send it twice.
@@ -203,6 +213,7 @@ export async function publishDue(now = new Date()): Promise<number> {
203
213
  })
204
214
  if (!receivers) continue
205
215
  published++
216
+ await tellRosterChange({ state: { domain: one.domain } }, one.id)
206
217
  /* No request, so no address to put on a mail button - the mail says where to find it instead. */
207
218
  const people = await receiversOf(receivers)
208
219
  const broadcast = broadcastOf(await audiencesOf(one.id), one.domain?.id)
@@ -214,11 +225,33 @@ export async function publishDue(now = new Date()): Promise<number> {
214
225
  return published
215
226
  }
216
227
 
228
+ /**
229
+ * Tells the rosters of the announcements whose period ended after `since` and by `now` - nothing else moves their
230
+ * badge then. Each server that runs it tells them; a second telling only asks the count again.
231
+ */
232
+ export async function tellEnded(since: Date, now = new Date()): Promise<number> {
233
+ const ended = await getRepository(Announcement).find({
234
+ where: { status: AnnouncementStatus.PUBLISHED, endsAt: Between(new Date(since.getTime() + 1), now), deletedAt: IsNull() } as any,
235
+ relations: ['domain']
236
+ })
237
+ for (const one of ended) await tellRosterChange({ state: { domain: one.domain } }, one.id)
238
+ return ended.length
239
+ }
240
+
217
241
  let timer: ReturnType<typeof setInterval> | undefined
218
242
 
219
- /** Looks for scheduled announcements every minute. The timer never keeps the process alive. */
243
+ /**
244
+ * Every minute, publishes the scheduled announcements whose start has come and tells the rosters of those whose period
245
+ * ended. The timer never keeps the process alive.
246
+ */
220
247
  export function schedulePublishing(): void {
221
248
  if (timer) return
222
- timer = setInterval(() => void publishDue().catch(error => logger.error('scheduled announcements', error)), 60 * 1000)
249
+ let since = new Date()
250
+ timer = setInterval(() => {
251
+ const now = new Date()
252
+ void publishDue(now).catch(error => logger.error('scheduled announcements', error))
253
+ void tellEnded(since, now).catch(error => logger.error('ended announcements', error))
254
+ since = now
255
+ }, 60 * 1000)
223
256
  timer.unref?.()
224
257
  }
@@ -14,7 +14,7 @@ import { getRepository } from '@things-factory/shell'
14
14
 
15
15
  import { Announcement, AnnouncementAudience, AnnouncementImportance, AnnouncementReceipt, AnnouncementStatus } from './announcement'
16
16
  import { ANNOUNCEMENT_CATEGORIES, rankOf } from './announcement-rules'
17
- import { sendOut, tellWhenCommitted } from './announcement-service'
17
+ import { sendOut, tellRosterChange, tellWhenCommitted } from './announcement-service'
18
18
 
19
19
  export interface AnnouncementTrigger {
20
20
  /** `compliance.regulation-adopted` - the module's name, a dot, the fact. */
@@ -106,6 +106,7 @@ export async function announce(context: any, of: AnnouncementOfTrigger): Promise
106
106
  const receivers = await sendOut(saved, domain, user, tx)
107
107
  const fresh = (await repository.findOneBy({ id: saved.id }))!
108
108
  await tellWhenCommitted(context, fresh, 'published', receivers, true)
109
+ await tellRosterChange(context, saved.id, tx)
109
110
  return fresh
110
111
  }
111
112
 
@@ -297,3 +297,60 @@ describe('계기 (ADR-0163 decision 7)', () => {
297
297
  await expect(triggers.announce(txContextOf('writer'), { trigger: 'test.unknown', ...words })).rejects.toThrow(/not registered/)
298
298
  })
299
299
  })
300
+
301
+ /*
302
+ * The badge moves by itself for everyone it concerns (2026-11-10) - without a notice to anyone. Before, only a new
303
+ * announcement's notice moved it, and only for those told: the person's other windows after reading, the readers
304
+ * after a withdrawal or a changed audience, and everyone when its period ended stayed where they were until the next
305
+ * sign-in.
306
+ */
307
+ describe('who hears that it changed', () => {
308
+ const heard: any[] = []
309
+ beforeEach(() => {
310
+ heard.length = 0
311
+ jest.spyOn(shell.pubsub, 'publish').mockImplementation(((topic: string, payload: any) => {
312
+ if (topic === 'my-change' && payload.kind === 'announcement') heard.push([payload.id, [...payload.userIds].sort()])
313
+ }) as any)
314
+ })
315
+ afterEach(() => jest.restoreAllMocks())
316
+
317
+ const resolver = new AnnouncementResolver()
318
+ const sales = () => draft([{ scope: 'DEPARTMENT', targetId: departments.sales.id }, { scope: 'ROLE', targetId: hr.id }])
319
+
320
+ it('★ going out, its whole roster - the publisher too; withdrawn, the same; no notice for either but the first', async () => {
321
+ const one = await sales()
322
+ await resolver.publishAnnouncement(one.id, contextOf('writer', true))
323
+ await resolver.withdrawAnnouncement(one.id, '잘못 보냄', contextOf('writer', true))
324
+ expect(heard).toEqual([
325
+ [one.id, ids('writer', 'part', 'team')],
326
+ [one.id, ids('writer', 'part', 'team')]
327
+ ])
328
+ })
329
+
330
+ it('★ read or acknowledged, the reader alone, in each of their windows - and not again when nothing changed', async () => {
331
+ const one = await sales()
332
+ await resolver.publishAnnouncement(one.id, contextOf('writer', true))
333
+ heard.length = 0
334
+ await resolver.readAnnouncement(one.id, contextOf('part'))
335
+ await resolver.readAnnouncement(one.id, contextOf('part'))
336
+ expect(heard).toEqual([[one.id, ids('part')]])
337
+ })
338
+
339
+ it('★ a scheduled one going out at its start, its roster', async () => {
340
+ const one = await draft([{ scope: 'DEPARTMENT', targetId: departments['sales-1'].id }], { status: 'SCHEDULED', startsAt: new Date(Date.now() - 1000), publisher: people.writer })
341
+ await service.publishDue()
342
+ expect(heard).toEqual([[one.id, ids('part')]])
343
+ })
344
+
345
+ it('★ its period ending, its roster - once, by the minute that saw it end', async () => {
346
+ const one = await sales()
347
+ await resolver.publishAnnouncement(one.id, contextOf('writer', true))
348
+ const ends = new Date(Date.now() + 30 * 1000)
349
+ await dataSource.getRepository(Announcement).update(one.id, { endsAt: ends })
350
+ heard.length = 0
351
+ await service.tellEnded(new Date(ends.getTime() - 60 * 1000), new Date(ends.getTime() - 1))
352
+ expect(heard).toEqual([])
353
+ await service.tellEnded(new Date(ends.getTime() - 1), new Date(ends.getTime() + 60 * 1000))
354
+ expect(heard).toEqual([[one.id, ids('writer', 'part', 'team')]])
355
+ })
356
+ })