@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/client/announcement-watch.ts +40 -20
- package/client/pages/announcement-editor.ts +3 -3
- package/dist-client/announcement-watch.js +32 -17
- package/dist-client/announcement-watch.js.map +1 -1
- package/dist-client/pages/announcement-editor.d.ts +1 -0
- package/dist-client/pages/announcement-editor.js +3 -3
- package/dist-client/pages/announcement-editor.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/announcement/announcement-resolver.js +8 -1
- package/dist-server/service/announcement/announcement-resolver.js.map +1 -1
- package/dist-server/service/announcement/announcement-service.d.ts +15 -1
- package/dist-server/service/announcement/announcement-service.js +36 -2
- package/dist-server/service/announcement/announcement-service.js.map +1 -1
- package/dist-server/service/announcement/announcement-trigger.js +1 -0
- package/dist-server/service/announcement/announcement-trigger.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/server/service/announcement/announcement-resolver.ts +9 -2
- package/server/service/announcement/announcement-service.ts +37 -4
- package/server/service/announcement/announcement-trigger.ts +2 -1
- package/test/announcement-persistence.test.ts +57 -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.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.
|
|
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.
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
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.
|
|
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.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": "
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
})
|